diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index ad6a9d6..bcbde33 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -178,6 +178,15 @@ jobs:
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
+ # Dangling styles: an element whose classes have only modifier rules and
+ # no base — a deleted CSS rule that left its `:hover` behind. Two shipped
+ # this way (a link rendering as raw browser blue, a flex row whose parent
+ # was gone so every child stacked). Neither is visible to vue-tsc; a dead
+ # style typechecks perfectly. Reported, not gated — a bare wrapper is
+ # legitimate, so the signal is the count growing.
+ - name: Dangling style check
+ run: python3 scripts/check_dangling_styles.py
+
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
diff --git a/frontend/src/assets/components.css b/frontend/src/assets/components.css
index 2b9c148..df298e5 100644
--- a/frontend/src/assets/components.css
+++ b/frontend/src/assets/components.css
@@ -36,7 +36,8 @@
.btn-secondary,
.btn-ghost,
.btn-danger,
-.btn-danger-outline {
+.btn-danger-outline,
+.btn-cta {
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
border: none;
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
@@ -46,6 +47,14 @@
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
+ /* So a button carrying an icon centres it against the label without each
+ caller re-inventing the flex row — the shape they all reached for
+ separately, and the reason icon buttons sat a pixel or two off. */
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--fs-space-2);
+ text-decoration: none;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
@@ -58,7 +67,8 @@
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
-.btn-danger-outline:disabled {
+.btn-danger-outline:disabled,
+.btn-cta:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
@@ -67,7 +77,8 @@
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible,
-.btn-danger-outline:focus-visible {
+.btn-danger-outline:focus-visible,
+.btn-cta:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
@@ -150,6 +161,25 @@
color: var(--fs-text-on-action);
}
+/* The one place the accent is allowed on a button: a deliberate brand moment,
+ * never an ordinary action. The system carries `--fs-gradient-cta` and
+ * `--fs-glow-cta` for exactly this and nothing else was using them.
+ *
+ * It exists because ProjectView's Workspace link WAS this button, defined in a
+ * scoped block that the migration deleted — leaving a `:hover` rule with no
+ * base and a link that rendered as raw browser blue. A variant living in one
+ * view is a variant waiting to be deleted by someone tidying another; this is
+ * the shared home so the next sweep can't strand it. */
+.btn-cta {
+ background: var(--fs-gradient-cta);
+ color: var(--fs-text-on-action);
+ box-shadow: var(--fs-glow-cta);
+ text-decoration: none;
+}
+.btn-cta:not(:disabled):hover {
+ box-shadow: var(--fs-glow-cta-hover);
+}
+
/* --- size modifiers ------------------------------------------------------
*
* THREE sizes, because the app genuinely has three. Measured across the ~100
@@ -184,7 +214,7 @@
/* Full width, for a form's single submitting action — the auth screens. Width
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
.btn-block {
- display: block;
+ display: flex; /* not `block` — the shared shape centres with flex */
width: 100%;
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
because a full-width button
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 5ce2c85..c40d0f7 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -128,16 +128,31 @@ router.afterEach(() => {
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
position: relative;
}
+/* Three tracks, not a flex row with an absolutely-centred overlay.
+ *
+ * The pill bar used to be `position: absolute; left: 50%`, which meant it did
+ * not participate in layout: when the header ran out of room it OVERLAPPED the
+ * brand and the utility cluster rather than pushing them, and nothing wrapped
+ * or scrolled to signal it. A sixth link reached that point at ~1270px, which
+ * is an ordinary window on any monitor.
+ *
+ * `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
+ * neither side can be squeezed below its content, and the two side tracks stay
+ * equal to each other — which is what keeps the bar centred in the viewport
+ * rather than merely centred in the leftover space. Overflow becomes the
+ * header growing, not two things sharing pixels. */
.nav {
padding: 0.6rem 1.5rem;
- display: flex;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
align-items: center;
- justify-content: space-between;
+ gap: 0.75rem;
position: relative;
}
/* Left — brand */
.nav-brand {
+ justify-self: start;
display: flex;
align-items: center;
gap: 0.45rem;
@@ -155,9 +170,7 @@ router.afterEach(() => {
/* Center — pill bar */
.nav-center {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
+ justify-self: center;
display: flex;
align-items: center;
}
@@ -172,10 +185,12 @@ router.afterEach(() => {
/* Right */
.nav-right {
+ justify-self: end;
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
+ min-width: 0;
}
.nav-link {
@@ -268,6 +283,12 @@ router.afterEach(() => {
font-size: 0.85rem;
color: var(--color-text-secondary);
font-weight: 500;
+ /* The widest thing on the right and the only one that can give: a long
+ username shouldn't be what decides where the nav bar sits. */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 12ch;
}
.admin-badge {
font-size: 0.65rem;
@@ -342,13 +363,12 @@ router.afterEach(() => {
margin-top: 0.25rem;
}
-/* The pill bar is absolutely centred, so when the header runs out of room it
- OVERLAPS the brand and the utility cluster rather than pushing them — nothing
- wraps, it just collides. Six primary links reach that point sooner than five
- did, so reclaim the width here instead of leaving one out of the bar.
- The wordmark goes first: the logo beside it says the same thing and is still
- the link home. */
-@media (max-width: 1150px) {
+/* The grid above means running out of room can no longer cause a collision —
+ but it can still make the header wider than the window, and a horizontally
+ scrolling header is its own defect. So shed width before that happens. The
+ wordmark goes first: the logo beside it says the same thing and is still the
+ link home. */
+@media (max-width: 1280px) {
.brand-text {
display: none;
}
diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue
index b7b963d..4a4274d 100644
--- a/frontend/src/components/SystemsSection.vue
+++ b/frontend/src/components/SystemsSection.vue
@@ -387,6 +387,41 @@ async function confirmDelete() {
.system-textarea { resize: vertical; }
.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-swatch {
diff --git a/frontend/src/components/TokenPreview.vue b/frontend/src/components/TokenPreview.vue
index 4accb5e..a2d5e5b 100644
--- a/frontend/src/components/TokenPreview.vue
+++ b/frontend/src/components/TokenPreview.vue
@@ -133,12 +133,13 @@ function ruleWidth(value: string): string {
+ {{ s.rendered }}
{{ s.name }}
- {{ s.declared || "—" }}
- {{ s.purpose }}
+ {{ s.declared || "—" }}
+ {{ s.purpose }}
@@ -199,13 +201,16 @@ function ruleWidth(value: string): string {
color: var(--color-text-muted);
}
-.tp-group { margin-bottom: var(--fs-space-5); }
+.tp-group { margin-bottom: var(--fs-space-6); }
.tp-group-heading {
- text-transform: capitalize;
- font-size: var(--fs-size-label);
- color: var(--color-text-secondary);
- margin-bottom: var(--fs-space-2);
+ text-transform: uppercase;
+ letter-spacing: var(--fs-tracking-tiny);
+ font-size: var(--fs-size-tiny);
+ color: var(--color-text-muted);
+ margin: 0 0 var(--fs-space-3);
+ padding-bottom: var(--fs-space-2);
+ border-bottom: var(--fs-border);
}
.tp-grid {
@@ -213,8 +218,8 @@ function ruleWidth(value: string): string {
padding: 0;
margin: 0;
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
- gap: var(--fs-space-3);
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+ gap: var(--fs-space-4) var(--fs-space-3);
}
.tp-item {
@@ -224,44 +229,85 @@ function ruleWidth(value: string): string {
gap: 0.15rem;
}
-/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid. */
+/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
+ *
+ * The stage itself is plain. An earlier version put the checkerboard here, so
+ * every specimen — including opaque colours and plain text — sat inside a
+ * frame of checks, and the pattern read as the loudest thing on the page. The
+ * checks belong to the ONE case that needs them: a colour that might be
+ * translucent. */
.tp-specimen {
- height: 2.75rem;
+ height: 2.5rem;
display: flex;
align-items: center;
- border: 1px solid var(--color-border);
border-radius: var(--fs-radius-sm);
- padding: 0 var(--fs-space-2);
+ padding: var(--fs-space-1);
overflow: hidden;
- /* Checks show through anything translucent — a 15% tint over a solid card
- would otherwise look opaque and read as the wrong colour. */
- background:
- repeating-conic-gradient(var(--color-surface) 0% 25%, var(--color-bg) 0% 50%)
- 0 0 / 12px 12px;
+ background: var(--color-bg-secondary);
+}
+
+/* Text-bearing specimens get no box at all — a border around a value is a
+ frame around nothing, which is most of what made the grid feel busy. */
+.tp-specimen.is-plain,
+.tp-specimen.is-length,
+.tp-specimen.is-font {
+ background: none;
+ padding: 0 var(--fs-space-1);
+}
+
+/* Checks UNDER the colour, not around it: an opaque value hides them
+ completely, and a 15% tint shows exactly as much of them as it should.
+ Layering the fill as a gradient is what lets one element do both. */
+.tp-swatch {
+ /* Declared here, overridden inline per swatch. Two reasons it is a real
+ default rather than a formality: a token that resolves to nothing renders
+ as bare checks instead of an invalid gradient, and a custom property that
+ exists ONLY as an inline style is invisible to the CI token check — which
+ reads it as an unresolvable reference, correctly, since nothing in any
+ stylesheet declares it. */
+ --tp-fill: transparent;
+ width: 100%;
+ height: 100%;
+ border-radius: calc(var(--fs-radius-sm) - 2px);
+ background-image:
+ linear-gradient(var(--tp-fill), var(--tp-fill)),
+ repeating-conic-gradient(
+ var(--color-border) 0% 25%,
+ var(--color-bg-secondary) 0% 50%
+ );
+ background-size: auto, 10px 10px;
}
-.tp-swatch,
.tp-surface {
width: 100%;
- height: 1.75rem;
- border-radius: calc(var(--fs-radius-sm) - 1px);
+ height: 100%;
+ border-radius: calc(var(--fs-radius-sm) - 2px);
+ background: var(--color-bg-card);
}
-.tp-surface { background: var(--color-surface); }
-
.tp-rule-wrap {
width: 100%;
display: flex;
align-items: center;
+ gap: var(--fs-space-2);
+ min-width: 0;
}
.tp-rule {
- height: 0.5rem;
+ height: 0.4rem;
min-width: 1px;
+ flex: none;
background: var(--color-primary-solid);
border-radius: 999px;
}
+.tp-rule-label {
+ font-family: var(--fs-font-mono);
+ font-size: var(--fs-size-tiny);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+}
+
.tp-font {
font-size: 1.4rem;
color: var(--color-text);
@@ -283,16 +329,34 @@ function ruleWidth(value: string): string {
font-style: italic;
}
+/* One line each, with the full text on hover.
+ *
+ * These wrapped freely at first, so a card was two lines tall or five depending
+ * on how long its `color-mix()` happened to be, and the grid lost any rhythm —
+ * which is most of what "messy" was. A derived value is not something anyone
+ * reads character by character in a gallery; it is something you check the
+ * shape of and open if it matters. */
+.tp-name,
+.tp-value,
+.tp-purpose {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.tp-name {
font-size: var(--fs-size-body-sm);
color: var(--color-text);
- word-break: break-all;
}
-.tp-value,
+.tp-value {
+ font-family: var(--fs-font-mono);
+ font-size: var(--fs-size-tiny);
+ color: var(--color-text-secondary);
+}
+
.tp-purpose {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
- word-break: break-word;
}
diff --git a/frontend/src/components/rules/PlanRulesPanel.vue b/frontend/src/components/rules/PlanRulesPanel.vue
index 1796b87..0610745 100644
--- a/frontend/src/components/rules/PlanRulesPanel.vue
+++ b/frontend/src/components/rules/PlanRulesPanel.vue
@@ -53,6 +53,12 @@ watch(() => props.projectId, load);
font-size: 0.9em; opacity: 0.7;
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 h5 {
font-size: 0.8em; opacity: 0.7;
diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue
index ad7040e..039c3c5 100644
--- a/frontend/src/components/rules/ProjectRulesTab.vue
+++ b/frontend/src/components/rules/ProjectRulesTab.vue
@@ -349,6 +349,10 @@ select {
.applicable { margin-top: 2rem; }
.rb-group { margin-bottom: 1.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 {
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.75rem;
diff --git a/frontend/src/components/rules/RulebookDetailPane.vue b/frontend/src/components/rules/RulebookDetailPane.vue
index fecf4e4..a89b560 100644
--- a/frontend/src/components/rules/RulebookDetailPane.vue
+++ b/frontend/src/components/rules/RulebookDetailPane.vue
@@ -135,6 +135,11 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg); }
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 ``, 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 {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg); color: inherit;
diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue
index 35664e9..f52a3df 100644
--- a/frontend/src/views/DashboardView.vue
+++ b/frontend/src/views/DashboardView.vue
@@ -176,6 +176,9 @@ onMounted(async () => {
", re.S)
+CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
+# `class="a b"` only — never `:class="[...]"`, whose value is an expression.
+# The negative lookbehind is the whole point: a bound class list mentions names
+# that a static parse would misread as the element's only classes.
+STATIC_CLASS = re.compile(r'(? set[str]:
+ """Class names any global stylesheet defines — a base rule from elsewhere."""
+ names: set[str] = set()
+ for sheet in sheets:
+ if sheet.exists():
+ names |= set(CLASS_TOKEN.findall(sheet.read_text()))
+ return names
+
+
+def base_class_sets(css: str) -> list[frozenset[str]]:
+ """Class combinations this stylesheet gives a base rule to.
+
+ The last compound of a selector is what the rule styles: in
+ `.panel .row:hover` that is `.row:hover`, a state — but in `.panel .row` it
+ is `.row`, a base.
+
+ 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: list[frozenset[str]] = []
+ for selector in SELECTOR.findall(css):
+ for part in selector.split(","):
+ part = part.strip()
+ if not part or part.startswith("@"):
+ continue
+ last = re.split(r"[\s>+~]+", part)[-1]
+ # A pseudo-class, pseudo-element or attribute selector makes it a
+ # state or a variant, not the element's base appearance.
+ 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
+
+
+def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
+ source = path.read_text()
+ template = source.split("