Skip to main content

max / makenotwork

Adopt the components layer, and stop re-specifying what makeover generates Wave 2 tiers B2, B4 and F3, landed together because each one regrows without the next: the deletions regrow without the layer, the layer regrows without the lint. B4. style.css, wizard.css and media-player.css move into the `components` layer that _head_assets.html has declared and left empty. This changes no rendering. `components` already sat after `makeover` in the declared order, so these sheets win the contests they were already winning; what changes is that they win by a stated rule rather than by being unlayered, and a per-page sheet still beats style.css on a tie because a layer resolves internally by source order. The @font-face rules stay outside the layer (not style rules), and the theme block theming.rs injects stays unlayered, which is load-bearing: that is how a creator's theme keeps outranking all of it. B2. The task's count of 64 was wrong and the real number is 4. It counted dashed-descendant classes — .card-title, .field-label — as re-specifications of .card and .field. Those are distinct classes the generated sheet says nothing about and never contests. Measuring the actual contests, a local rule setting a property the generated sheet also sets for the same primitive IN THE SAME STATE, gives four. Taking the default-is-deletion rule: .tab background deleted, it restated --surface-sunken verbatim .tab:focus-visible deleted, -2px inset ring only pays where a strip clips, and .tabs sets no overflow .badge color survives, with a reason .progress-fill background survives, it is a name collision not a contest The larger find is not a property clash and no lint would have caught it: the tab carried a SECOND selection signal on top of the generated one, opacity 0.6 unselected and 1 selected, so a tab said "not chosen" twice and makeover's surface step did nothing visible. The opacity idiom is gone, in the strip and in the overflow menu, and the surface contrast is the signal. Selection keeps the name `.is-selected` rather than taking makeover's `.chosen`. That name is one idiom across git nav links, editor tabs, section tabs, issue tabs and filter buttons, and core/tabs.js and the media player read it; makeover has primitives for only some of those, so renaming the tabs alone would split one idiom into two. The name is local, the values are the generated ones. F3. scripts/lint-frontend.sh, in the shape of GoingsOn's and Balanced Breakfast's. Four gates, two of which are the defects this wave actually hit: no-primitive-respec reads the primitives and their properties out of layout.css at lint time, so regenerating makeover updates the rule. A survivor carries /* respec-ok: <reason> */, which is where B2's "each survivor carries a written reason" stops being a convention and starts being enforced. no-undefined-token a var() with no definition and no fallback drops the whole declaration silently. This is what let wizard.css and media-player.css lose every colour for months. no-colour-literal a literal is allowed as a token's value and nowhere else. The rgba-black scale grew back twice. layer-adoption one un-wrapped sheet takes back every contest the layer order was declared to settle. Clean as of this commit.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 15:53 UTC
Signed with PGP, not checked
Commit: b3dca353a15c877a15a2097bb30d15f3d192ef5c
Parent: e62bb54
5 files changed, +289 insertions, -31 deletions
M server/build.rs +14 -6
@@ -112,12 +112,20 @@
112 112 not link and which base.html therefore cannot order on its own.
113 113
114 114 Earlier in the list = lower priority. `makeover` is first because the
115 - design system is what the site overrides, never the reverse. Unlayered
116 - rules still beat every named layer, which is why style.css works today
117 - with no layers of its own, and why the theme block that theming.rs
118 - injects into <head> keeps outranking everything. The other three names
119 - are declared ahead of any layer adoption in style.css and are empty
120 - until then; declaring an empty layer costs nothing. -->
115 + design system is what the site overrides, never the reverse.
116 +
117 + `components` is where the site's own sheets live: style.css, and the
118 + per-page wizard.css and media-player.css. It sits after `makeover`, so
119 + those sheets win the contests they were already winning when they were
120 + unlayered, and they now win them by a stated rule. A layer resolves
121 + internally by source order, so a per-page sheet still beats style.css
122 + on a tie exactly as before.
123 +
124 + Unlayered rules still beat every named layer, and one thing relies on
125 + that: the theme block theming.rs injects into <head> is unlayered, so a
126 + creator's chosen theme outranks all of the above without knowing this
127 + order. `base` and `responsive` are still empty; declaring an empty
128 + layer costs nothing and fixes its position. -->
121 129 <style>@layer makeover, base, components, responsive;</style>
122 130 <link rel="stylesheet" href="/static/geometry.css?v={version}">
123 131 <link rel="stylesheet" href="/static/layout.css?v={version}">
@@ -1,3 +1,9 @@
1 + /* In the `components` cascade layer, declared in _head_assets.html.
2 + This sheet loads after style.css and shares its layer, so a tie still
3 + resolves in this file's favour by source order, exactly as when both
4 + were unlayered. The creator theme block stays unlayered and outranks
5 + both. */
6 + @layer components {
1 7 /* Unified media player styles, shared by audio and video player pages. */
2 8
3 9 /* Media container */
@@ -168,6 +174,11 @@
168 174
169 175 .progress-fill {
170 176 height: 100%;
177 + /* respec-ok: name collision, not a contest. layout.css styles
178 + `.progress > .progress-fill`, and this one is inside .progress-bar, the
179 + media scrubber, which is not the generated primitive and never matches
180 + that selector. Worth knowing before anything here adopts .progress: the
181 + two would then collide for real. */
171 182 background: var(--action);
172 183 width: 0%;
173 184 border-radius: var(--radius-control);
@@ -403,3 +414,4 @@
403 414 align-items: flex-start;
404 415 }
405 416 }
417 + }
@@ -129,6 +129,27 @@
129 129 font-display: swap;
130 130 }
131 131
132 + /* Everything below is in the `components` cascade layer.
133 +
134 + _head_assets.html declares the order once, `@layer makeover, base,
135 + components, responsive`, and until now three of those four names were
136 + empty: this file adopted none of them, so 11,788 unlayered lines outranked
137 + every named layer by construction and the generated sheets lost every
138 + contest they entered.
139 +
140 + Adopting `components` changes no rendering today. `components` already sits
141 + after `makeover` in the declared order, so this file wins the same contests
142 + it was winning before; what changes is that it wins them by a stated rule
143 + rather than by being unlayered. wizard.css and media-player.css adopt the
144 + same layer, and they still beat this file on a tie because a layer resolves
145 + internally by source order and they load later.
146 +
147 + Two things stay OUT of the layer, deliberately. The @font-face rules above
148 + are not style rules and take no part in the cascade. And the theme block
149 + theming.rs injects into <head> is unlayered, which is load-bearing: an
150 + unlayered rule outranks every layer, so a creator's chosen theme keeps
151 + overriding this file's :root without needing to know the order. */
152 + @layer components {
132 153 :root {
133 154 /* Typography (three-tier system) */
134 155 --font-heading: "Young Serif", serif;
@@ -766,8 +787,13 @@
766 787 margin-bottom: 0;
767 788 }
768 789
790 + /* Geometry and type only. The surface is layout.css's: `.tab` is sunken,
791 + `:hover` steps to --hover-surface on a fine pointer, `:active` insets. This
792 + rule used to restate the sunken background verbatim and then carry a second,
793 + redundant selection signal on top of it — opacity 0.6 unselected, 1 selected
794 + — so a tab said "not chosen" twice and the generated surface step did
795 + nothing. The opacity idiom is gone and the surface contrast is the signal. */
769 796 .tab {
770 - background: var(--surface-sunken);
771 797 color: var(--content);
772 798 border: none;
773 799 padding: var(--gap-section) var(--gap-page);
@@ -777,21 +803,22 @@
777 803 /* Tabs navigate, they don't commit, stay flat. Overrides the base
778 804 `button` shadow if a tab is a <button>. */
779 805 box-shadow: none;
780 - transition:
781 - background 0.2s ease,
782 - opacity 0.2s ease;
783 - opacity: 0.6;
806 + transition: background 0.2s ease;
784 807 white-space: nowrap;
785 808 flex-shrink: 0;
786 809 }
787 810
811 + /* layout.css spells selection `.tab.chosen`; the site spells it
812 + `.is-selected`, and it spells it that way on git nav links, editor tabs,
813 + section tabs, issue tabs and filter buttons as well — one idiom across a
814 + dozen components that makeover has primitives for only some of. Renaming
815 + the tabs alone would split that idiom in two, so the name stays local and
816 + the VALUES are the generated ones: this is `.tab.chosen` under the site's
817 + own selection name. If the description layer ever emits the markup, the
818 + name goes with it and this rule goes away. */
788 819 .tab.is-selected {
789 - background: var(--surface-overlay);
790 - opacity: 1;
791 - }
792 -
793 - .tab:hover {
794 - opacity: 1;
820 + background: var(--surface-raised);
821 + box-shadow: var(--bevel-raised);
795 822 }
796 823
797 824 .tab-more-wrap {
@@ -810,21 +837,16 @@
810 837 box-shadow: var(--elevation-overlay);
811 838 }
812 839
840 + /* The overflow menu restates the tab as a menu row: block, full width, left
841 + aligned, tighter. It carried the same opacity pair the strip did and it goes
842 + for the same reason; hover and selection are the generated surface steps. */
813 843 .tab-overflow-menu .tab {
814 844 display: block;
815 845 width: 100%;
816 846 text-align: left;
817 847 padding: var(--gap-group) var(--gap-section);
818 - opacity: 0.7;
819 848 }
820 849
821 - .tab-overflow-menu .tab:hover {
822 - opacity: 1;
823 - }
824 -
825 - .tab-overflow-menu .tab.is-selected {
826 - opacity: 1;
827 - }
828 850
829 851 .tab-content {
830 852 display: none;
@@ -1017,6 +1039,13 @@
1017 1039 padding: var(--gap-bound) var(--gap-peer);
1018 1040 font-size: var(--text-fine);
1019 1041 font-family: var(--font-mono);
1042 + /* respec-ok: makeover's badge is coloured text on the page, and has no
1043 + filled form at all — it sets `color` and never a background. MNW's badge
1044 + is a filled pill and the fill is what the whole `.badge--*` status
1045 + vocabulary in the charter colours, so the fill cannot come from the
1046 + generated sheet. Given the fill, the generated `color: --content-muted`
1047 + is not merely different, it is muted content colour on a near-black
1048 + ground and fails contrast, so --primary-light stands. */
1020 1049 background: var(--primary-dark);
1021 1050 color: var(--primary-light);
1022 1051 }
@@ -5104,11 +5133,11 @@
5104 5133 outline-offset: 2px;
5105 5134 }
5106 5135
5107 - /* Tab focus styles */
5108 - .tab:focus-visible {
5109 - outline: 2px solid var(--focus-ring);
5110 - outline-offset: -2px;
5111 - }
5136 + /* Tab focus is layout.css's, same ring as everything else at outline-offset
5137 + 2px. The local rule inset it to -2px, which is only worth doing where a
5138 + strip clips its overflow; `.tabs` is flex/nowrap with no overflow set, so
5139 + there was nothing to clip and the tabs read differently from every other
5140 + focusable thing on the page for no reason. */
5112 5141
5113 5142 /* Canonical "selected" recipe (charter: docs/design-system.md).
5114 5143 Apply `.is-selected` to any interactive container to mark it as the
@@ -11801,3 +11830,5 @@
11801 11830 @media (max-width: 599px) {
11802 11831 .table-row .row-match-note { display: none; }
11803 11832 }
11833 +
11834 + }
@@ -1,3 +1,9 @@
1 + /* In the `components` cascade layer, declared in _head_assets.html.
2 + This sheet loads after style.css and shares its layer, so a tie still
3 + resolves in this file's favour by source order, exactly as when both
4 + were unlayered. The creator theme block stays unlayered and outranks
5 + both. */
6 + @layer components {
1 7 /* Creation Wizard Layout */
2 8
3 9 .wizard-layout {
@@ -634,4 +640,4 @@
634 640 left: 0;
635 641 color: var(--success);
636 642 }
637 -
643 + }
@@ -1,0 +1,201 @@
1 + #!/bin/bash
2 + # Frontend design-system lint guards for the MNW server.
3 + #
4 + # The server had no frontend lint at all, which is how 64 local rules
5 + # re-specifying a generated primitive accumulated without anything noticing,
6 + # and how two per-page sheets spent months referencing custom properties that
7 + # had been deleted. Both classes are mechanical to detect, so they are.
8 + #
9 + # See docs/design-system.md. Exit 0 = clean, non-zero = violations (file:line).
10 +
11 + set -u
12 + ROOT="$(cd "$(dirname "$0")/.." && pwd)"
13 + STATIC="$ROOT/static"
14 + # The site's own sheets. The generated ones (geometry.css, layout.css) are
15 + # makeover's output and are never linted; they are the standard, not the code.
16 + SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css"
17 + GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css"
18 +
19 + violations=0
20 +
21 + report() {
22 + local rule="$1"; shift
23 + local msg="$1"; shift
24 + if [ -n "$*" ]; then
25 + echo
26 + echo "[$rule] $msg"
27 + echo "$*"
28 + violations=$((violations + 1))
29 + fi
30 + }
31 +
32 + # 1. No local rule may re-specify a property the generated sheet already sets
33 + # for the same primitive.
34 + #
35 + # Unlayered CSS used to beat @layer makeover by construction, so the
36 + # generated sheet lost every contest it entered; the site said .card and
37 + # then told itself what a card is. style.css is in the `components` layer
38 + # now, which is still ahead of `makeover`, so the contest is stated rather
39 + # than accidental but the local rule still wins it. This gate is what makes
40 + # winning deliberate.
41 + #
42 + # The primitives and their properties are read out of layout.css at lint
43 + # time rather than listed here, so regenerating makeover updates the rule.
44 + #
45 + # A survivor carries `/* respec-ok: <reason naming what the generated sheet
46 + # cannot express> */` inside the rule body. "Different from what we had" is
47 + # the expected outcome of adopting a design system and is not a reason.
48 + hits=$(python3 - "$GENERATED_SHEETS" "$SITE_SHEETS" <<'PY'
49 + import re, sys
50 +
51 + def rules(path):
52 + """(selector, body, line) for every rule with declarations, @media included."""
53 + raw = open(path).read()
54 + # Blank comments out rather than deleting them, so line numbers survive.
55 + src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), raw, flags=re.S)
56 + out, stack, cur, i = [], [], '', 0
57 + while i < len(src):
58 + c = src[i]
59 + if c == '{':
60 + stack.append((cur.strip(), i)); cur = ''
61 + elif c == '}':
62 + if stack:
63 + sel, start = stack.pop()
64 + body = src[start + 1:i]
65 + if '{' not in body:
66 + out.append((sel, body, raw[start + 1:i], src.count('\n', 0, start) + 1))
67 + cur = ''
68 + else:
69 + cur += c
70 + i += 1
71 + return out
72 +
73 + def props(body):
74 + return {d.split(':')[0].strip() for d in body.split(';') if ':' in d and d.split(':')[0].strip().startswith(('-', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'))}
75 +
76 + def keys(sel):
77 + """(class, state) per compound in the selector, so a rule is only ever
78 + compared against the generated rule for the SAME state. Unioning the
79 + states instead reports .card { color } against the generated
80 + .card:disabled, which is not a contest: they never both apply."""
81 + out = set()
82 + for compound in re.split(r'[\s>+~,]+', sel):
83 + m = re.match(r'\.([a-z][a-z0-9-]*)', compound)
84 + if not m:
85 + continue
86 + # Everything else in the compound is state: further classes as much as
87 + # pseudo-classes. Dropping the extra classes keys .tab.chosen as plain
88 + # .tab and lends the base primitive every property its modifiers set.
89 + state = ''.join(sorted(
90 + re.findall(r'(::?[a-z-]+(?:\([^)]*\))?|\[[^\]]*\])', compound)
91 + + re.findall(r'\.[a-z][a-z0-9-]*', compound)[1:]))
92 + out.add((m.group(1), state))
93 + return out
94 +
95 + generated = {}
96 + for path in sys.argv[1].split():
97 + for sel, body, _raw, _line in rules(path):
98 + for k in keys(sel):
99 + generated.setdefault(k, set()).update(props(body))
100 +
101 + bad = []
102 + for path in sys.argv[2].split():
103 + for sel, body, raw_body, line in rules(path):
104 + if 'respec-ok:' in raw_body:
105 + continue
106 + for cls, state in keys(sel):
107 + clash = generated.get((cls, state), set()) & props(body)
108 + if clash:
109 + short = ' '.join(sel.split())[:70]
110 + bad.append(f" {path.split('/')[-1]}:{line} {short}\n"
111 + f" re-specifies .{cls}{state}: {', '.join(sorted(clash))}")
112 + for b in sorted(set(bad)):
113 + print(b)
114 + PY
115 + )
116 + report "no-primitive-respec" \
117 + "A local rule re-specifies what the generated sheet already sets. Delete it and take the generated look, or add /* respec-ok: <reason> */ naming what the generated sheet cannot express." \
118 + "$hits"
119 +
120 + # 2. Every var(--token) must resolve to a property something defines.
121 + #
122 + # An undefined custom property is invalid at computed-value time, which
123 + # drops the WHOLE declaration rather than falling back — so this fails
124 + # silently and looks like a layout bug months later. 770f38c0 deleted the
125 + # brand-alias block and converted style.css but not the two per-page
126 + # sheets; both spent from then until 2026-08-10 dropping every colour they
127 + # declared. A var() with a fallback is fine and is skipped: those are the
128 + # properties JS sets at runtime.
129 + hits=$(python3 - "$GENERATED_SHEETS $SITE_SHEETS" <<'PY'
130 + import re, sys
131 +
132 + paths = sys.argv[1].split()
133 + defined = set()
134 + for p in paths:
135 + defined |= set(re.findall(r'^\s*(--[a-z0-9-]+)\s*:', open(p).read(), re.M))
136 +
137 + for p in paths:
138 + for n, line in enumerate(open(p), 1):
139 + for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*([,)])', line):
140 + if m.group(2) == ',':
141 + continue # has a fallback: JS-set at runtime
142 + if m.group(1) not in defined:
143 + print(f" {p.split('/')[-1]}:{n} {m.group(1)} is used but never defined")
144 + PY
145 + )
146 + report "no-undefined-token" \
147 + "var(--token) with no definition and no fallback. The whole declaration is dropped at computed-value time." \
148 + "$hits"
149 +
150 + # 3. No raw colour literal outside the two blocks documented to hold them.
151 + #
152 + # A literal picked against parchment and then applied to all 31 themes a
153 + # creator can select is the defect the bevel pair and then the elevation
154 + # intent were each introduced to undo, and it grew back both times. The
155 + # intent :root and the APP-LOCAL CONSTANTS block are where a literal is
156 + # allowed to live; everywhere else wants a token.
157 + hits=$(python3 - "$SITE_SHEETS" <<'PY'
158 + import re, sys
159 +
160 + LITERAL = re.compile(r'(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[0-9])')
161 + for p in sys.argv[1].split():
162 + src = open(p).read()
163 + src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), src, flags=re.S)
164 + for n, line in enumerate(src.split('\n'), 1):
165 + if not LITERAL.search(line):
166 + continue
167 + decl = line.split(':')[0].strip()
168 + # A literal is allowed as the value of a custom property: that is what
169 + # a token IS. It is not allowed as the value of anything else.
170 + if decl.startswith('--'):
171 + continue
172 + print(f" {p.split('/')[-1]}:{n} {line.strip()[:80]}")
173 + PY
174 + )
175 + report "no-colour-literal" \
176 + "Raw colour literal outside the intent :root and the APP-LOCAL CONSTANTS block. Use a token so it re-themes." \
177 + "$hits"
178 +
179 + # 4. The site's sheets stay in the components layer.
180 + #
181 + # An unlayered sheet outranks every named layer whatever the specificity,
182 + # so one un-wrapped file silently takes back every contest the layer order
183 + # was declared to settle. Cheap to check, so check it.
184 + hits=""
185 + for sheet in $SITE_SHEETS; do
186 + if ! grep -qE '^@layer components \{' "$sheet"; then
187 + hits="$hits ${sheet##*/} does not open a components layer"$'\n'
188 + fi
189 + done
190 + report "layer-adoption" \
191 + "A site sheet is unlayered, so it beats every named layer regardless of specificity." \
192 + "$(echo "$hits" | sed '/^$/d')"
193 +
194 + if [ $violations -eq 0 ]; then
195 + echo "frontend lint: clean"
196 + exit 0
197 + else
198 + echo
199 + echo "frontend lint: $violations rule(s) failed"
200 + exit 1
201 + fi