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 `