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
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:
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user