max / makenotwork
5 files changed,
+289 insertions,
-31 deletions
| @@ -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 |