Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes

This commit is contained in:
2026-06-05 01:29:10 +03:00
parent 547edb85e4
commit 22f0556ef8
31 changed files with 713 additions and 136 deletions
+14 -11
View File
@@ -7,25 +7,27 @@
.header {
display: flex;
align-items: center;
gap: var(--space-3);
gap: var(--space-4);
height: var(--header-height);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
padding: 0 var(--space-5);
border-bottom: var(--border-width) solid var(--border);
background: var(--layer-01);
flex: 0 0 auto;
}
.title {
font-weight: 600;
font-size: 16px;
letter-spacing: 0.01em;
}
.version {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
color: var(--text-secondary);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
padding: 1px var(--space-2);
padding: var(--space-1) var(--space-3);
}
.spacer {
@@ -41,14 +43,14 @@
.pane {
flex: 1 1 0;
min-width: 0;
padding: var(--space-4);
overflow: auto;
border-right: 1px solid var(--color-border);
border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
/* Library is a fixed-ish sidebar; editor + preview share the rest. */
.panes > .pane:first-child {
flex: 0 0 260px;
flex: 0 0 280px;
}
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
@@ -56,7 +58,8 @@
flex: 1 1 0;
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--color-border);
border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
.pane:last-child {
+2
View File
@@ -1,6 +1,7 @@
import { LivePreview } from './components/LivePreview';
import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import styles from './App.module.css';
/**
@@ -18,6 +19,7 @@ export function App() {
<span className={styles.title}>Astrolabe</span>
<span className={styles.version}>v{__APP_VERSION__}</span>
<span className={styles.spacer} />
<ThemeToggle />
</header>
<main className={styles.panes}>
+5 -4
View File
@@ -2,6 +2,7 @@
height: 100%;
width: 100%;
overflow: auto;
background: var(--bg);
}
.chart {
@@ -9,16 +10,16 @@
align-items: flex-start;
justify-content: center;
min-height: 100%;
padding: var(--space-2);
padding: var(--space-5);
}
.error {
margin: 0;
padding: var(--space-3);
padding: var(--space-5);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--color-error);
line-height: 1.6;
color: var(--support-error);
white-space: pre-wrap;
word-break: break-word;
}
+33 -25
View File
@@ -2,24 +2,26 @@
display: flex;
flex-direction: column;
height: 100%;
gap: var(--space-3);
}
.createNew {
flex: 0 0 auto;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--color-accent);
color: var(--color-accent-contrast);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
cursor: pointer;
text-align: left;
text-align: center;
transition: background var(--dur-fast) var(--ease);
}
.createNew:hover {
filter: brightness(1.05);
background: var(--accent-hover);
}
.list {
@@ -29,34 +31,36 @@
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-1);
border-top: var(--border-width) solid var(--border);
}
.empty {
color: var(--color-text-muted);
color: var(--text-secondary);
font-size: 13px;
padding: var(--space-2);
padding: var(--space-5) var(--space-4);
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius);
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-left: 2px solid transparent;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.item + .item {
border-top: var(--border-width) solid var(--border);
}
.item:hover {
background: var(--color-surface);
background: var(--layer-01);
}
.active {
background: var(--color-surface);
border-color: var(--color-accent);
background: var(--layer-01);
border-left-color: var(--accent);
}
.itemMain {
@@ -64,7 +68,7 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--space-1);
}
.name {
@@ -77,26 +81,30 @@
.date {
font-size: 11px;
color: var(--color-text-muted);
color: var(--text-secondary);
}
.delete {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--color-text-muted);
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
padding: var(--space-1);
border-radius: var(--radius);
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
.item:hover .delete {
.item:hover .delete,
.delete:focus-visible {
opacity: 1;
}
.delete:hover {
color: var(--color-error);
background: var(--color-bg);
color: var(--support-error);
}
+3 -2
View File
@@ -1,6 +1,7 @@
.editorPane {
position: relative;
height: 100%;
background: var(--bg);
}
.editor {
@@ -15,8 +16,8 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
color: var(--text-secondary);
font-size: 13px;
background: var(--color-bg);
background: var(--bg);
pointer-events: none;
}
+4 -1
View File
@@ -42,6 +42,9 @@ export function SpecEditor() {
language: 'json',
automaticLayout: true,
minimap: { enabled: false },
// The editor is a Plex Mono surface per the design language (doc §3.1).
// Monaco needs an explicit family string — it can't read the CSS token.
fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
fontSize: 13,
tabSize: 2,
wordWrap: 'on',
@@ -78,7 +81,7 @@ export function SpecEditor() {
// Editor theme follows the UI theme (M1: light/dark stock themes).
useEffect(() => {
monaco.editor.setTheme(uiTheme === 'experimental' ? 'vs-dark' : 'vs');
monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme]);
return (
+21
View File
@@ -0,0 +1,21 @@
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.toggle:hover {
background: var(--layer-02);
color: var(--text);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Theme toggle — a header control that flips light ⇄ dark (spec §07 Appearance).
*
* Interim home: the spec houses the UI-theme control inside the Settings modal,
* which arrives in M5. Until then this header button is the control; it persists
* through the same `ui.theme` settings key, so M5 can move it into Settings (or
* keep it as a shortcut) without changing what's stored.
*
* The button shows the icon of the theme you'll switch *to* (moon when light,
* sun when dark) and labels itself for screen readers. The focus ring comes from
* the shared rule in base.css; chart + editor follow the theme via their own
* store subscriptions, so flipping the store repaints everything.
*/
import { useAppStore } from '../stores/AppStore';
import styles from './ThemeToggle.module.css';
function MoonIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
);
}
function SunIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
export function ThemeToggle() {
const uiTheme = useAppStore((s) => s.uiTheme);
const toggleTheme = useAppStore((s) => s.toggleTheme);
const target = uiTheme === 'dark' ? 'light' : 'dark';
return (
<button
type="button"
className={styles.toggle}
onClick={toggleTheme}
aria-label={`Switch to ${target} theme`}
title={`Switch to ${target} theme`}
>
{uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
);
}
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadUiTheme, saveUiTheme } from './settings-store';
const KEY = 'astrolabe:settings';
/**
* In-memory localStorage stub. The adapter is tested against a stub rather than
* the ambient global (docs/architecture/02 §5) — doubly necessary here because
* Node ships a non-functional `localStorage` global that shadows happy-dom's.
*/
function makeStorageStub() {
const map = new Map<string, string>();
return {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, String(v)),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
describe('settings-store · ui.theme', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
describe('loadUiTheme', () => {
it('defaults to light when nothing is stored', () => {
expect(loadUiTheme()).toBe('light');
});
it('returns a stored valid theme', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'dark' } }));
expect(loadUiTheme()).toBe('dark');
});
it('falls back to light on malformed JSON', () => {
localStorage.setItem(KEY, '{ not valid json');
expect(loadUiTheme()).toBe('light');
});
it('falls back for an unrecognized/legacy value (e.g. retired "experimental")', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'experimental' } }));
expect(loadUiTheme()).toBe('light');
});
it('tolerates a record with no ui group', () => {
localStorage.setItem(KEY, JSON.stringify({ formatting: { dateFormat: 'iso' } }));
expect(loadUiTheme()).toBe('light');
});
});
describe('saveUiTheme', () => {
it('round-trips through load', () => {
saveUiTheme('dark');
expect(loadUiTheme()).toBe('dark');
});
it('preserves other keys already in the settings record (forward-compatible merge)', () => {
// Simulate a future/full UserSettings record written by M5.
localStorage.setItem(
KEY,
JSON.stringify({
version: 1,
editor: { fontSize: 14 },
ui: { theme: 'light', previewFitMode: 'width' },
}),
);
saveUiTheme('dark');
const stored = JSON.parse(localStorage.getItem(KEY)!);
expect(stored.ui.theme).toBe('dark');
// Everything else survives — nothing clobbered.
expect(stored.version).toBe(1);
expect(stored.editor.fontSize).toBe(14);
expect(stored.ui.previewFitMode).toBe('width');
});
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Settings persistence (localStorage) — docs/architecture/02 §5.
*
* The authoritative home for `ui.theme` is the *UserSettings* record under the
* `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward
* (the toggle ships before the Settings modal), so this adapter currently wires
* only `ui.theme`. It reads/writes with **load-with-fallback + write-through
* merge**: a partial record written now is preserved key-for-key, so when M5
* builds the full UserSettings adapter on this same key it upgrades cleanly
* rather than clobbering anything.
*
* Per the architecture rule, this is one of the only modules that may touch
* `localStorage`; everything else goes through these typed functions.
*/
import type { UiTheme } from '@core/theme';
const KEY = 'astrolabe:settings';
/** Spec §07 Appearance default. */
const DEFAULT_THEME: UiTheme = 'light';
/** Loose view of the stored record — M5 will give this its full typed shape. */
interface StoredSettings {
ui?: { theme?: unknown; [k: string]: unknown };
[k: string]: unknown;
}
/** localStorage can be absent or throw (private mode, SSR, blocked storage). */
function available(): boolean {
try {
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
} catch {
return false;
}
}
function readRaw(): StoredSettings {
if (!available()) return {};
try {
const raw = localStorage.getItem(KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as StoredSettings) : {};
} catch (err) {
console.warn('[settings] failed to read, using defaults', err);
return {};
}
}
function writeRaw(next: StoredSettings): void {
if (!available()) return;
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch (err) {
console.warn('[settings] failed to write', err);
}
}
function isUiTheme(v: unknown): v is UiTheme {
return v === 'light' || v === 'dark';
}
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
export function loadUiTheme(): UiTheme {
const stored = readRaw().ui?.theme;
// An unrecognized value (a future theme, or the retired 'experimental') falls
// back rather than breaking — the load-with-fallback contract (doc §5).
return isUiTheme(stored) ? stored : DEFAULT_THEME;
}
/** Persist the UI theme, preserving every other key already in the record. */
export function saveUiTheme(theme: UiTheme): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, theme } });
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Theme orchestration — bridges the (browser-free) AppStore to the DOM and to
* localStorage, the same store↔adapter pattern as snippet persistence.
*
* `initTheme` runs synchronously from main.tsx *before* first paint so a saved
* dark theme never flashes light on load. `wireTheme` then keeps `<html
* data-theme>` in sync and writes every change through to the settings adapter.
*/
import { loadUiTheme, saveUiTheme } from '../infrastructure/settings-store';
import { useAppStore } from '../stores/AppStore';
function applyToDocument(theme: string): void {
document.documentElement.dataset.theme = theme;
}
/** Hydrate the persisted theme into the store + document. Call before render. */
export function initTheme(): void {
const theme = loadUiTheme();
useAppStore.getState().setTheme(theme);
applyToDocument(theme);
}
/**
* Mirror store theme → `<html data-theme>` and persist on change. Returns a
* teardown that detaches the subscriber.
*/
export function wireTheme(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.uiTheme === prev.uiTheme) return;
applyToDocument(state.uiTheme);
saveUiTheme(state.uiTheme);
});
}
+20
View File
@@ -0,0 +1,20 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { useAppStore } from './AppStore';
const store = () => useAppStore.getState();
beforeEach(() => store().setTheme('light'));
describe('theme', () => {
test('setTheme sets the theme explicitly', () => {
store().setTheme('dark');
expect(store().uiTheme).toBe('dark');
});
test('toggleTheme flips light ⇄ dark', () => {
expect(store().uiTheme).toBe('light');
store().toggleTheme();
expect(store().uiTheme).toBe('dark');
store().toggleTheme();
expect(store().uiTheme).toBe('light');
});
});
+3
View File
@@ -23,6 +23,8 @@ export interface AppState {
activeModal: ModalName | null;
setTheme: (theme: UiTheme) => void;
/** Flip between light and dark — the header ThemeToggle's action. */
toggleTheme: () => void;
/**
* Low-level modal setter — the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -37,5 +39,6 @@ export const useAppStore = create<AppState>((set) => ({
activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setActiveModal: (activeModal) => set({ activeModal }),
}));
+1 -1
View File
@@ -7,4 +7,4 @@
* must never import from `src/app/`. The store and the document `data-theme`
* mirror this value; this is its single definition.
*/
export type UiTheme = 'light' | 'experimental';
export type UiTheme = 'light' | 'dark';
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
import type { UiTheme } from './theme';
/**
* Light smoke coverage for the chart theme — the design is mostly visual and
* verified by eye (docs/IMPLEMENTATION-PLAN.md M1.5). These guard the contract:
* every theme resolves to a config, charts stay transparent (inherit the
* surface), and the expressive categorical palette is present per theme.
*/
describe('chartConfigFor', () => {
const themes: UiTheme[] = ['light', 'dark'];
it('maps every UiTheme to its config', () => {
expect(chartConfigFor('light')).toBe(lightChartConfig);
expect(chartConfigFor('dark')).toBe(darkChartConfig);
});
it.each(themes)('keeps the chart background transparent (%s)', (theme) => {
expect(chartConfigFor(theme).background).toBe('transparent');
});
it.each(themes)('uses IBM Plex for the chart font (%s)', (theme) => {
expect(chartConfigFor(theme).font).toContain('IBM Plex');
});
it.each(themes)('ships a multi-color categorical palette (%s)', (theme) => {
const category = chartConfigFor(theme).range?.category as string[];
expect(Array.isArray(category)).toBe(true);
expect(category.length).toBeGreaterThanOrEqual(8);
// All entries are hex colors and distinct (no accidental duplicate slot).
expect(category.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
expect(new Set(category).size).toBe(category.length);
});
it('uses distinct palettes per theme', () => {
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
});
});
+66 -23
View File
@@ -1,57 +1,100 @@
/**
* Vega-Lite chart config per UI theme (docs/architecture/05 §3).
* Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §5).
*
* Portable core: a Vega-Lite `Config` styles every chart globally so charts
* visually belong to the app rather than looking like stock Vega-Lite. This is
* the single source of truth mapping a `UiTheme` to a config; it is applied at
* embed time (never baked into the user's stored spec). Adding a UI theme = one
* config object plus one map entry here.
*
* Values track the design language: IBM Plex font, axis/grid colors from the
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a
* categorical `range.category` palette transcribed from Carbon's data-viz
* 14-color pairing (white theme for light, g100 for dark — see
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the
* expressive "free color" layer (doc §3.5, §5).
*/
import type { Config } from 'vega-lite';
import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
/** Carbon data-viz 14-color categorical palette — white (light) theme. */
const lightCategory = [
'#6929c4', // purple 70
'#1192e8', // cyan 50
'#005d5d', // teal 70
'#9f1853', // magenta 70
'#fa4d56', // red 50
'#520408', // red 90
'#198038', // green 60
'#002d9c', // blue 80
'#ee5396', // magenta 50
'#b28600', // yellow 50
'#009d9a', // teal 50
'#012749', // cyan 90
'#8a3800', // orange 70
'#a56eff', // purple 50
];
/** Carbon data-viz 14-color categorical palette — g100 (dark) theme. */
const darkCategory = [
'#8a3ffc', // purple 60
'#33b1ff', // cyan 40
'#007d79', // teal 60
'#ff7eb6', // magenta 40
'#fa4d56', // red 50
'#fff1f1', // red 10
'#6fdc8c', // green 30
'#4589ff', // blue 50
'#d02670', // magenta 60
'#d2a106', // yellow 40
'#08bdba', // teal 40
'#bae6ff', // cyan 20
'#ba4e00', // orange 60
'#d4bbff', // purple 30
];
export const lightChartConfig: Config = {
background: 'transparent',
font: '"Inter", system-ui, sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
axis: {
domainColor: '#1c1c1e',
gridColor: '#e4e4e7',
gridDash: [3, 3],
labelColor: '#52525b',
titleColor: '#1c1c1e',
domainColor: '#c6c6c6', // --border-strong (light)
gridColor: '#e0e0e0', // --border (light)
gridDash: [2, 2],
labelColor: '#525252', // --text-secondary (light)
titleColor: '#161616', // --text (light)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: {
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
},
range: { category: lightCategory },
view: { stroke: 'transparent' },
};
export const experimentalChartConfig: Config = {
export const darkChartConfig: Config = {
background: 'transparent',
font: '"Inter", system-ui, sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
axis: {
domainColor: '#a1a1aa',
gridColor: '#3f3f46',
gridDash: [3, 3],
labelColor: '#a1a1aa',
titleColor: '#f4f4f5',
domainColor: '#525252', // --border-strong (dark)
gridColor: '#393939', // --border (dark)
gridDash: [2, 2],
labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f4', // --text (dark)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: {
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
},
range: { category: darkCategory },
view: { stroke: 'transparent' },
};
const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig,
experimental: experimentalChartConfig,
dark: darkChartConfig,
};
/** The Vega-Lite config for a UI theme — the only theme → config mapping. */
+6 -10
View File
@@ -1,18 +1,14 @@
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { initApp } from './app/orchestration/startup';
import { useAppStore } from './app/stores/AppStore';
import { initTheme, wireTheme } from './app/orchestration/theme';
import '../styles/base.css';
// Mirror the UI theme onto <html data-theme>: apply the initial value before
// first paint, then keep it in sync. (Store stays DOM-free; the adapter is here.)
const applyTheme = (theme: string) => {
document.documentElement.dataset.theme = theme;
};
applyTheme(useAppStore.getState().uiTheme);
useAppStore.subscribe((state, prev) => {
if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme);
});
// Hydrate the persisted theme onto <html data-theme> before first paint (no
// FOUC), then keep store ↔ DOM ↔ localStorage in sync. Store stays DOM-free;
// all browser access funnels through the orchestration + adapter.
initTheme();
wireTheme();
// Load the library from IndexedDB (seeding a sample on first run) and wire
// persistence. Fire-and-forget: the UI renders immediately and fills in when