Skip to main content

max / makenotwork

9.9 KB · 245 lines History Blame Raw
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,
15 # typography.css) are makeover's output and are never linted; they are the
16 # standard, not the code. They are still READ, because they are where the
17 # tokens the site's sheets spend are defined.
18 SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css"
19 # `timing.css` joined the list 2026-08-26. It was generated, linked from the
20 # shell, and invisible here, so every token it defines read as undefined to
21 # rule 2 -- which is the same silence rule 2 exists to break, one level up.
22 # Found when makeover-webview 0.60.0 started using `--cadence-activity`, which
23 # timing.css has defined since the axis existed.
24 GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css $STATIC/timing.css"
25
26 violations=0
27
28 # 0a. Every sheet the rules below read must exist.
29 #
30 # A missing generated sheet makes rules 1 and 2 grep nothing and pass,
31 # which is exactly the silence they exist to break. Fail loudly instead.
32 missing=""
33 for sheet in $SITE_SHEETS $GENERATED_SHEETS; do
34 [ -f "$sheet" ] || missing="$missing $sheet"$'\n'
35 done
36 if [ -n "$missing" ]; then
37 echo "[0a] stylesheets missing; run 'cargo build' to generate them:"
38 printf '%s' "$missing"
39 exit 1
40 fi
41
42 # 0b. static/style.css is generated by build.rs from css/*.css and committed.
43 #
44 # Committed so 11,000 lines of annotated CSS keep their blame and so every
45 # guard, test and doc path that reads the built sheet keeps working. That
46 # leaves one door open: editing the output instead of a part. This closes
47 # it by rebuilding the join here and diffing.
48 built=$(
49 cat "$ROOT/css/00-preamble.css"
50 echo "@layer components {"
51 for part in "$ROOT"/css/*.css; do
52 [ "$(basename "$part")" = "00-preamble.css" ] && continue
53 cat "$part"
54 done
55 echo "}"
56 )
57 if ! printf '%s\n' "$built" | diff -q - "$STATIC/style.css" >/dev/null; then
58 echo "[0b] static/style.css does not match css/*.css."
59 echo " It is generated: edit the part, not the output, then 'cargo build'."
60 printf '%s\n' "$built" | diff - "$STATIC/style.css" | head -20
61 exit 1
62 fi
63
64 report() {
65 local rule="$1"; shift
66 local msg="$1"; shift
67 if [ -n "$*" ]; then
68 echo
69 echo "[$rule] $msg"
70 echo "$*"
71 violations=$((violations + 1))
72 fi
73 }
74
75 # 1. No local rule may re-specify a property the generated sheet already sets
76 # for the same primitive.
77 #
78 # Unlayered CSS used to beat @layer makeover by construction, so the
79 # generated sheet lost every contest it entered; the site said .card and
80 # then told itself what a card is. style.css is in the `components` layer
81 # now, which is still ahead of `makeover`, so the contest is stated rather
82 # than accidental but the local rule still wins it. This gate is what makes
83 # winning deliberate.
84 #
85 # The primitives and their properties are read out of layout.css at lint
86 # time rather than listed here, so regenerating makeover updates the rule.
87 #
88 # A survivor carries `/* respec-ok: <reason naming what the generated sheet
89 # cannot express> */` inside the rule body. "Different from what we had" is
90 # the expected outcome of adopting a design system and is not a reason.
91 hits=$(python3 - "$GENERATED_SHEETS" "$SITE_SHEETS" <<'PY'
92 import re, sys
93
94 def rules(path):
95 """(selector, body, line) for every rule with declarations, @media included."""
96 raw = open(path).read()
97 # Blank comments out rather than deleting them, so line numbers survive.
98 src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), raw, flags=re.S)
99 out, stack, cur, i = [], [], '', 0
100 while i < len(src):
101 c = src[i]
102 if c == '{':
103 stack.append((cur.strip(), i)); cur = ''
104 elif c == '}':
105 if stack:
106 sel, start = stack.pop()
107 body = src[start + 1:i]
108 if '{' not in body:
109 out.append((sel, body, raw[start + 1:i], src.count('\n', 0, start) + 1))
110 cur = ''
111 else:
112 cur += c
113 i += 1
114 return out
115
116 def props(body):
117 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'))}
118
119 def keys(sel):
120 """(class, state) per compound in the selector, so a rule is only ever
121 compared against the generated rule for the SAME state. Unioning the
122 states instead reports .card { color } against the generated
123 .card:disabled, which is not a contest: they never both apply."""
124 out = set()
125 for compound in re.split(r'[\s>+~,]+', sel):
126 m = re.match(r'\.([a-z][a-z0-9-]*)', compound)
127 if not m:
128 continue
129 # Everything else in the compound is state: further classes as much as
130 # pseudo-classes. Dropping the extra classes keys .tab.chosen as plain
131 # .tab and lends the base primitive every property its modifiers set.
132 state = ''.join(sorted(
133 re.findall(r'(::?[a-z-]+(?:\([^)]*\))?|\[[^\]]*\])', compound)
134 + re.findall(r'\.[a-z][a-z0-9-]*', compound)[1:]))
135 out.add((m.group(1), state))
136 return out
137
138 generated = {}
139 for path in sys.argv[1].split():
140 for sel, body, _raw, _line in rules(path):
141 for k in keys(sel):
142 generated.setdefault(k, set()).update(props(body))
143
144 bad = []
145 for path in sys.argv[2].split():
146 for sel, body, raw_body, line in rules(path):
147 if 'respec-ok:' in raw_body:
148 continue
149 for cls, state in keys(sel):
150 clash = generated.get((cls, state), set()) & props(body)
151 if clash:
152 short = ' '.join(sel.split())[:70]
153 bad.append(f" {path.split('/')[-1]}:{line} {short}\n"
154 f" re-specifies .{cls}{state}: {', '.join(sorted(clash))}")
155 for b in sorted(set(bad)):
156 print(b)
157 PY
158 )
159 report "no-primitive-respec" \
160 "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." \
161 "$hits"
162
163 # 2. Every var(--token) must resolve to a property something defines.
164 #
165 # An undefined custom property is invalid at computed-value time, which
166 # drops the WHOLE declaration rather than falling back — so this fails
167 # silently and looks like a layout bug months later. 770f38c0 deleted the
168 # brand-alias block and converted style.css but not the two per-page
169 # sheets; both spent from then until 2026-08-10 dropping every colour they
170 # declared. A var() with a fallback is fine and is skipped: those are the
171 # properties JS sets at runtime.
172 hits=$(python3 - "$GENERATED_SHEETS $SITE_SHEETS" <<'PY'
173 import re, sys
174
175 paths = sys.argv[1].split()
176 defined = set()
177 for p in paths:
178 defined |= set(re.findall(r'^\s*(--[a-z0-9-]+)\s*:', open(p).read(), re.M))
179
180 for p in paths:
181 for n, line in enumerate(open(p), 1):
182 for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*([,)])', line):
183 if m.group(2) == ',':
184 continue # has a fallback: JS-set at runtime
185 if m.group(1) not in defined:
186 print(f" {p.split('/')[-1]}:{n} {m.group(1)} is used but never defined")
187 PY
188 )
189 report "no-undefined-token" \
190 "var(--token) with no definition and no fallback. The whole declaration is dropped at computed-value time." \
191 "$hits"
192
193 # 3. No raw colour literal outside the two blocks documented to hold them.
194 #
195 # A literal picked against parchment and then applied to all 31 themes a
196 # creator can select is the defect the bevel pair and then the elevation
197 # intent were each introduced to undo, and it grew back both times. The
198 # intent :root and the APP-LOCAL CONSTANTS block are where a literal is
199 # allowed to live; everywhere else wants a token.
200 hits=$(python3 - "$SITE_SHEETS" <<'PY'
201 import re, sys
202
203 LITERAL = re.compile(r'(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[0-9])')
204 for p in sys.argv[1].split():
205 src = open(p).read()
206 src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), src, flags=re.S)
207 for n, line in enumerate(src.split('\n'), 1):
208 if not LITERAL.search(line):
209 continue
210 decl = line.split(':')[0].strip()
211 # A literal is allowed as the value of a custom property: that is what
212 # a token IS. It is not allowed as the value of anything else.
213 if decl.startswith('--'):
214 continue
215 print(f" {p.split('/')[-1]}:{n} {line.strip()[:80]}")
216 PY
217 )
218 report "no-colour-literal" \
219 "Raw colour literal outside the intent :root and the APP-LOCAL CONSTANTS block. Use a token so it re-themes." \
220 "$hits"
221
222 # 4. The site's sheets stay in the components layer.
223 #
224 # An unlayered sheet outranks every named layer whatever the specificity,
225 # so one un-wrapped file silently takes back every contest the layer order
226 # was declared to settle. Cheap to check, so check it.
227 hits=""
228 for sheet in $SITE_SHEETS; do
229 if ! grep -qE '^@layer components \{' "$sheet"; then
230 hits="$hits ${sheet##*/} does not open a components layer"$'\n'
231 fi
232 done
233 report "layer-adoption" \
234 "A site sheet is unlayered, so it beats every named layer regardless of specificity." \
235 "$(echo "$hits" | sed '/^$/d')"
236
237 if [ $violations -eq 0 ]; then
238 echo "frontend lint: clean"
239 exit 0
240 else
241 echo
242 echo "frontend lint: $violations rule(s) failed"
243 exit 1
244 fi
245