Skip to content

Adopt the console design system, with the map's palette as dark - #35

Merged
Babissimo merged 1 commit into
mainfrom
feat/ui-brand-align
Sep 15, 2026
Merged

Babissimo merged 1 commit into
mainfrom
feat/ui-brand-align

Conversation

@Babissimo

@Babissimo Babissimo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The UI was styled to nothing in particular: a navy gradient header over gradient
buttons, drop shadows where the rest of the estate uses hairlines, and forty-odd
colour literals spread across four stylesheets and three components. It read as a
different product from the consoles it sits beside.

This takes dash.retina.fm's design system wholesale, with map.retina.fm's palette
as the dark theme, per claude-shared/docs/brand/brand-guide.md. dash already
carries the map's dark ramp as its own dark theme, so this is the same two
palettes under the same token names: retina-server's dashboard, its
map-surface.css and this now share one vocabulary.

The appearance switch is dash's, ported class for class

Not an approximation of it. ThemeContext with the same three states and the
same retina.theme storage key, and the same .theme-switch radiogroup:
Feather sun/monitor/moon, ordered light → system → dark, roving tabIndex,
arrows moving selection and focus together, aria-label plus title on each
button since they carry no text.

system is the default and stamps no attribute, letting the
prefers-color-scheme block answer, so the OS preference needs no JavaScript and
keeps working when the OS changes its mind mid-session. The cost is the dark
palette written twice, once per selector, because CSS cannot share a declaration
block across a media query boundary; themeTokens.test.ts (ported from dash)
guards the two copies against drifting, and also enforces that no palette value
is hardcoded outside the token block.

The console hangs the switch under an "Appearance" label in its avatar menu.
There is no signed-in user on this surface, so it sits in the header bar. Only
the placement differs.

Deliberate departures

Three, each where the guide records the original as a gap rather than an intent:
a focus ring (dash has none anywhere), a disabled state on the primary button
(dash attaches it to .btn-secondary alone), and a styled error boundary (dash
and the map both fall back to an unstyled heading, which on dark is black on
white in the middle of the console). The search spinner also stays, against
dash's no-spinner-anywhere rule: this query warns it may take a minute.

One silent trap, now pinned by a test

react-leaflet replays pathOptions through setStyle, which drops className,
so the search-radius ring kept Leaflet's own #3388ff in both themes with
nothing erroring; the build, the type check and the whole suite passed. The class
is a top-level prop now, and an e2e test asserts the computed stroke per theme.

Also

  • The rank ramp and band chips become tokens so they brighten on navy. The bands
    take owl-os's palette, which exists there because picking a transmitter is the
    same job this page does.
  • The map panel fills its grid cell, with a ResizeObserver calling
    invalidateSize: Leaflet caches the container size at init, and adding a
    frequency row changes it with no window resize to notice.
  • The stylesheet lint is themeTokens.test.js, JavaScript rather than
    TypeScript, which is the one odd-looking thing in here. It reads
    surface.css off disk because vite's ?raw transform returns an empty
    string in this repo, and a lint over an empty file passes every assertion in
    it. Reading a file needs node:fs, which needs @types/node, and adding
    that rewrites package-lock.json: npm on macOS prunes the other platforms'
    optional @esbuild builds out of the lock and CI's clean install then
    refuses it. tsconfig already sets checkJs: false, so a .js test is run
    by vitest and skipped by tsc, and package.json, package-lock.json and
    tsconfig.json stay byte-identical with main.

Verification

90 unit, 19 e2e (Chromium), 681 backend, typecheck and build all green.
Rendered and checked at 1440px, 390px and 320px in both themes; no horizontal
overflow at either phone width.

Brand-guide side: offworldlabs/claude-shared#23.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 5m 41s —— View job


Claude is reviewing this PR

  • Read trigger request and repo instructions
  • Gather context (no CLAUDE.md in repo; diff against origin/main reviewed)
  • Run code review skill across changed files (finder agents running, compiling and verifying results)
  • Post findings

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 3m 0s —— View job


Claude's review

  • Read trigger request and repo instructions
  • Gather context (diff against origin/main, changed files list)
  • Run code review across changed files (theming, CSS tokens, ThemeSwitch/ThemeContext, TowerMap ResizeObserver, tests)
  • Post findings

Went through the full diff: ThemeContext.tsx, ThemeSwitch.tsx (+ its test), surface.css + themeTokens.test.js, TowerMap.tsx/.css, App.tsx/.css, SearchForm/ResultsTable/ErrorBoundary, main.tsx, the e2e theme spec, and the backend CSP test. Note up front: this sandbox has no network and no node_modules, so I read the code rather than running npm test/typecheck/build myself — I'm relying on the PR's stated 86 unit / 19 e2e / 681 backend green run.

Overall: this is unusually careful work — the light/dark token duplication is guarded by themeTokens.test.ts, the pathOptions/className Leaflet trap is both fixed and pinned by an e2e assertion on computed stroke, useResolvedTheme correctly degrades outside a provider, and the CSS token discipline (surface.css is the only file with literal colors) checks out — I grepped the other component stylesheets and found no leaked hex/rgba values. I didn't find any functional bugs in the theme switch, resize handling, or CSS variable wiring.

One inconsistency worth fixing before merge:

PR description contradicts the shipped code on @types/node. The body's "Also" section says:

@types/node is added so the stylesheet lint can read surface.css off disk.

but frontend/package.json and package-lock.json have zero diff in this PR — @types/node was not added. And themeTokens.test.js's own header comment says the opposite happened on purpose:

JavaScript, not TypeScript, and that is the whole reason: reading a file needs node:fs, which needs @types/node, and adding it rewrites package-lock.json — npm on macOS prunes the other platforms' optional @esbuild builds out of the lock, and CI's npm ci then refuses it. ... Types buy a stylesheet lint almost nothing; a 512-line lockfile rewrite inside a UI change costs plenty.

So the actual (correct, and better) decision was to avoid adding @types/node by writing the lint as a plain .js file. The PR description just states the opposite of what was implemented — worth a one-line fix to the description so the history doesn't mislead future readers about why this file is .js and not .ts.

No other issues found. Fix this →

…dark

The UI was styled to nothing in particular: a navy gradient header over
gradient buttons, drop shadows where the rest of the estate uses hairlines,
and forty-odd colour literals spread across four stylesheets and three
components. It read as a different product from the consoles it sits beside.

Take dash.retina.fm's system wholesale and map.retina.fm's palette as the dark
theme, per claude-shared's brand guide. The pairing is the one the estate
already makes: dash carries the map's dark ramp as its own dark theme, so this
is the same two palettes under the same names, and retina-server's dashboard,
its map-surface.css and this now carry one vocabulary.

The appearance switch is dash's, ported class for class: the three-state
.theme-switch radiogroup, the retina.theme storage key, the ThemeContext whose
`system` stamps no attribute and lets the prefers-color-scheme block answer.
That third state is why the dark palette is written twice, once per selector —
CSS cannot share a declaration block across a media query boundary, so a test
guards the two copies against drifting rather than the stylesheet. The console
hangs the switch under an "Appearance" label in its avatar menu; there is no
signed-in user on this surface, so it sits in the header bar. Only the
placement differs.

Three things are deliberate departures from the surfaces being copied, each
because the guide records the original as a gap rather than an intent: a focus
ring (dash has none anywhere), a disabled state on the primary button (dash
attaches it to .btn-secondary alone), and a styled error boundary (dash and the
map both fall back to an unstyled heading, which on dark is black on white in
the middle of the console). The search spinner also stays, against dash's
no-spinner-anywhere rule: this query warns it may take a minute.

One trap found while building it, silent and now pinned by a test:
react-leaflet replays `pathOptions` through setStyle, which drops `className`,
so the search-radius ring kept Leaflet's own blue in both themes with nothing
erroring. The class is a top-level prop, and an e2e test asserts the computed
stroke per theme.

The rank ramp and the band chips become tokens rather than literals so they
brighten on navy; the bands take owl-os's palette, which exists there because
picking a transmitter is the same job this page does.

The stylesheet lint is JavaScript rather than TypeScript, which is the one
odd-looking thing in here. It reads surface.css off disk, because vite's ?raw
transform returns an empty string in this repo and a lint over an empty file
passes every assertion in it. Reading a file needs node:fs, which needs
@types/node, and adding that rewrites package-lock.json: npm on macOS prunes
the other platforms' optional @esbuild builds out of the lock, and CI's clean
install then refuses it. tsconfig already sets checkJs: false, so a .js test is
run by vitest and skipped by tsc, and the lockfile is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 1m 45s —— View job


Claude finished reviewing this PR

  • Read trigger request and repo instructions
  • Gather context (diff against origin/main)
  • Run code review across changed files
  • Post findings

Summary

This is a large, well-executed styling migration: 21 files, one design-token system (frontend/src/surface.css) driving both light and dark palettes, and thorough test coverage for the tricky parts (the dual dark-mode blocks, the pathOptions/className Leaflet trap, the theme switch a11y behavior). I read through every changed file and diffed against origin/main. Overall this is solid, low-risk work — no correctness bugs found in the app logic. One documentation inconsistency worth fixing before merge:

Findings

1. PR description is out of sync with the actual implementation (minor, but worth fixing)

The PR body states:

@types/node is added so the stylesheet lint can read surface.css off disk.

But frontend/package.json has no @types/node dependency, and frontend/src/themeTokens.test.js itself explains why: it's written as a .js file specifically to avoid adding @types/node (which would rewrite package-lock.json and break npm ci on macOS per the comment in the file), relying on checkJs: false in tsconfig.json so the file is run by vitest but skipped by tsc.

This looks like the PR description is a stale draft written before the approach was changed to sidestep the lockfile churn — the in-code comment (themeTokens.test.js:14-19) is authoritative and correct, but the PR body contradicts it. Worth updating the body so it doesn't mislead future readers about why this file is JS instead of TS.

What I verified

  • surface.css's two dark blocks (@media (prefers-color-scheme: dark) and :root[data-theme="dark"]) are byte-identical, matching what themeTokens.test.js asserts.
  • No hardcoded hex/rgba/named colors remain in App.css, SearchForm.css, ResultsTable.css, or TowerMap.css (grepped independently of the test).
  • The Leaflet pathOptions/className fix in TowerMap.tsx is real: className="search-radius" is now a top-level prop, and frontend/e2e/tower-finder.spec.ts asserts the computed stroke differs per theme (rgb(59,130,246) light vs rgb(56,189,248) dark).
  • ThemeContext.tsx correctly guards localStorage/matchMedia access for SSR/jsdom absence, cleans up its media-query listener, and system preference stamps no data-theme attribute as documented.
  • ThemeSwitch.tsx's roving-tabindex radiogroup keyboard handling (arrows/Home/End) is correct and matches its own test coverage.
  • CSS classes referenced from renamed components (.card, .chip, .label, .crash-screen, .btn/.btn-primary/.btn-secondary) are all defined in the corresponding stylesheets — no orphaned class references.
  • test_the_page_carries_no_inline_script in backend/tests/test_edge_security_headers.py correctly greps index.html for inline <script> tags lacking src=.

I was not able to run npm ci / the test suites myself in this sandboxed environment (command execution required approval that wasn't available), so I relied on static review rather than re-verifying the "86 unit, 19 e2e, 681 backend" numbers claimed in the description.

@Babissimo

Copy link
Copy Markdown
Contributor Author

Fixed the stale description: the @types/node line described an approach reverted after CI rejected the lockfile churn, and the test counts predated two more guards. themeTokens.test.js's own comment was the authoritative one, as you said.

On the sandbox note — the three suites were run locally before each push, and CI's own frontend and test jobs are the independent check on the numbers.

@Babissimo
Babissimo merged commit be7e38b into main Sep 15, 2026
7 checks passed
@Babissimo
Babissimo deleted the feat/ui-brand-align branch September 15, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant