fix(ui): walk the eleven dangling-style reports — two were real
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 45s

#2444. Each needed reading rather than a batch fix, and the split was 2 real
losses, 4 false reports, 5 wrappers that are bare on purpose.

REAL:

  .system-card   was a flex row, and every child still says so —
                 .system-swatch and .system-actions are flex-shrink: 0,
                 .system-body and .system-form--inline are flex: 1.
                 align-items: flex-start is why the swatch carries
                 margin-top: 0.3rem: nudged onto the first line of text.
  .systems-list  no rule AT ALL, so the systems list rendered with browser
                 bullets and indent. Invisible to the check — see below.
  .graph-embed   the panel is a flex column whose header is flex-shrink: 0,
                 so this is the item that takes the remaining height. Without
                 it the `height: 100%` on the line below resolves against auto
                 and does nothing, which left the comment above it specifying
                 a rule that could not work.

FALSE REPORTS, and the checker was wrong rather than the code:

`.pane.empty` and `td.num` are base rules for the element that carries those
classes — the check read any compound with more than a lone class as a
modifier. It now records a compound's whole class SET and clears an element
carrying all of them, which is exact: recording the classes individually would
have cleared `.pane` everywhere on the strength of a rule that only applies
alongside `.empty`. Four reports gone, and a check with false reports is one
that gets skimmed.

BARE ON PURPOSE — .rb, .topic-group, .new-topic, .sub-list, .dash-head, and
both .detail-row rows. Each namespaces descendant rules and assumes nothing
about layout, which is the tell that separates them from a deleted base. All
seven now carry a comment saying so, so the next reader doesn't re-litigate
them and a NEW entry in the report means something actually changed.

Also recorded in the script: it cannot see a class with no rule anywhere, since
that is indistinguishable from a semantic-only hook. `.systems-list` was found
by reading the file beside a class that WAS half-styled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-08-05 09:26:35 -04:00
co-authored by Claude Opus 5
parent 46271ccaa7
commit a6d6550483
9 changed files with 111 additions and 14 deletions
@@ -387,6 +387,41 @@ async function confirmDelete() {
.system-textarea { resize: vertical; } .system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; } .system-form-actions { display: flex; gap: 0.4rem; }
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
The card WAS a flex row and every child still says so — `.system-swatch`
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and
indent — invisible to the dangling-style check, which can only see a class
that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook.
Surface values match `.system-form` above, which is the same card shape in
this file and the reason they can be recovered rather than guessed. */
.systems-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.system-card {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.system-card--archived { opacity: 0.6; } .system-card--archived { opacity: 0.6; }
.system-swatch { .system-swatch {
@@ -53,6 +53,12 @@ watch(() => props.projectId, load);
font-size: 0.9em; opacity: 0.7; font-size: 0.9em; opacity: 0.7;
text-transform: uppercase; letter-spacing: 0.05em; text-transform: uppercase; letter-spacing: 0.05em;
} }
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
below, and its children carry their own spacing (the h4 keeps the UA
margin-top that separates one rulebook group from the next). Nothing here
assumes a flex or grid parent, which is the tell that distinguishes this
from a base rule someone deleted (#2444). Stated so the next reader doesn't
re-open the question. */
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; } .rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
.rb h5 { .rb h5 {
font-size: 0.8em; opacity: 0.7; font-size: 0.8em; opacity: 0.7;
@@ -349,6 +349,10 @@ select {
.applicable { margin-top: 2rem; } .applicable { margin-top: 2rem; }
.rb-group { margin-bottom: 1.5rem; } .rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; } .rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
one and the flex row further down), with the h5's own margin-top doing the
separating. Its children assume nothing about it, which is what tells it
apart from a base rule someone deleted (#2444). */
.topic-group h5 { .topic-group h5 {
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.75rem; margin-top: 0.75rem;
@@ -135,6 +135,11 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg); } li.active { background: var(--color-primary-bg); }
li:hover { background: var(--color-hover); } li:hover { background: var(--color-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
button-or-form whose children style themselves; the second is a `<ul>`, and
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
.new-topic input { .new-topic input {
width: 100%; margin-bottom: 0.5rem; width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg); color: inherit; background: var(--color-bg); color: inherit;
+3
View File
@@ -176,6 +176,9 @@ onMounted(async () => {
<style scoped> <style scoped>
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; } .dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
/* `.dash-head` is deliberately bare: a block header whose two children carry
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
assumes a flex parent, which is the tell for a deleted rule (#2444). */
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; } .dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; } .dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; } .dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
+11
View File
@@ -936,6 +936,17 @@ onUnmounted(() => {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
`flex-shrink: 0`, so this is the item that takes the remaining height — and
without it the `height: 100%` below resolves against `auto` and does
nothing, which made the comment underneath a spec for a rule that could not
work. `min-height: 0` is the companion that lets a flex item shrink under
its content instead of overflowing the panel. */
.graph-embed {
flex: 1;
min-height: 0;
}
/* Override GraphView's 100vh height so it fills the panel instead */ /* Override GraphView's 100vh height so it fills the panel instead */
.graph-embed :deep(.graph-page) { .graph-embed :deep(.graph-page) {
height: 100%; height: 100%;
+2
View File
@@ -454,6 +454,8 @@ function clearFilters() {
} }
/* Detail row */ /* Detail row */
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
cells don't carry, and the row exists to scope the rule below (#2444). */
.detail-row td { .detail-row td {
padding: 0 0.75rem 0.75rem; padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
+2
View File
@@ -2937,6 +2937,8 @@ function formatUserDate(iso: string): string {
.cell-status { font-family: monospace; font-size: 0.85rem; } .cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; } .cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--color-danger); } .text-error { color: var(--color-danger); }
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style
that its cells don't carry (#2444). */
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); } .detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; } .detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
.detail-json { .detail-json {
+43 -14
View File
@@ -33,11 +33,22 @@ class with only modifier rules is a deletion that went half-way.
Descendant selectors count as a base — `.panel .row {}` styles `.row` — because Descendant selectors count as a base — `.panel .row {}` styles `.row` — because
from the element's side there is no difference. Only the LAST compound of a from the element's side there is no difference. Only the LAST compound of a
selector is what it styles. selector is what it styles, and a compound's whole class SET is what it
requires: `.pane.empty` and `td.num` are base rules for the element carrying
those classes, not modifiers. Reading them as modifiers cost this check four
false reports on its first run, and a check with false reports is one that gets
skimmed.
REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate, REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate,
so this cannot be a gate without a suppression mechanism nobody would maintain. so this cannot be a gate without a suppression mechanism nobody would maintain.
A count that grows is the signal to look. A count that grows is the signal to look. Where a wrapper is bare on purpose,
say so in a comment beside its descendant rules — the remaining reports here
all carry one, so a new entry means something changed.
KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because
it cannot be told from a semantic-only hook. `.systems-list` had lost its
entire rule and was rendering with browser bullets; it was found by reading the
file next to a class that WAS half-styled, not by this check.
INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or
a convention; point it at any Vue tree. a convention; point it at any Vue tree.
@@ -57,7 +68,6 @@ CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
STATIC_CLASS = re.compile(r'(?<![:\w-])class="([^"{}\[\]]*)"') STATIC_CLASS = re.compile(r'(?<![:\w-])class="([^"{}\[\]]*)"')
SELECTOR = re.compile(r"([^{}]+)\{") SELECTOR = re.compile(r"([^{}]+)\{")
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)") CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")
BARE_CLASS = re.compile(r"\.([\w-]+)\Z")
def shared_classes(sheets: list[pathlib.Path]) -> set[str]: def shared_classes(sheets: list[pathlib.Path]) -> set[str]:
@@ -69,24 +79,40 @@ def shared_classes(sheets: list[pathlib.Path]) -> set[str]:
return names return names
def based_classes(css: str) -> set[str]: def base_class_sets(css: str) -> list[frozenset[str]]:
"""Classes this stylesheet gives a base rule to. """Class combinations this stylesheet gives a base rule to.
The last compound of a selector is what the rule styles: in The last compound of a selector is what the rule styles: in
`.panel .row:hover` that is `.row:hover`, a modifier — but in `.panel .row` `.panel .row:hover` that is `.row:hover`, a state — but in `.panel .row` it
it is `.row`, a base. So a selector qualifies only when its final compound is `.row`, a base.
is a lone class with nothing appended.
A compound may carry more than one class, and a type selector alongside
them. `.pane.empty` and `td.num` are both base rules for the element that
matches, so each is recorded as the SET of classes it requires; an element
is styled when it carries all of them. Recording the classes individually
instead would clear `.pane` everywhere on the strength of a rule that only
ever applies with `.empty` — precision matters more here than reach, since
a missed base is a false report and a wrong one is a defect gone quiet.
A compound with no class at all (`ul`, `li`) is skipped: it styles by tag,
which this cannot verify without parsing the template's elements, and an
empty set would clear every element in the file.
""" """
out: set[str] = set() out: list[frozenset[str]] = []
for selector in SELECTOR.findall(css): for selector in SELECTOR.findall(css):
for part in selector.split(","): for part in selector.split(","):
part = part.strip() part = part.strip()
if not part or part.startswith("@"): if not part or part.startswith("@"):
continue continue
last = re.split(r"[\s>+~]+", part)[-1] last = re.split(r"[\s>+~]+", part)[-1]
match = BARE_CLASS.fullmatch(last) # A pseudo-class, pseudo-element or attribute selector makes it a
if match: # state or a variant, not the element's base appearance.
out.add(match.group(1)) if ":" in last or "[" in last:
continue
names = CLASS_TOKEN.findall(last)
# Everything outside the class tokens must be a bare type selector.
if names and re.fullmatch(r"[A-Za-z][\w-]*|\*|", CLASS_TOKEN.sub("", last)):
out.append(frozenset(names))
return out return out
@@ -97,7 +123,7 @@ def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
if not css.strip(): if not css.strip():
return [] return []
based = based_classes(css) base_sets = base_class_sets(css)
mentioned = set(CLASS_TOKEN.findall(css)) mentioned = set(CLASS_TOKEN.findall(css))
findings: list[tuple[str, list[str]]] = [] findings: list[tuple[str, list[str]]] = []
@@ -105,7 +131,10 @@ def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)] names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
if not names: if not names:
continue continue
if any(n in based or n in shared for n in names): carried = set(names)
if any(carried >= required for required in base_sets):
continue
if any(n in shared for n in names):
continue continue
dangling = [n for n in names if n in mentioned] dangling = [n for n in names if n in mentioned]
if dangling: if dangling: