Radar finale — themes, then the features that make it stick
Five PRs, in order. The first, themes, is planned in detail; the rest are sketched so the order and shape are agreed, and each gets its own short plan before it is built.
- Themes: Night, Blueprint, Paper, High contrast (this plan)
- Deep links: the URL opens on the exact view
- Flight recorder: record a session as Messages and replay it as a guided tour
- Bring your own radar: drop in a file and see your own stack
- Finishing touches: the opening shot, and a poster export
1. Themes
What the reader gets
| Theme | Look | Chosen automatically when |
|---|---|---|
| Night | Today’s radar: night sky, stars, translucent shells, glow | prefers-color-scheme: dark (and the fallback) |
| Blueprint | Cyanotype: deep blue paper, white linework, a faint drafting grid on the equatorial plane instead of stars | Never; picked by hand |
| Paper | Ink on warm white: no stars, no glow, shells as hairline rings, labels in dark ink | prefers-color-scheme: light |
| High contrast | No translucency, solid shell outlines, bolder labels and edges, the line styles kept as the second encoding | prefers-contrast: more or forced-colors |
- A Theme picker sits in the sidebar’s View section: a radio group reading Auto, Night, Blueprint, Paper, High contrast.
- Auto follows the reader’s settings and changes live when they do.
- A manual choice is remembered in this browser (key
radar-theme). Auto clears it.
Design
src/radar/themes.ts, pure.Theme = 'night' | 'blueprint' | 'paper' | 'contrast'andThemeChoice = 'auto' | Theme.- A
themesrecord, checked withsatisfies Record<Theme, Tokens>. ItsTokensare:- the surface and ink
- muted text, the guide line, the label outline
- the shell tint and opacity, and how shells draw (
'translucent' | 'outline') - the sky (
'stars' | 'grid' | 'none') - the halo (
'glow' | 'ring') and an edge-strength factor - the category and relationship colours
- the CSS custom properties (
--surface,--text,--muted,--border,--blue)
resolveTheme(choice, settings)turns a choice plus the media facts into aTheme. It holds every rule above, and it is the one place they live.
- Model.
themeChoice: ThemeChoiceandsettings: { scheme, contrast }. The theme in force is always derived, never stored.- New Messages:
ChangedColorScheme,ChangedContrast,ChoseTheme. - Settings come from
Dom.streamFromMediaQuerysubscriptions, as reduced motion does today.
- Remembering the choice. Two Commands, so
updatestays pure:LoadThemeChoiceat init, settled bySettledThemeChoiceSaveThemeChoiceonChoseTheme
- Scene.
- Every hard-coded colour reads from
themes[theme]:ink,guide,outline, the star colour, both palettes, the shell material and the egg materials. materialsbecomesmaterialsFor(theme)with the same ids. effect-three’s shared material definitions are upserts, so a theme change updates live materials in place, with no remount.- Blueprint replaces the star field with a static line grid. Paper and High contrast show no sky.
- The constellation egg lives in the stars, so it shows in Night and Blueprint only. The other eggs work in every theme.
- Every hard-coded colour reads from
- View.
- The app root carries
data-themeand the theme’s CSS custom properties, so the sidebar, overlays and minimap follow the 3D scene. - The CSS fallbacks stay as Night’s values, for the moment before hydration.
- The page itself stays dark-neutral (
color-schemefollows the theme).
- The app root carries
- Colour still means the same thing.
- Each category keeps its hue in every theme, with a different step per surface. Colour follows the entity, and the legend, labels and table still carry identity, so colour is never alone.
- The relationship palette was validated only against Night’s surface (#12121f). Blueprint and Paper get their own steps.
Palette validation (a build step, with results in the PR)
- Run the validator. Each theme’s category set and relationship set goes through the
dataviz
validate_palette.js, against that theme’s surface:- lightness band, chroma floor
- adjacent-pair CVD ΔE ≥ 8
- normal-vision floor
- contrast against the surface
- Fix any failures. A failing slot snaps to the nearest passing step of the same hue.
- If no step passes on a surface, the slot relies on its line style or body shape there, noted in the PR.
- A contrast WARN means the legend and table must stay visible: they already are.
- High contrast uses the system’s forced colours where they apply, and otherwise Paper’s validated steps at full strength.
Tests (bun:test, pure)
- Tokens: every theme has every token, and
materialsForreturns the same set of ids for every theme. resolveTheme: the whole matrix (scheme × contrast × choice), with a manual choice winning over Auto.- Messages:
ChoseThemesaves, and Auto clears the saved choice.SettledThemeChoicerestores it, and a garbage or missing value falls back to Auto.
- Scene:
- Night has stars; Blueprint has the grid and no stars; Paper and High contrast have no sky.
- High contrast has no translucent shells.
- The constellation egg shows only with stars.
- Palettes: each theme’s palettes match the validated values in
themes.ts. The PR records the validator output.
Delivery
- Branch
feat/radar-themes, three commits:- theme state, picker and remembering, with Night and Paper
- Blueprint and its grid
- High contrast
- A Chromium pass of all four themes at desktop and 375px, with screenshots in the PR.
- Auto is also checked by switching the emulated colour scheme.
2. Deep links
What the reader gets: the address bar always describes the view, so a copied URL opens the radar exactly there. A Copy link button in the details card and the breadcrumb says “Link copied” in the readout.
- The link’s fields (query string, all optional, unknown or invalid ones dropped):
entry: the pinned entryedition: by date, so links survive new editionsview:universe,cluster:<category>orentry, the last flying toentryq,category,rings(only when not all),edges=offtheme(a picked theme; Auto is left out)
- Not the raw camera. A drag is not a place worth linking, and camera noise would churn the URL.
- Pure core in
link.ts:linkOf(model)gives the query string, andapplyLink(model, params)sets edition, filters, pin, theme and the view’s camera. - Reading the link.
radar.astrorenders on the server, so it passes the page’s query parameters to the app as alinkprop.initapplies them on the server and the client alike, so hydration matches. - Writing the link. The
updatewrapper compareslinkOfbefore and after each step. When it changes, it adds aReplaceLinkCommand, which calls Foldkit’sNavigation.replaceUrl(no history entry per click). - Theme in a link applies for that visit only; it is not saved over the reader’s own choice.
- Copy link is a Command writing
location.hrefto the clipboard. If the clipboard refuses, the readout says so. - Tests:
applyLink(linkOf(m))round-trips every field.- Garbage parameters are dropped.
- Drags and hovers never change the link.
initwith a link opens pinned, in the right edition and theme.
3. Flight recorder (guided tours)
What the reader gets: a Tour section in the sidebar: Record, Stop, Play, Save and Load. Play replays the session as a guided tour, with a caption bar over the stage (“Flies to FoldKit”, “Scrubs to the second edition”, “Shatters the Monolith”).
- What is recorded. The reader’s intents, in order, from an allowlist of Messages:
- pins, fly-tos, relays, cluster stops, universe and reset
- turns and zooms
- edition and play
- search, filters, rings, the edges toggle
- theme
- the Monolith and the Konami code
- What is not. Drags, hovers and animation frames: a tour is the named moves, not mouse noise.
- Where a tour starts. A recording begins with
linkOf(model)at the moment Record is pressed. Replay first applies that link, then feeds the steps, so it starts where the recording did. - Pacing is by kind, not by wall-clock time, so tours are deterministic and watchable:
- a flight or relay waits for its landing (about 2.4 s)
- a pin waits 1.5 s
- a filter or scrub waits 1.2 s
- playing the timeline waits for it to finish
- During playback a subscription clock drives the steps. Any interaction of the reader’s stops the tour. Under reduced motion it steps without flights, cameras landing at once.
- Saving and loading. A tour is saved as a JSON file through a download Command, and
loaded through Foldkit’s
File.selectandreadAsText. Loading decodes against the Message schema, and the readout reports a bad file in words. - Tests:
- The central property: folding
updateover a recording, from its start link, ends at the Model the recording stopped at (comparing everything but cameras in flight and clocks). - Pacing is a pure table.
- Captions exist for every recorded kind.
- Disallowed Messages are never recorded.
- The central property: folding
4. Bring your own radar
What the reader gets: a Data section with Load your radar… (.json or .csv)
and Back to the demo. The file is read in the browser and never leaves it.
- JSON: a
RadarDataset, validated by the existingdecodeRadarDataset, so it may carry relationships and editions too. - CSV: the common tech-radar sheet format,
name, ring, quadrant, isNew, description, with an optionalurl.- Quadrant names map case-insensitively to the four categories (Techniques, Tools,
Platforms, Languages & Frameworks);
Holdmaps to Caution. - Ids are slugs of the names, made unique.
- A small pure parser handles quoted fields and commas inside quotes.
- Quadrant names map case-insensitively to the four categories (Techniques, Tools,
Platforms, Languages & Frameworks);
sourceUrlbecomes optional in the entry schema. Today it is required and must parse as a URL, which a sheet without links would fail. The details card hides “Source” when there is none.- Errors list in the panel by row and field (“Row 7: ring ‘Later’ is not adopt, trial, assess or hold”). Nothing loads until the file is clean.
- What follows the data:
- With no editions the timeline hides; with no relationships, the edges and moons do.
- The fixture’s own eggs (the Futhark saucer, the CAP moons on Local-first sync) simply don’t appear. The others work on any data.
- A deep link to an entry the loaded data lacks drops the pin.
- Tests: the CSV parser (quoting, blank lines, BOM), quadrant and ring mapping, id uniqueness, error messages, and a loaded radar rendering a scene.
5. Finishing touches
The opening shot
- The first time the radar opens, the camera starts far out above it and flies in to home (or to a deep link’s view), while a title card (“3D tech radar”, the edition date) fades away.
- How: the model starts with an
openingcamera. On the scene’sStartedScenelifecycle event,updatesets the real target, and the existing flight damping does the rest. This is pure, with no timers. - Under reduced motion the camera starts at its target and the title only fades.
Poster export
- A Save poster button saves the current view as a PNG:
- the stage, plus a strip with the title, edition, theme and date
- named
tech-radar-<theme>-<date>.png - Blueprint is the showpiece.
- How:
- The radar gets its own mount whose WebGL renderer passes
preserveDrawingBuffer: true. effect-three’s renderer factory already forwards renderer options, so no library change is needed. - A Command draws the stage canvas and the caption strip onto an offscreen canvas and downloads it.
- Labels are drawn in the canvas (troika), so they come along. HTML overlays don’t.
- The radar gets its own mount whose WebGL renderer passes
- WebGPU pages: where the page draws with WebGPU (
?renderer=webgpu), capture differs, so the button hides there. That’s noted as a follow-up for effect-three. - Tests:
- The opening steps (start, then the lifecycle sets the target, and reduced motion skips it).
- The poster’s file name and caption text.
- The canvas capture itself is checked in the browser.
Order and delivery
- Each item is its own branch and PR, in the order above, after the themes PR. Deep links include the theme, and tours build on deep links.
- Each is verified with the full suites,
bun run checkand a Chromium pass at desktop and 375px. - Each is committed locally, and pushed and opened as a PR only when asked.