Compare commits

...
5 Commits
Author SHA1 Message Date
bvandeusen 11c243c0fa Deleted CSS left its modifiers behind — restore six base rules, and check for the rest (#98)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 18s
2026-08-05 09:41:18 -04:00
bvandeusenandClaude Opus 5 a6d6550483 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
2026-08-05 09:26:35 -04:00
bvandeusenandClaude Opus 5 46271ccaa7 fix(project): the goal field is a textarea, not a one-line input
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 39s
A project goal is a paragraph in practice. This one rendered as "Maintain
Scribe as the reliabl" with no way to read the rest but arrowing through it,
in a sidebar with room to spare. Description gets two more rows for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:42:58 -04:00
bvandeusenandClaude Opus 5 6ac821178f fix(design): declare --tp-fill so the token check can see it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Canceled after 32s
The swatch set it inline only, and a custom property that exists nowhere in a
stylesheet is exactly what check_design_tokens reports as unresolvable — it was
right, and it caught this on the commit that introduced it.

Declaring it on .tp-swatch is the real fix rather than a silencer: a token that
resolves to nothing now renders as bare checks instead of an invalid gradient,
which is what the inline value would produce when empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:41:24 -04:00
bvandeusenandClaude Opus 5 4a9744172f fix(ui): restore four base rules a CSS sweep deleted, and check for the rest
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Canceled after 38s
CI & Build / Build & push image (push) Canceled after 0s
Operator reported four things looking wrong. Two were the same bug, and it is
not a design drift — it is deleted CSS.

Removing a rule from a scoped stylesheet leaves its modifiers behind. The
selector still exists, so nothing reads as unused, and the element renders with
no base styling at all:

  .btn-workspace      base gone, :hover survived — the Workspace link rendered
                      as raw browser blue, underlined
  .milestone-header   base gone, .clickable and :hover survived. Every child is
                      written for a flex ROW (.ms-name { flex: 1 }, the progress
                      track, .ms-pct), so without the parent they stacked and a
                      one-line milestone became five. That is the "projects
                      section uses space poorly" — a deletion, not a redesign.
  .milestone-group    no rule at all; the card around each milestone
  .ds-header          only its h1 descendant survived

vue-tsc cannot see any of it. A dead style typechecks perfectly.

scripts/check_dangling_styles.py finds the shape: an element whose every static
class has no base rule anywhere, while at least one carries modifier rules. It
reports 11 more. Reported and not gated, because a genuinely bare wrapper is
legitimate — the signal is the count growing. Runs in the lint lane, stdlib
only, and knows no class name or convention (rule #115).

Also from the same report:

- The header pill bar was `position: absolute; left: 50%`, so it did not
  participate in layout: out of room, it OVERLAPPED the brand and the utility
  cluster instead of pushing them. A sixth link reached that at ~1270px, an
  ordinary window. Now `1fr auto 1fr` — a 1fr track has an auto minimum, so
  neither side can be squeezed under its content and the two stay equal, which
  is what keeps the bar centred in the viewport rather than in the leftover
  space. Overflow becomes the header growing, not two things sharing pixels.

- The token preview put its checkerboard on the whole specimen stage, so every
  swatch sat in a frame of checks and the pattern read as the loudest thing on
  the page. The checks now sit UNDER the colour as a second background layer:
  an opaque value hides them, a 15% tint shows exactly as much as it should.
  Text-bearing specimens lose the box entirely, and name/value/purpose are one
  line each with the full text on hover — they wrapped freely before, so a card
  was two lines tall or five depending on how long its color-mix() happened to
  be, and the grid had no rhythm.

- .btn-cta joins the shared button family: the gradient-and-glow brand moment
  the system carries tokens for, which had been living in one view's scoped
  block. That is what made it deletable. The header actions are now one size
  and one family instead of four sizes and two.

- The shared button shape gained inline-flex + gap, so a button carrying an
  icon centres it without each caller rebuilding the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:40:41 -04:00
15 changed files with 460 additions and 54 deletions
+9
View File
@@ -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')
+34 -4
View File
@@ -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
+32 -12
View File
@@ -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;
}
@@ -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 {
+92 -28
View File
@@ -133,12 +133,13 @@ function ruleWidth(value: string): string {
<li v-for="s in specimens" :key="s.name" class="tp-item">
<div
class="tp-specimen"
:class="`is-${s.shape}`"
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
>
<span
v-if="s.shape === 'colour'"
class="tp-swatch"
:style="{ background: s.rendered }"
:style="{ '--tp-fill': s.rendered }"
/>
<span
v-else-if="s.shape === 'surface'"
@@ -149,6 +150,7 @@ function ruleWidth(value: string): string {
/>
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
<span class="tp-rule-label">{{ s.rendered }}</span>
</span>
<span
v-else-if="s.shape === 'font'"
@@ -160,8 +162,8 @@ function ruleWidth(value: string): string {
</div>
<code class="tp-name">{{ s.name }}</code>
<span class="tp-value">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-purpose">{{ s.purpose }}</span>
<span class="tp-value" :title="s.declared">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
</li>
</ul>
</div>
@@ -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;
}
</style>
@@ -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;
@@ -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;
@@ -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 `<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 {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg); color: inherit;
+3
View File
@@ -176,6 +176,9 @@ onMounted(async () => {
<style scoped>
.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-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; }
+6
View File
@@ -1102,6 +1102,12 @@ function isSelfContainedColour(value: string): boolean {
padding: 1.5rem 1rem 4rem;
}
/* Restored with the same sweep that took `.milestone-header` — only the `h1`
descendant rule survived, so the header had no spacing of its own. */
.ds-header {
margin-bottom: 2rem;
}
.ds-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
+11
View File
@@ -936,6 +936,17 @@ onUnmounted(() => {
border-bottom: 1px solid var(--color-border);
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 */
.graph-embed :deep(.graph-page) {
height: 100%;
+2
View File
@@ -454,6 +454,8 @@ function clearFilters() {
}
/* 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 {
padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border);
+34 -10
View File
@@ -446,27 +446,27 @@ async function confirmDelete() {
@keyup.enter="confirmStartPlanning"
/>
<button
class="btn-workspace"
class="btn-primary btn-compact"
:disabled="!planTitle.trim() || planningBusy"
@click="confirmStartPlanning"
>
Create plan
</button>
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
<button class="btn-ghost btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
</template>
<button
v-else-if="project"
class="btn-workspace"
class="btn-ghost btn-compact"
@click="showStartPlanning = true"
>
Start planning
</button>
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-workspace">
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-cta btn-compact">
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline btn-compact" @click="showDeleteConfirm = true">Delete</button>
</div>
</div>
@@ -527,11 +527,15 @@ async function confirmDelete() {
<h3 class="panel-heading">Details</h3>
<div class="edit-field">
<label class="edit-label">Goal</label>
<input v-model="editGoal" type="text" class="edit-input" placeholder="What are you trying to achieve?" />
<!-- A textarea, not a single-line input. A project goal is a
paragraph in practice this one showed as "Maintain Scribe as
the reliabl" and gave no way to read the rest without arrowing
through it. -->
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Status</label>
@@ -870,9 +874,6 @@ async function confirmDelete() {
min-width: 200px;
}
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
/* Share: Bronze action-secondary — alternate path */
.project-title-input {
flex: 1;
font-size: 1.75rem;
@@ -1067,6 +1068,29 @@ async function confirmDelete() {
}
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
/* RESTORED. Both of these lost their base rule to a CSS sweep and left only
modifiers behind — `.milestone-header.clickable`, `.milestone-header:hover`.
Every child here (`.ms-chevron`, `.ms-name { flex: 1 }`, the progress track,
`.ms-pct`) is written for a flex ROW, so without the parent they stacked
vertically and each milestone grew to five lines of mostly nothing. That is
the "uses space poorly" the operator saw, and it was a deletion rather than a
design change. A dangling `:hover` is the tell, and it is now checked for. */
.milestone-group {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 0.75rem;
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
background: var(--color-bg-secondary);
}
.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
+2
View File
@@ -2937,6 +2937,8 @@ function formatUserDate(iso: string): string {
.cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.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-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
.detail-json {
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Find elements whose classes have no base rule — the dangling-selector bug.
THE FAILURE THIS CATCHES
Deleting a CSS rule from a scoped stylesheet is not the local edit it looks
like. Three ways it goes wrong, all of them silent:
1. A rule is deleted and its `:hover` / modifier survives. The selector still
exists, so nothing reads as unused, but the element renders with no base
styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a
router-link rendered as raw browser blue for days.
2. The parent's layout rule is deleted while the children keep theirs.
`.milestone-header` was a flex row; its children still declare `flex: 1`
and `flex-shrink: 0`. Without the parent they stack vertically, and a
milestone that was one line becomes five. Nothing errors — the page just
wastes space, which reads as a design decision.
3. A rule is removed from a comma-separated group, leaving `.a,` dangling in
front of the next rule and swallowing it. That one at least has a
brace-balance tell; these two do not.
None of it is visible to `vue-tsc`, which is the frontend's entire check. A
dead style typechecks perfectly.
WHAT IT REPORTS
An element whose every static class is styled NOWHERE as a base rule, while at
least one of them appears in the file's CSS. That conjunction is the signal: a
class nobody styles is ordinary (a hook for a test, a semantic label), and a
class with only modifier rules is a deletion that went half-way.
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
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,
so this cannot be a gate without a suppression mechanism nobody would maintain.
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
a convention; point it at any Vue tree.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", 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'(?<![:\w-])class="([^"{}\[\]]*)"')
SELECTOR = re.compile(r"([^{}]+)\{")
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")
def shared_classes(sheets: list[pathlib.Path]) -> 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("<style")[0]
css = "\n".join(CSS_COMMENT.sub("", block) for block in STYLE_BLOCK.findall(source))
if not css.strip():
return []
base_sets = base_class_sets(css)
mentioned = set(CLASS_TOKEN.findall(css))
findings: list[tuple[str, list[str]]] = []
for attr in sorted(set(STATIC_CLASS.findall(template))):
names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
if not names:
continue
carried = set(names)
if any(carried >= required for required in base_sets):
continue
if any(n in shared for n in names):
continue
dangling = [n for n in names if n in mentioned]
if dangling:
findings.append((attr, dangling))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default="frontend/src", help="tree of .vue files")
parser.add_argument(
"--shared",
action="append",
default=None,
help="global stylesheet whose classes count as a base rule (repeatable)",
)
args = parser.parse_args()
root = pathlib.Path(args.root)
if not root.exists():
print(f"{root}: no such directory", file=sys.stderr)
return 2
sheets = [pathlib.Path(s) for s in (args.shared or [])]
if not sheets:
sheets = sorted(root.glob("assets/*.css"))
shared = shared_classes(sheets)
total = 0
for path in sorted(root.rglob("*.vue")):
for attr, dangling in scan(path, shared):
total += 1
print(f'{path}: class="{attr}" — styled but never based: {", ".join(dangling)}')
print()
if total:
print(
f"REPORT: {total} element(s) whose classes carry modifier rules but no base "
f"rule. Each is either a deleted rule that left its :hover behind, or a "
f"deliberately bare wrapper."
)
else:
print("OK — every styled class has a base rule.")
return 0
if __name__ == "__main__":
raise SystemExit(main())