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
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
This commit was merged in pull request #98.
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,6 +387,41 @@ async function confirmDelete() {
|
|||||||
.system-textarea { resize: vertical; }
|
.system-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||||
|
|
||||||
|
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||||
|
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||||
|
The card WAS a flex row and every child still says so — `.system-swatch`
|
||||||
|
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
|
||||||
|
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
|
||||||
|
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
|
||||||
|
of text rather than centred against the whole card.
|
||||||
|
|
||||||
|
The list had no rule at all, so it rendered with browser bullets and
|
||||||
|
indent — invisible to the dangling-style check, which can only see a class
|
||||||
|
that is PARTLY styled. A class with no rules anywhere looks exactly like a
|
||||||
|
semantic-only hook.
|
||||||
|
|
||||||
|
Surface values match `.system-form` above, which is the same card shape in
|
||||||
|
this file and the reason they can be recovered rather than guessed. */
|
||||||
|
.systems-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
.system-card--archived { opacity: 0.6; }
|
.system-card--archived { opacity: 0.6; }
|
||||||
|
|
||||||
.system-swatch {
|
.system-swatch {
|
||||||
|
|||||||
@@ -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,85 @@ 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 {
|
||||||
|
/* 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 {
|
.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 +329,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>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ watch(() => props.projectId, load);
|
|||||||
font-size: 0.9em; opacity: 0.7;
|
font-size: 0.9em; opacity: 0.7;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
|
||||||
|
below, and its children carry their own spacing (the h4 keeps the UA
|
||||||
|
margin-top that separates one rulebook group from the next). Nothing here
|
||||||
|
assumes a flex or grid parent, which is the tell that distinguishes this
|
||||||
|
from a base rule someone deleted (#2444). Stated so the next reader doesn't
|
||||||
|
re-open the question. */
|
||||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||||
.rb h5 {
|
.rb h5 {
|
||||||
font-size: 0.8em; opacity: 0.7;
|
font-size: 0.8em; opacity: 0.7;
|
||||||
|
|||||||
@@ -349,6 +349,10 @@ select {
|
|||||||
.applicable { margin-top: 2rem; }
|
.applicable { margin-top: 2rem; }
|
||||||
.rb-group { margin-bottom: 1.5rem; }
|
.rb-group { margin-bottom: 1.5rem; }
|
||||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||||
|
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
|
||||||
|
one and the flex row further down), with the h5's own margin-top doing the
|
||||||
|
separating. Its children assume nothing about it, which is what tells it
|
||||||
|
apart from a base rule someone deleted (#2444). */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
|
|||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--color-primary-bg); }
|
li.active { background: var(--color-primary-bg); }
|
||||||
li:hover { background: var(--color-hover); }
|
li:hover { background: var(--color-hover); }
|
||||||
|
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
||||||
|
button-or-form whose children style themselves; the second is a `<ul>`, and
|
||||||
|
the bare `ul` rule above already gives it list-style, padding and margin —
|
||||||
|
a base a class-name check cannot see, since it comes from an element
|
||||||
|
selector. Both namespace descendant rules and assume nothing about layout. */
|
||||||
.new-topic input {
|
.new-topic input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--color-bg); color: inherit;
|
background: var(--color-bg); color: inherit;
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
/* `.dash-head` is deliberately bare: a block header whose two children carry
|
||||||
|
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
|
||||||
|
assumes a flex parent, which is the tell for a deleted rule (#2444). */
|
||||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -936,6 +936,17 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
|
||||||
|
`flex-shrink: 0`, so this is the item that takes the remaining height — and
|
||||||
|
without it the `height: 100%` below resolves against `auto` and does
|
||||||
|
nothing, which made the comment underneath a spec for a rule that could not
|
||||||
|
work. `min-height: 0` is the companion that lets a flex item shrink under
|
||||||
|
its content instead of overflowing the panel. */
|
||||||
|
.graph-embed {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||||
.graph-embed :deep(.graph-page) {
|
.graph-embed :deep(.graph-page) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ function clearFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Detail row */
|
/* Detail row */
|
||||||
|
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||||
|
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||||
.detail-row td {
|
.detail-row td {
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 0.75rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -527,11 +527,15 @@ async function confirmDelete() {
|
|||||||
<h3 class="panel-heading">Details</h3>
|
<h3 class="panel-heading">Details</h3>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Goal</label>
|
<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>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Description</label>
|
<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>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Status</label>
|
<label class="edit-label">Status</label>
|
||||||
@@ -870,9 +874,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 +1068,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)); }
|
||||||
|
|
||||||
|
|||||||
@@ -2937,6 +2937,8 @@ function formatUserDate(iso: string): string {
|
|||||||
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
||||||
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
.text-error { color: var(--color-danger); }
|
.text-error { color: var(--color-danger); }
|
||||||
|
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style
|
||||||
|
that its cells don't carry (#2444). */
|
||||||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||||||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
|
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
|
||||||
.detail-json {
|
.detail-json {
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user