From 74a80b2a2020e992ef84a1b7992e062507e1903a Mon Sep 17 00:00:00 2001 From: Kris Armstrong Date: Wed, 16 Sep 2026 07:58:18 -0500 Subject: [PATCH] fix(theme): make msn-shared.css canonical here and gate the copies by hash The four UI repos each kept a copy of the shared colour tokens and they had drifted into three different files with every repo green: seed and niac-go identical, stem carrying its own darker success green, trellis carrying an extra typography block. A per-repo check cannot see a fleet-wide divergence, which is how all four shipped a light-mode --color-surface-border at 1.45:1 against WCAG 1.4.11's 3:1 for a UI edge. - ui/theme/msn-shared.css is now the one copy. Light --color-surface-border #d9d2bd -> #8e7f52 (3.08-3.96:1 on every light surface) and --color-status-success #2f7d4f -> #2a7146 (5.06:1 on the running-row wash status pills sit on), which is the value stem had already reached alone. - The .dark block is the approved 2026-09-15 dark-palette proposal verbatim: dark is now the night version of the warm light theme (hue 43-51 deg) rather than the cold-steel palette that shared only the status hues. - ci-conformance compares each repo's ui/src/theme/msn-shared.css to it by sha256; a repo with no ui/src is out of scope. - The set C product hues are published in ui/theme/README.md. They stay in each repo's product-.css: decision D6 stands. The four adoption PRs are separate rows (UI-SEED-7, UI-STEM-7, UI-NIAC-9, UI-TRL-8), so the check fails all four repos until they land. Fixes #69 --- README.md | 13 +- scripts/check-ci-conformance.py | 54 +++++++- scripts/test-check-ci-conformance.py | 44 ++++++- ui/theme/README.md | 58 ++++++++ ui/theme/msn-shared.css | 190 +++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 ui/theme/README.md create mode 100644 ui/theme/msn-shared.css diff --git a/README.md b/README.md index ae63d86..b1be97d 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,22 @@ The goal: make a fleet-wide change by editing **one file here**, not three repos | `.github/actions/*` (composite) | Shared job steps. Repos call them via `uses: MustardSeedNetworks/.github/.github/actions/@`. | | `profile/README.md` | The organization profile shown at . Logo lives beside it. | | `SECURITY.md`, `CODE_OF_CONDUCT.md` | Org-wide defaults GitHub applies to every repo that has no file of its own. Product repos keep their own. | +| `ui/theme/msn-shared.css` | The canonical shared interface theme. Every UI repo keeps a byte-identical copy at `ui/src/theme/msn-shared.css`; `ci-conformance` compares them by sha256. See `ui/theme/README.md`. | | `scripts/i18n/` | The fleet i18n gate — one canonical copy of the checks; each repo keeps only its own glossary, banned vocabulary and locales. See `scripts/i18n/README.md`. | ## Discipline Share **undifferentiated plumbing** only (dependency versions, CI gates, build contract, release pipeline, security-crypto policy). **Never** share product -code — UI/brand tokens, product features, and authz wrappers stay per-repo, -each repo owning its own implementation. +code — product features and authz wrappers stay per-repo, each repo owning its +own implementation. + +The shared interface theme is the one deliberate exception (owner, 2026-09-15). +It is undifferentiated by definition — the four products are meant to look like +one company — and keeping it per-repo is what let the copies drift into three +versions with every repo green. It is shared as a **checked file**, not as a +package: decision D6 (no shared TS package) stands, each repo still owns its +copy, and `ci-conformance` fails the copy that disagrees. The **brand** tokens +that make a product itself — `product-.css` — stay per-repo. See the remediation plan for the full rationale and the duplication scorecard. diff --git a/scripts/check-ci-conformance.py b/scripts/check-ci-conformance.py index ab48d10..949de25 100755 --- a/scripts/check-ci-conformance.py +++ b/scripts/check-ci-conformance.py @@ -34,11 +34,17 @@ merge burst silently drops main runs — and release-please, gated on workflow_run success, then skips those commits entirely. +9. The shared interface theme matches the canonical copy byte for byte. + `ui/src/theme/msn-shared.css` had drifted into three versions across the + four UI repos with nothing able to see it, so all four shipped the same + 1.45:1 light border. + Exit 0 clean, 1 on any finding. """ from __future__ import annotations +import hashlib import json import re import subprocess @@ -199,6 +205,47 @@ def linter_floor(root: Path, policy_dir: Path) -> list[str]: return sorted(required - enabled) +def shared_theme(root: Path, repo_root: Path) -> list[str]: + """The product's copy of the shared theme, if it has drifted. + + The four UI repos each keep a copy of the colour tokens, and on 2026-09-15 + they were three different files (.github#69): seed and niac-go identical, + stem with its own success green, trellis with an extra typography block. + Every repo was green throughout — a per-repo check cannot see a fleet-wide + divergence, and the divergence is what let all four ship a light-mode + border at 1.45:1 against WCAG 1.4.11's 3:1. + + Compared by hash rather than by token, deliberately: a semantic comparison + would let the comments explaining WHY a value is what it is drift apart, + and those comments are the only record of the measurements behind them. + + A repo with no ui/src is a backend-only repo and is not in scope. + """ + canonical = repo_root / "ui/theme/msn-shared.css" + if not canonical.exists(): + return [] + if not (root / "ui/src").is_dir(): + return [] + copy = root / "ui/src/theme/msn-shared.css" + if not copy.exists(): + return [ + "ui/src/theme/msn-shared.css is missing — every UI repo carries a " + "byte-identical copy of the canonical shared theme" + ] + want = hashlib.sha256(canonical.read_bytes()).hexdigest() + got = hashlib.sha256(copy.read_bytes()).hexdigest() + if got == want: + return [] + return [ + f"ui/src/theme/msn-shared.css has drifted from the canonical theme\n" + f" found: sha256 {got[:12]}\n" + f" expected: sha256 {want[:12]} (MustardSeedNetworks/.github " + f"ui/theme/msn-shared.css)\n" + f" Change the canonical file and re-copy it into all four repos; " + f"a product-local edit cannot pass." + ] + + def status_vocabulary(root: Path, policy_dir: Path) -> list[str]: """Definitions of the fleet status vocabulary that have drifted. @@ -439,7 +486,12 @@ def main() -> int: fail(f"missing {rel} ({why})") findings += 1 - policy = Path(__file__).resolve().parent.parent / "policy" + repo_root = Path(__file__).resolve().parent.parent + for drift in shared_theme(root, repo_root): + fail(drift) + findings += 1 + + policy = repo_root / "policy" if policy.exists(): try: for missing in linter_floor(root, policy): diff --git a/scripts/test-check-ci-conformance.py b/scripts/test-check-ci-conformance.py index 3a41827..5b69d93 100644 --- a/scripts/test-check-ci-conformance.py +++ b/scripts/test-check-ci-conformance.py @@ -67,7 +67,8 @@ class ConformanceChecks(unittest.TestCase): - def run_checker(self, ci: str, status_definitions: str = "") -> tuple[int, str]: + def run_checker(self, ci: str, status_definitions: str = "", + theme: str | None = None) -> tuple[int, str]: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) (root / ".github/workflows").mkdir(parents=True) @@ -83,6 +84,9 @@ def run_checker(self, ci: str, status_definitions: str = "") -> tuple[int, str]: if status_definitions: (root / "ui/src").mkdir(parents=True) (root / "ui/src/state.ts").write_text(status_definitions) + if theme is not None: + (root / "ui/src/theme").mkdir(parents=True, exist_ok=True) + (root / "ui/src/theme/msn-shared.css").write_text(theme) p = subprocess.run([sys.executable, str(CHECKER)], cwd=root, capture_output=True, text=True) return p.returncode, p.stdout + p.stderr @@ -195,5 +199,43 @@ def test_record_state_remains_an_alias(self) -> None: self.assertIn("`RecordState` has drifted", out) + # ---- shared theme (.github#69) ------------------------------------- + # + # The fleet shipped three different msn-shared.css files with every repo + # green, so these assert on the REAL canonical file rather than a fixture + # of one: a canonical edit that is not re-copied must fail here too. + + CANONICAL = CHECKER.parent.parent / "ui/theme/msn-shared.css" + + def test_identical_theme_copy_passes(self) -> None: + _, out = self.run_checker(GOOD_CI, theme=self.CANONICAL.read_text()) + self.assertNotIn("has drifted from the canonical theme", out) + + def test_drifted_theme_copy_is_rejected(self) -> None: + drifted = self.CANONICAL.read_text().replace( + "--color-status-success: #2a7146;", "--color-status-success: #2f7d4f;") + self.assertNotEqual(drifted, self.CANONICAL.read_text(), + "fixture did not mutate the canonical file") + code, out = self.run_checker(GOOD_CI, theme=drifted) + self.assertNotEqual(code, 0, out) + self.assertIn("has drifted from the canonical theme", out) + + def test_comment_only_theme_drift_is_rejected(self) -> None: + """Comments carry the measurements; they drift too.""" + drifted = self.CANONICAL.read_text() + "\n/* product-local note */\n" + code, out = self.run_checker(GOOD_CI, theme=drifted) + self.assertNotEqual(code, 0, out) + self.assertIn("has drifted from the canonical theme", out) + + def test_missing_theme_in_a_ui_repo_is_rejected(self) -> None: + code, out = self.run_checker(GOOD_CI, status_definitions="// ui repo\n") + self.assertNotEqual(code, 0, out) + self.assertIn("ui/src/theme/msn-shared.css is missing", out) + + def test_backend_only_repo_is_out_of_scope(self) -> None: + _, out = self.run_checker(GOOD_CI) + self.assertNotIn("msn-shared.css", out) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/ui/theme/README.md b/ui/theme/README.md new file mode 100644 index 0000000..5aa4113 --- /dev/null +++ b/ui/theme/README.md @@ -0,0 +1,58 @@ +# Shared interface theme + +`msn-shared.css` here is the canonical copy of the colour half of every +product's UI. Each UI repo (`seed`, `stem`, `niac-go`, `trellis`) keeps a +**byte-identical** copy at `ui/src/theme/msn-shared.css`, and the fleet +`ci-conformance` gate compares the two by sha256. + +## Why a hash check + +On 2026-09-15 the four copies were three different files +([.github#69](https://github.com/MustardSeedNetworks/.github/issues/69)): +`seed` and `niac-go` identical, `stem` carrying its own darker success green, +`trellis` carrying an extra typography block. Every repo was green the whole +time — a per-repo check cannot see a fleet-wide divergence — and all four +shipped a light-mode `--color-surface-border` at 1.45:1 against WCAG 1.4.11's +3:1 for a UI edge. + +The comparison is by hash, not by token, on purpose: a semantic comparison +would let the comments explaining *why* a value is what it is drift apart, and +those comments are the only record of the measurements behind them. + +## Changing a colour + +1. Edit `ui/theme/msn-shared.css` here. +2. Copy it verbatim into all four repos' `ui/src/theme/msn-shared.css` in one + lockstep change. + +A product-local edit fails `ci-conformance` by construction. A repo with no +`ui/src` directory is out of scope. + +## Scope + +Colour tokens plus the small component layer built on them (`.kicker`, +`.figure`, `.panel`, `.target`). A product's own type scale is **not** here — +`seed`, `stem` and `niac-go` define `.heading-1`, `.body-small` and the +`.gap-*` helpers in their own `index.css`, and `trellis` should too. + +## Product hues (set C, owner 2026-09-15) + +These are **not** in the shared file: each repo sets them in its own +`ui/src/theme/product-.css`, because the brand anchor is the one thing +the products do not share. They are published here so the four stay one +family. Each dark value is at least 4.5:1 on the dark page ground `#181611`. + +| Product | Light | Dark | +| --- | --- | --- | +| `seed` | `#2f7d3a` | `#34b244` | +| `stem` | `#1565c0` | `#2d81e1` | +| `niac` | `#6a3fa8` | `#9569d3` | +| `trellis` | `#b94689` | `#cd5199` | + +## Dark mode + +Dark is the night version of the light theme, not a second palette (owner, +2026-09-15). It used to sit at 195–208° cold steel against a 44–50° warm-cream +light theme and shared only the status hues. Every surface and text token is +now derived at hue 43–51°, and every pair is measured: text 4.5:1 or better, +UI edges 3:1 or better. diff --git a/ui/theme/msn-shared.css b/ui/theme/msn-shared.css new file mode 100644 index 0000000..34c1de1 --- /dev/null +++ b/ui/theme/msn-shared.css @@ -0,0 +1,190 @@ +/* ========================================================================== + MSN shared interface theme — the canonical copy + ========================================================================== + This file is the ONE source of the colour half of every product's UI. Each + product repo keeps a byte-identical copy at ui/src/theme/msn-shared.css and + the fleet `ci-conformance` gate compares it by sha256, because the copies + had already drifted into three versions (.github#69, 2026-09-15): seed and + niac-go byte-identical, stem carrying one different success green, trellis + carrying an extra typography block. Nothing could see it — every repo was + green, and only diffing the four files showed it. + + Change the colours HERE, then re-copy into the four repos in one lockstep + change. A product-local edit fails the hash check by construction. + + Scope: colour tokens plus the small component layer built on them. It is + deliberately NOT the place for a product's own type scale — trellis had + added .heading-1/.body-small/.gap-* here because it lacked them, while the + siblings define them in their own index.css; that block belongs in + trellis's index.css beside its siblings' (UI-TRL-8). + + Import order in each repo's index.css: + + @import "tailwindcss"; + @import "./theme/msn-shared.css"; ← this file + @import "./theme/product-.css"; ← the brand values + + The fleet product hues (set C, owner 2026-09-15) live in each repo's + product-.css and are published in ui/theme/README.md beside this file. + + Why the values are what they are: + + 1. Light mode is warm cream and stays so. Two of its tokens were fixed on + 2026-09-15 for WCAG: surface-border was #d9d2bd (1.45:1 on the page — + 1.4.11 needs 3:1 for a UI edge) and status-success was #2f7d4f, which + measured 4.42:1 on its own 10 % wash, the surface status pills actually + sit on. Stem had already darkened the green locally; that value is now + the fleet's. + 2. Dark mode is the NIGHT VERSION of light, not a second palette (owner + 2026-09-15). It used to sit at 195–208° cold steel against a 44–50° + warm-cream light theme and shared only the status hues. Every surface + and text token below is derived at hue 43–51°, and every pair was + measured: text 4.5:1 or better, UI edges 3:1 or better. The values are + the approved "Dark proposed" column of the 2026-09-15 dark-palette + proposal, taken verbatim. + 3. Card borders are hairlines. A 1px solid edge on every card drew a grid + of boxes; the eye counted containers instead of reading content. + surface-border keeps its old role for form controls and dividers, where + a visible edge IS the point, and the hairline tokens carry panels. + 4. Figures are monospaced, everywhere, via --font-family-mono. Every value + that changes on refresh — a rate, a latency, an address, a VMID — is + compared against its previous reading, and proportional digits make + that harder than it needs to be. The mono stack is native: no product + may fetch a font. + ========================================================================== */ + +:root { + /* ---- surfaces (light: warm cream, unchanged in character) ---------- */ + --color-surface-base: #fbfaf5; + --color-surface-raised: #ffffff; + /* was #d9d2bd — 1.45:1 on the page ground, failing WCAG 1.4.11 for a + UI edge. 3.48:1 on base and 3.08:1 on the sunken surface now, the + mirror of the dark border's 3.12–3.58 range. */ + --color-surface-border: #8e7f52; /* form controls, dividers */ + --color-surface-hover: #f3efe2; + --color-surface-sunken: #e9e3ce; + --color-surface-deep: #efe9d8; + + /* ---- text ---------------------------------------------------------- */ + --color-text-primary: #1a2520; + --color-text-secondary: #4a5650; + /* was #6b766f — 4.52:1, technically passing but with no margin at 10px */ + --color-text-muted: #5f6a63; + --color-text-inverse: #ffffff; + --color-text-disabled: #8a938c; + + /* ---- status (constant across products and modes) ------------------- */ + /* was #2f7d4f — 4.42:1 on its own 10 % wash (#eaf2ed) and 4.31:1 on the + running-row variant (#e8efe9), so status pills failed WCAG AA for 12px + text on the surface they are designed to sit on. 5.19:1 and 5.06:1 now, + matching the margin the warning token was darkened to. */ + --color-status-success: #2a7146; + --color-status-warning: #8a6208; /* darkened from #d4a017: 3.82:1 → 5.1:1 */ + --color-status-error: #b93a3a; + --color-status-info: #1263a8; + + /* ---- log levels (Syslog/RFC 5424, constant) ------------------------ */ + --color-log-trace: #64748b; + --color-log-debug: #0e7490; + --color-log-info: #1d4ed8; + --color-log-warn: #a16207; + --color-log-error: #b91c1c; + --color-log-fatal: #991b1b; + + /* ---- constants ----------------------------------------------------- */ + --color-scrim: #000000; + --color-knob: #ffffff; + + /* ---- additions ----------------------------------------------------- */ + /* Panel boundaries. Hairlines, not borders. */ + --color-hairline: rgba(26, 37, 32, 0.1); + --color-hairline-strong: rgba(26, 37, 32, 0.18); + /* Navigation rail gradient. */ + --color-rail-from: #f7f4ea; + --color-rail-to: #efe9d8; + /* Eyebrows and accent icons. Was per-product --color-brand-gold with the + same value in all four repos; shared here so the dark lift is one + decision. */ + --color-accent-gold: #8a6208; +} + +.dark { + /* Derived from the light tokens at hue 43–51°, not a second palette. Every + * value here is the approved 2026-09-15 proposal verbatim, except the two + * hairlines, which the proposal does not list: they carry text-primary at + * the alphas the old cold-steel pair used. The contrast in each comment is + * measured, not asserted. */ + --color-surface-base: #181611; + --color-surface-raised: #23211a; + --color-surface-border: #746d58; /* 3.50 on base, 3.12 on raised */ + --color-surface-hover: #312e25; + --color-surface-sunken: #15140e; + --color-surface-deep: #12110c; + + --color-text-primary: #f3efe2; /* 15.7 on base, 14.0 on raised */ + --color-text-secondary: #b5ac8c; /* 7.96 on base, 7.09 on raised */ + --color-text-muted: #91896e; /* 5.17 on base, 4.61 on raised */ + --color-text-inverse: #181611; + --color-text-disabled: #706b5c; /* 3.40 on base, 3.03 on raised */ + + --color-status-success: #3cb46e; + --color-status-warning: #ca9721; + --color-status-error: #de8787; + --color-status-info: #5da5e5; + + --color-log-trace: #8a9bb2; + --color-log-debug: #21a5ca; + --color-log-info: #7795e9; + --color-log-warn: #d38922; + --color-log-error: #e97777; + --color-log-fatal: #f15050; + + /* Hairlines carry the text colour, so they warm with the ground. */ + --color-hairline: rgba(243, 239, 226, 0.14); + --color-hairline-strong: rgba(243, 239, 226, 0.22); + --color-rail-from: #15140e; + --color-rail-to: #12110c; + --color-accent-gold: #b88a1e; /* 5.76 on base, 5.13 on raised */ +} + +/* Third label tier. The old scale had heading-1…4 plus section-title, and + * every product ended up hand-rolling the 10px uppercase kicker that labels + * a panel. Name it once. */ +@layer components { + .kicker { + font-size: 0.625rem; /* 10px */ + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-text-muted); + } + + .kicker-accent { + color: var(--color-accent-gold); + } + + /* Figures: any value that changes on refresh. */ + .figure { + font-family: var(--font-family-mono); + font-variant-numeric: tabular-nums; + letter-spacing: 0; + } + + /* Panels are hairline-bounded; .card keeps the old solid border for + * anything that must read as an input surface. */ + .panel { + border: 1px solid var(--color-hairline); + border-radius: 1rem; + background: var(--color-surface-raised); + } + + .panel-hover:hover { + border-color: color-mix(in oklab, var(--color-brand-primary) 50%, transparent); + background: var(--color-surface-hover); + } + + /* 44px minimum interactive target — was enforced per product, now shared. */ + .target { + min-height: 2.75rem; + } +}