Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions backlog/tasks/task-590 - Add-six-web-colour-themes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
id: TASK-590
title: Add six web colour themes
status: Done
assignee:
- '@codex'
created_date: '2026-10-07 17:00'
updated_date: '2026-10-11 16:16'
labels: []
dependencies: []
references:
- src/viewers/web/atoms/theme.ts
- src/viewers/web/page.ts
modified_files:
- src/viewers/web/atoms/theme.ts
- src/viewers/web/page.ts
- src/viewers/web/startup/page.ts
- test-bun/web-theme.test.ts
- test-bun/web-sharing.test.ts
- docs/viewers/web/index.md
- src/viewers/web/url.ts
- src/viewers/web/sharing/images.ts
ordinal: 640000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Architecture maps need palettes that suit different projects, including a dark electric-blue and cyan palette.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Six additional themes are selectable and remembered in the web viewer.
- [x] #2 New themes survive shared query URLs and publication paths, including setup and exported covers.
- [x] #3 Existing Auto, Light, Dark and Blueprint choices keep working.
<!-- AC:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [x] #1 Acceptance criteria have objective verification evidence.
- [x] #2 Relevant checks pass and changes remain task-scoped.
- [x] #3 Public contracts or documentation are updated when behavior changes.
- [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes.
<!-- DOD:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
Extend the existing palette registry and derive page/setup CSS from it. Keep the current theme picker, URL and cover mechanisms. Extend existing URL and sharing tests because their hardcoded old choices miss unsupported new themes; verify actual switching in a browser and run the repository check.
Rename the electric-blue/cyan palette to Electric throughout the public theme registry, documentation and PR. Rebuild the website and recheck theme links and covers.
Test note for the maintainer pass. Rule and authority: acceptance criteria 1 and 2, every theme choice survives publication paths and shared URLs and gets a cover image. Wrong result caught: a new theme that the URL reader or the cover export does not know. Gap: the tests named only the original themes, and the first version of this change replaced those names with the implementation's own list, which cannot fail. Smallest change: name the themes literally and add one new theme to each loop.
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Focused viewer checks: 14 pass, 0 fail with the repository runner options. Chrome browser checks passed for all 9 palettes: picker, saved choice, reload and setup screen. Full check: lint, typecheck and 32 Node tests pass; Bun suite 756 pass, 51 skip, 17 fail, all Java/Scala/COBOL scanner failures due to missing Java runtime. No scanner changes. Browser screenshots retained outside source.

Renamed the electric-blue/cyan palette to Electric at the owner request; removed product references from public code, documentation, task description and PR. All 14 focused tests and browser checks for all 9 palettes pass after renaming. Website export rebuilt; 57 site tests pass.

Maintainer pass, 11 October 2026: merged main (Bun 1.4.3, OpenTUI 0.6.0) and renumbered this record from TASK-557, which main already uses. A cold simplicity review led to these changes: the theme names are one list (webThemes) that the menu, URL values, page CSS and cover images all read; cssBlock no longer emits color-scheme, so the main viewer keeps its behaviour for Light, Dark and Blueprint and only the startup screen sets it, as before; the theme menu marks are typed without casts; the theme tests name their themes literally again and include one new theme (sand).

Maintainer verification, 11 October 2026, on Bun 1.4.3 with main merged: bun run check passed (Biome: one existing warning and two infos outside the changed files; TypeScript passed; 16 Node tests; 780 Bun tests passed, 52 skipped for missing optional toolchains, 0 failed). Live viewer on a fixture project: the theme menu lists ten choices, the four original ones with their icons and the six new ones with a dot in their highlight colour; theme=sand and theme=electric apply their palettes from the URL; the viewer's document colour scheme is unchanged from main; no console errors. Open for the owner: every export now writes nine cover images instead of three (about 2.2 s and 3.8 MB against 0.75 s and 1.2 MB on this repository's map), and the new palettes use their own highlight colour for selection instead of the brand green. Two commits in this branch's history contain a third-party product name; squash-merge to keep it out of main.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added Electric, Solar, Ocean, Forest, Plum and Sand to the existing theme menu, shared URLs, startup CSS and exported covers. Focused and browser checks pass; full suite requires Java for unrelated scanners.
<!-- SECTION:FINAL_SUMMARY:END -->
11 changes: 7 additions & 4 deletions docs/viewers/web/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ infer its merge base, or install a CI workflow.

### Social previews

Export also writes `cover-light.png`, `cover-dark.png`, and `cover-blueprint.png`
Export also writes a `cover-{theme}.png` for each theme (excluding Auto)
at 1200×630 pixels. Each cover uses the actual map, one continuous graph-paper
grid, and a glass footer with the project title and groma.md attribution.
The map's geometry, drawing, themes, and bundled fonts remain the source of truth.
Expand Down Expand Up @@ -275,11 +275,14 @@ so all Help content fits without scrolling at 1280×720. The information icon op
a brief product description and repository link, followed by every directly declared
third-party runtime library and development tool, including its version, license,
and project link. The dropdown lists Auto,
Light, Dark and Blueprint. Auto is the default for a browser profile with no
Light, Dark, Blueprint, Electric, Solar, Ocean, Forest, Plum and Sand. Auto is the default for a browser profile with no
saved choice and follows that browser's light or dark colour preference. A
choice is saved for later visits. Blueprint uses a deep navy field, quieter blue drafting lines,
and groma.md green for selection and active flows, with restrained calibration marks
and the same architecture and compass. The full footer is absent, and Live work keeps the bottom
and the same architecture and compass. Electric pairs near-black paper with electric blue lines
and cyan highlights. Solar uses warm amber, Ocean teal,
Forest moss green, Plum violet, and Sand warm light paper with terracotta highlights.
All themes use the same map, saved-choice, URL and cover rules. The full footer is absent, and Live work keeps the bottom
centre. The camera fits architecture into the clear area between
the floating hierarchy and any open details pane, so the grid continues
beneath the chrome without hiding the fitted world.
Expand Down Expand Up @@ -930,7 +933,7 @@ Repeated `flow=<id>` parameters name checked scenarios in selection order;
selection, the last flow owns details. An
architecture selection alongside it opens endpoint inspection while retaining
the checked flows and focused step. Opening a shared selection fits its content.
`theme=auto|light|dark|blueprint` names an explicit theme, and `hud=off` hides
`theme=auto|light|dark|blueprint|electric|solar|ocean|forest|plum|sand` names an explicit theme, and `hud=off` hides
the page chrome. A publication path ending in `/architecture/{theme}/`, with
any repository prefix, also selects one of those themes. An explicit query
theme takes priority over the path, then the saved browser preference applies.
Expand Down
154 changes: 152 additions & 2 deletions src/viewers/web/atoms/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { GROMA_ACCENT, GROMA_ACCENT_ON_LIGHT } from '../../../brand.ts'

/** Every colour the page and the map use, as CSS variable values; the map's level tints mix paper and ink in its stylesheet. */
export interface Palette {
/** Not a CSS variable: the startup screen uses it to pick the browser's own light or dark form controls. */
colourScheme: 'light' | 'dark'
paper: string
ink: string
muted: string
Expand Down Expand Up @@ -29,7 +31,12 @@ export interface Palette {
diffRemoved: string
}

export type WebTheme = 'light' | 'dark' | 'blueprint'
/**
* Every palette, in the order the theme menu lists them. This list is the one source for the menu, the
* `theme` URL values, the page CSS and the cover images: each name adds one cover PNG to every export.
*/
export const webThemes = ['light', 'dark', 'blueprint', 'electric', 'solar', 'ocean', 'forest', 'plum', 'sand'] as const
export type WebTheme = typeof webThemes[number]
export type WebThemeMode = 'auto' | WebTheme

/** Shared typography for the interactive map and its published cover. */
Expand All @@ -43,10 +50,11 @@ export function mixColour(paper: string, ink: string, share: number): string {
return `#${channels}`
}

export const themeModes: readonly WebThemeMode[] = ['auto', 'light', 'dark', 'blueprint']
export const themeModes: readonly WebThemeMode[] = ['auto', ...webThemes]

export const palettes: Record<WebTheme, Palette> = {
light: {
colourScheme: 'light',
paper: '#FFFFFF',
ink: '#22262E',
muted: '#585B62',
Expand All @@ -68,6 +76,7 @@ export const palettes: Record<WebTheme, Palette> = {
diffRemoved: '#B42318',
},
dark: {
colourScheme: 'dark',
paper: '#111315',
ink: '#E6E8EB',
muted: '#9AA0A8',
Expand All @@ -89,6 +98,7 @@ export const palettes: Record<WebTheme, Palette> = {
diffRemoved: '#FF7B72',
},
blueprint: {
colourScheme: 'dark',
paper: '#07152B',
ink: '#D8F3FF',
muted: '#79A9BD',
Expand All @@ -109,6 +119,138 @@ export const palettes: Record<WebTheme, Palette> = {
diffModified: '#FFE066',
diffRemoved: '#FF8FAB',
},
electric: {
colourScheme: 'dark',
paper: '#030813',
ink: '#E6F4FF',
muted: '#91ABC9',
accentText: GROMA_ACCENT,
highlight: '#21D4DC',
highlightText: '#21D4DC',
hairline: '#193151',
hover: 'rgba(33, 212, 220, 0.10)',
line: '#386ED6',
hatch: '#21417D',
syntaxComment: '#8096B5',
syntaxFunction: '#21D4DC',
syntaxKeyword: '#9DADFF',
syntaxNumber: '#FFC08A',
syntaxString: '#83E6CA',
syntaxType: '#68B5FF',
diffAdded: '#68B5FF',
diffModified: '#FFD477',
diffRemoved: '#FF929C',
},
solar: {
colourScheme: 'dark',
paper: '#1D1410',
ink: '#FFF1DE',
muted: '#C2A38C',
accentText: GROMA_ACCENT,
highlight: '#FFB35C',
highlightText: '#FFB35C',
hairline: '#483127',
hover: 'rgba(255, 179, 92, 0.10)',
line: '#AB7854',
hatch: '#69462F',
syntaxComment: '#B59A86',
syntaxFunction: '#FFD48C',
syntaxKeyword: '#F9A3B7',
syntaxNumber: '#FFBC80',
syntaxString: '#B4D99C',
syntaxType: '#9DCFE6',
diffAdded: '#9DCFE6',
diffModified: '#FFD48C',
diffRemoved: '#FF9292',
},
ocean: {
colourScheme: 'dark',
paper: '#071E26',
ink: '#E1F6F7',
muted: '#90B8BF',
accentText: GROMA_ACCENT,
highlight: '#5BD6CE',
highlightText: '#5BD6CE',
hairline: '#204650',
hover: 'rgba(91, 214, 206, 0.10)',
line: '#558E9F',
hatch: '#2D5968',
syntaxComment: '#82A7B1',
syntaxFunction: '#89E2DC',
syntaxKeyword: '#C9B4FF',
syntaxNumber: '#FFCA8A',
syntaxString: '#B4E3A2',
syntaxType: '#8FCFFF',
diffAdded: '#8FCFFF',
diffModified: '#FFCA8A',
diffRemoved: '#FF9A9A',
},
forest: {
colourScheme: 'dark',
paper: '#101C15',
ink: '#EAF3E6',
muted: '#A0B59A',
accentText: GROMA_ACCENT,
highlight: '#B6DA75',
highlightText: '#B6DA75',
hairline: '#2E4332',
hover: 'rgba(182, 218, 117, 0.10)',
line: '#6E9567',
hatch: '#425E3F',
syntaxComment: '#92AB8A',
syntaxFunction: '#E2D68B',
syntaxKeyword: '#D9B1E6',
syntaxNumber: '#F4BA89',
syntaxString: '#B6DA75',
syntaxType: '#99CDDC',
diffAdded: '#99CDDC',
diffModified: '#E2D68B',
diffRemoved: '#F69B98',
},
plum: {
colourScheme: 'dark',
paper: '#201426',
ink: '#F6E9FC',
muted: '#BDA2C8',
accentText: GROMA_ACCENT,
highlight: '#E3A4FA',
highlightText: '#E3A4FA',
hairline: '#493052',
hover: 'rgba(227, 164, 250, 0.10)',
line: '#9F78B0',
hatch: '#64476F',
syntaxComment: '#AC91B7',
syntaxFunction: '#F2C58D',
syntaxKeyword: '#E3A4FA',
syntaxNumber: '#F8B29A',
syntaxString: '#A8DEBF',
syntaxType: '#9ACFF5',
diffAdded: '#9ACFF5',
diffModified: '#F2C58D',
diffRemoved: '#FF99AD',
},
sand: {
colourScheme: 'light',
paper: '#F7F0E3',
ink: '#352D25',
muted: '#706253',
accentText: GROMA_ACCENT_ON_LIGHT,
highlight: '#A34A24',
highlightText: '#963D1A',
hairline: '#DED2BD',
hover: 'rgba(53, 45, 37, 0.06)',
line: '#A3947D',
hatch: '#C5B89F',
syntaxComment: '#786D5E',
syntaxFunction: '#226C76',
syntaxKeyword: '#7C3E91',
syntaxNumber: '#A34A24',
syntaxString: '#3B6D38',
syntaxType: '#315CA0',
diffAdded: '#315CA0',
diffModified: '#89600B',
diffRemoved: '#AC302D',
},
}

/** Whether a stored or shared value names a supported user choice. */
Expand Down Expand Up @@ -158,3 +300,11 @@ export function cssBlock(palette: Palette): string {
--map-grid-major: ${mixColour(palette.paper, palette.line, 0.2)};
`
}

/**
* One `[data-theme]` rule per palette, for the viewer and the startup screen. The viewer keeps Light as its
* `:root` default and never sets `data-theme="light"`; the startup screen sets it for a saved Light choice.
*/
export function themeCss(): string {
return webThemes.map(theme => `[data-theme="${theme}"] { ${cssBlock(palettes[theme])} }`).join('\n')
}
16 changes: 10 additions & 6 deletions src/viewers/web/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { chromeCss } from './atoms/chrome.ts'
import { escaped } from './atoms/escape.ts'
import { anchoredPopoverCss } from './atoms/popover.ts'
import { kindGlyph, kindLabel } from '../atoms/kind.ts'
import { cssBlock, palettes, themeLabel, themeModes, webFontFamily, type WebThemeMode } from './atoms/theme.ts'
import { cssBlock, palettes, themeCss, themeLabel, themeModes, webFontFamily, type WebTheme, type WebThemeMode } from './atoms/theme.ts'
import { addDialogCss } from './chrome/add.ts'
import { creditsControl, creditsCss } from './chrome/credits.ts'
import { editorCss } from './editing/gestures.ts'
Expand Down Expand Up @@ -89,9 +89,8 @@ const style = `
--hierarchy-inset: var(--hierarchy-column);
--details-inset: var(--details-column);
}
[data-theme="dark"] { ${cssBlock(palettes.dark)} }
${themeCss()}
[data-theme="blueprint"] {
${cssBlock(palettes.blueprint)}
--chrome-surface: color-mix(in srgb, var(--paper) 78%, transparent);
}
html { margin: 0; height: 100%; overflow-x: auto; overflow-y: hidden; background: var(--paper); }
Expand Down Expand Up @@ -396,16 +395,21 @@ function legend(): string {
}).join('')
}

const themeIcons: Record<WebThemeMode, string> = {
auto: autoIcon,
/** The original themes have their own icon; every other palette shows a dot in its highlight colour. */
const themeIcons: Partial<Record<WebTheme, string>> = {
light: sunIcon,
dark: moonIcon,
blueprint: blueprintIcon,
}

function themeMark(mode: WebThemeMode): string {
if (mode === 'auto') return autoIcon
return themeIcons[mode] ?? icon(`<circle cx="12" cy="12" r="7" fill="${palettes[mode].highlight}" stroke="none"/>`)
}

function themeControl(): string {
const options = themeModes
.map(mode => `<button class="anchored-option theme-option" type="button" data-theme-mode="${mode}" aria-current="${String(mode === 'auto')}">${themeIcons[mode]}<span>${themeLabel(mode)}</span></button>`)
.map(mode => `<button class="anchored-option theme-option" type="button" data-theme-mode="${mode}" aria-current="${String(mode === 'auto')}">${themeMark(mode)}<span>${themeLabel(mode)}</span></button>`)
.join('')
return `<details id="theme" data-theme-mode="auto"><summary class="anchored-option" aria-label="Theme"><span>Theme</span><span class="label">Auto</span><span class="theme-chevron"></span></summary><div class="anchored-popover theme-menu">${options}</div></details>`
}
Expand Down
4 changes: 2 additions & 2 deletions src/viewers/web/sharing/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { readFileSync } from 'node:fs'
import { initWasm, Resvg } from '@resvg/resvg-wasm'
import wasm from '@resvg/resvg-wasm/index_bg.wasm' with { type: 'file' }
import { fontBuffers } from '../atoms/fonts/index.ts'
import { themeModes, type WebTheme } from '../atoms/theme.ts'
import { webThemes, type WebTheme } from '../atoms/theme.ts'
import { renderCover, type CoverPayload } from './cover.ts'

export const coverThemes = themeModes.filter(theme => theme !== 'auto')
export const coverThemes = webThemes
export type CoverImages = Record<WebTheme, Uint8Array<ArrayBuffer>>

let ready: Promise<void> | undefined
Expand Down
9 changes: 4 additions & 5 deletions src/viewers/web/startup/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { scannerSelection, scannerSelectionScript } from './scanners.ts'

import type { GromaInitResult } from '../../../initialize.ts'
import { escaped } from '../atoms/escape.ts'
import { cssBlock, palettes } from '../atoms/theme.ts'
import { cssBlock, palettes, themeCss, webThemes } from '../atoms/theme.ts'
import { chromeCss } from '../atoms/chrome.ts'
import { startupUpdate, type StartupProgress } from './progress.ts'

Expand All @@ -25,9 +25,8 @@ interface SetupPage {
const style = `
:root { ${cssBlock(palettes.light)} color-scheme: light; }
@media (prefers-color-scheme: dark) { :root { ${cssBlock(palettes.dark)} color-scheme: dark; } }
[data-theme="light"] { ${cssBlock(palettes.light)} color-scheme: light; }
[data-theme="dark"] { ${cssBlock(palettes.dark)} color-scheme: dark; }
[data-theme="blueprint"] { ${cssBlock(palettes.blueprint)} color-scheme: dark; }
${themeCss()}
${webThemes.map(theme => `[data-theme="${theme}"] { color-scheme: ${palettes[theme].colourScheme}; }`).join('\n')}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100dvh; padding: 32px 20px;
Expand Down Expand Up @@ -156,7 +155,7 @@ const style = `
const script = `
${scannerSelectionScript}
const theme = localStorage.getItem('groma.theme');
if (['light', 'dark', 'blueprint'].includes(theme)) document.documentElement.dataset.theme = theme;
if (${JSON.stringify(webThemes)}.includes(theme)) document.documentElement.dataset.theme = theme;
const main = document.querySelector('main');
const content = document.querySelector('#startup-content');
const progress = document.querySelector('#startup-progress');
Expand Down
Loading
Loading