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
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
This commit is contained in:
@@ -178,6 +178,15 @@ jobs:
|
|||||||
- name: Design token check
|
- name: Design token check
|
||||||
run: python3 scripts/check_design_tokens.py --report-literals
|
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:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
.btn-secondary,
|
.btn-secondary,
|
||||||
.btn-ghost,
|
.btn-ghost,
|
||||||
.btn-danger,
|
.btn-danger,
|
||||||
.btn-danger-outline {
|
.btn-danger-outline,
|
||||||
|
.btn-cta {
|
||||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||||
@@ -46,6 +47,14 @@
|
|||||||
line-height: var(--fs-leading-body);
|
line-height: var(--fs-leading-body);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
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),
|
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||||
color var(--fs-dur-fast) var(--fs-ease);
|
color var(--fs-dur-fast) var(--fs-ease);
|
||||||
@@ -58,7 +67,8 @@
|
|||||||
.btn-secondary:disabled,
|
.btn-secondary:disabled,
|
||||||
.btn-ghost:disabled,
|
.btn-ghost:disabled,
|
||||||
.btn-danger:disabled,
|
.btn-danger:disabled,
|
||||||
.btn-danger-outline:disabled {
|
.btn-danger-outline:disabled,
|
||||||
|
.btn-cta:disabled {
|
||||||
opacity: var(--fs-disabled-opacity);
|
opacity: var(--fs-disabled-opacity);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -67,7 +77,8 @@
|
|||||||
.btn-secondary:focus-visible,
|
.btn-secondary:focus-visible,
|
||||||
.btn-ghost:focus-visible,
|
.btn-ghost:focus-visible,
|
||||||
.btn-danger:focus-visible,
|
.btn-danger:focus-visible,
|
||||||
.btn-danger-outline:focus-visible {
|
.btn-danger-outline:focus-visible,
|
||||||
|
.btn-cta:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--fs-focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
@@ -150,6 +161,25 @@
|
|||||||
color: var(--fs-text-on-action);
|
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 ------------------------------------------------------
|
/* --- size modifiers ------------------------------------------------------
|
||||||
*
|
*
|
||||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
* 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
|
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||||
.btn-block {
|
.btn-block {
|
||||||
display: block;
|
display: flex; /* not `block` — the shared shape centres with flex */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||||
because a full-width button
|
because a full-width button
|
||||||
|
|||||||
@@ -128,16 +128,31 @@ router.afterEach(() => {
|
|||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
position: relative;
|
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 {
|
.nav {
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1.5rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left — brand */
|
/* Left — brand */
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
|
justify-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -155,9 +170,7 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Center — pill bar */
|
/* Center — pill bar */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
position: absolute;
|
justify-self: center;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -172,10 +185,12 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Right */
|
/* Right */
|
||||||
.nav-right {
|
.nav-right {
|
||||||
|
justify-self: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -268,6 +283,12 @@ router.afterEach(() => {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 500;
|
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 {
|
.admin-badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -342,13 +363,12 @@ router.afterEach(() => {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The pill bar is absolutely centred, so when the header runs out of room it
|
/* The grid above means running out of room can no longer cause a collision —
|
||||||
OVERLAPS the brand and the utility cluster rather than pushing them — nothing
|
but it can still make the header wider than the window, and a horizontally
|
||||||
wraps, it just collides. Six primary links reach that point sooner than five
|
scrolling header is its own defect. So shed width before that happens. The
|
||||||
did, so reclaim the width here instead of leaving one out of the bar.
|
wordmark goes first: the logo beside it says the same thing and is still the
|
||||||
The wordmark goes first: the logo beside it says the same thing and is still
|
link home. */
|
||||||
the link home. */
|
@media (max-width: 1280px) {
|
||||||
@media (max-width: 1150px) {
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,12 +133,13 @@ function ruleWidth(value: string): string {
|
|||||||
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
||||||
<div
|
<div
|
||||||
class="tp-specimen"
|
class="tp-specimen"
|
||||||
|
:class="`is-${s.shape}`"
|
||||||
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="s.shape === 'colour'"
|
v-if="s.shape === 'colour'"
|
||||||
class="tp-swatch"
|
class="tp-swatch"
|
||||||
:style="{ background: s.rendered }"
|
:style="{ '--tp-fill': s.rendered }"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-else-if="s.shape === 'surface'"
|
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 v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
||||||
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
||||||
|
<span class="tp-rule-label">{{ s.rendered }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="s.shape === 'font'"
|
v-else-if="s.shape === 'font'"
|
||||||
@@ -160,8 +162,8 @@ function ruleWidth(value: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<code class="tp-name">{{ s.name }}</code>
|
<code class="tp-name">{{ s.name }}</code>
|
||||||
<span class="tp-value">{{ s.declared || "—" }}</span>
|
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
||||||
<span v-if="s.purpose" class="tp-purpose">{{ s.purpose }}</span>
|
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,13 +201,16 @@ function ruleWidth(value: string): string {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-group { margin-bottom: var(--fs-space-5); }
|
.tp-group { margin-bottom: var(--fs-space-6); }
|
||||||
|
|
||||||
.tp-group-heading {
|
.tp-group-heading {
|
||||||
text-transform: capitalize;
|
text-transform: uppercase;
|
||||||
font-size: var(--fs-size-label);
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
color: var(--color-text-secondary);
|
font-size: var(--fs-size-tiny);
|
||||||
margin-bottom: var(--fs-space-2);
|
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 {
|
.tp-grid {
|
||||||
@@ -213,8 +218,8 @@ function ruleWidth(value: string): string {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
gap: var(--fs-space-3);
|
gap: var(--fs-space-4) var(--fs-space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-item {
|
.tp-item {
|
||||||
@@ -224,44 +229,78 @@ function ruleWidth(value: string): string {
|
|||||||
gap: 0.15rem;
|
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 {
|
.tp-specimen {
|
||||||
height: 2.75rem;
|
height: 2.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0 var(--fs-space-2);
|
padding: var(--fs-space-1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Checks show through anything translucent — a 15% tint over a solid card
|
background: var(--color-bg-secondary);
|
||||||
would otherwise look opaque and read as the wrong colour. */
|
}
|
||||||
background:
|
|
||||||
repeating-conic-gradient(var(--color-surface) 0% 25%, var(--color-bg) 0% 50%)
|
/* Text-bearing specimens get no box at all — a border around a value is a
|
||||||
0 0 / 12px 12px;
|
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 {
|
||||||
|
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 {
|
.tp-surface {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1.75rem;
|
height: 100%;
|
||||||
border-radius: calc(var(--fs-radius-sm) - 1px);
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||||
|
background: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-surface { background: var(--color-surface); }
|
|
||||||
|
|
||||||
.tp-rule-wrap {
|
.tp-rule-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-rule {
|
.tp-rule {
|
||||||
height: 0.5rem;
|
height: 0.4rem;
|
||||||
min-width: 1px;
|
min-width: 1px;
|
||||||
|
flex: none;
|
||||||
background: var(--color-primary-solid);
|
background: var(--color-primary-solid);
|
||||||
border-radius: 999px;
|
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 {
|
.tp-font {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -283,16 +322,34 @@ function ruleWidth(value: string): string {
|
|||||||
font-style: italic;
|
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 {
|
.tp-name {
|
||||||
font-size: var(--fs-size-body-sm);
|
font-size: var(--fs-size-body-sm);
|
||||||
color: var(--color-text);
|
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 {
|
.tp-purpose {
|
||||||
font-size: var(--fs-size-tiny);
|
font-size: var(--fs-size-tiny);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1102,6 +1102,12 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
padding: 1.5rem 1rem 4rem;
|
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 {
|
.ds-header h1 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
|||||||
@@ -446,27 +446,27 @@ async function confirmDelete() {
|
|||||||
@keyup.enter="confirmStartPlanning"
|
@keyup.enter="confirmStartPlanning"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn-workspace"
|
class="btn-primary btn-compact"
|
||||||
:disabled="!planTitle.trim() || planningBusy"
|
:disabled="!planTitle.trim() || planningBusy"
|
||||||
@click="confirmStartPlanning"
|
@click="confirmStartPlanning"
|
||||||
>
|
>
|
||||||
Create plan
|
Create plan
|
||||||
</button>
|
</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>
|
</template>
|
||||||
<button
|
<button
|
||||||
v-else-if="project"
|
v-else-if="project"
|
||||||
class="btn-workspace"
|
class="btn-ghost btn-compact"
|
||||||
@click="showStartPlanning = true"
|
@click="showStartPlanning = true"
|
||||||
>
|
>
|
||||||
Start planning
|
Start planning
|
||||||
</button>
|
</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" />
|
<LayoutGrid :size="16" />
|
||||||
Workspace
|
Workspace
|
||||||
</router-link>
|
</router-link>
|
||||||
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -870,9 +870,6 @@ async function confirmDelete() {
|
|||||||
min-width: 200px;
|
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 {
|
.project-title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
@@ -1067,6 +1064,29 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
/* 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 { cursor: pointer; }
|
||||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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-]*)")
|
||||||
|
BARE_CLASS = re.compile(r"\.([\w-]+)\Z")
|
||||||
|
|
||||||
|
|
||||||
|
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 based_classes(css: str) -> set[str]:
|
||||||
|
"""Classes 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 modifier — but in `.panel .row`
|
||||||
|
it is `.row`, a base. So a selector qualifies only when its final compound
|
||||||
|
is a lone class with nothing appended.
|
||||||
|
"""
|
||||||
|
out: set[str] = set()
|
||||||
|
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]
|
||||||
|
match = BARE_CLASS.fullmatch(last)
|
||||||
|
if match:
|
||||||
|
out.add(match.group(1))
|
||||||
|
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 []
|
||||||
|
|
||||||
|
based = based_classes(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
|
||||||
|
if any(n in based or 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())
|
||||||
Reference in New Issue
Block a user