From 3e579edab8ca4587628807ccc7218ec1b3955611 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 07:00:13 +0800 Subject: [PATCH 1/6] feat(ui): direction D tokens, type and the Daylight theme Replace upstream's paper/ink tokens with the two sets of the selected design (docs/design/viewer-d.md): Nebula for dark and Daylight for light, under the same names, with the spec's effects, gradients, canvas and type roles, and upstream's names kept as aliases so every existing rule paints with them. Drop the global square-corner rule. The viewer follows prefers-color-scheme until the reader picks System, Dark or Light; the choice is stored under codegraph-ui.theme and applied as data-theme on the page. A contrast suite reads both sets from theme.css and fails on any declared pair under 4.5:1. Bundle Inter and JetBrains Mono instead of Archivo and IBM Plex Mono, turn ligatures off, draw Lucide icons as masks, colour kind tiles by family, and let the SVG export paint either set. --- ui/package-lock.json | 16 +- ui/package.json | 4 +- ui/src/app.css | 615 +++++++++++++++++++++++++++-- ui/src/components/Icon.svelte | 67 ++++ ui/src/components/KindGlyph.svelte | 97 +++-- ui/src/lib/export-svg.ts | 216 ++++++---- ui/src/lib/highlight.ts | 19 + ui/src/lib/icons.ts | 116 ++++++ ui/src/lib/kinds.ts | 8 +- ui/src/lib/theme-choice.svelte.ts | 86 ++++ ui/src/lib/theme.css | 498 +++++++++++++++-------- ui/src/main.ts | 15 +- ui/tests/theme-contrast.test.ts | 148 +++++++ ui/tests/ui-d-highlight.test.ts | 29 ++ ui/tests/ui-export-svg.test.ts | 3 +- ui/vite.config.ts | 5 + 16 files changed, 1635 insertions(+), 307 deletions(-) create mode 100644 ui/src/components/Icon.svelte create mode 100644 ui/src/lib/icons.ts create mode 100644 ui/src/lib/theme-choice.svelte.ts create mode 100644 ui/tests/theme-contrast.test.ts create mode 100644 ui/tests/ui-d-highlight.test.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 2e6258a..7200e56 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,8 +12,8 @@ "@xyflow/svelte": "1.6.5" }, "devDependencies": { - "@fontsource-variable/archivo": "5.3.0", - "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@types/node": "20.19.33", "jsdom": "25.0.1", @@ -598,20 +598,20 @@ "node": ">=18" } }, - "node_modules/@fontsource-variable/archivo": { + "node_modules/@fontsource-variable/inter": { "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/@fontsource-variable/archivo/-/archivo-5.3.0.tgz", - "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==", + "resolved": "https://registry.npmmirror.com/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", "dev": true, "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fontsource/ibm-plex-mono": { + "node_modules/@fontsource-variable/jetbrains-mono": { "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", - "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "resolved": "https://registry.npmmirror.com/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", "dev": true, "license": "OFL-1.1", "funding": { diff --git a/ui/package.json b/ui/package.json index db1f719..99370bd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,8 +30,8 @@ "@xyflow/svelte": "1.6.5" }, "devDependencies": { - "@fontsource-variable/archivo": "5.3.0", - "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@types/node": "20.19.33", "jsdom": "25.0.1", diff --git a/ui/src/app.css b/ui/src/app.css index f67140a..c622e93 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1,11 +1,11 @@ /* ===================================================================== - codegraph ui — the app's global primitives + codegraph ui — the app's global primitives (direction D) - The design tokens themselves live in `lib/theme.css`, which is also - what `@colbymchenry/codegraph-ui` exports for a host to import and - override. This file is everything ON TOP of them that only the - standalone viewer needs: the reset, the shell grid, and the handful of - primitives shared across views. + The tokens live in `lib/theme.css`, which is also what a host imports. + This file is everything ON TOP of them that the standalone viewer needs: + the reset, the shell grid of docs/design/viewer-d.md §2, and the handful of + primitives §7 shares across views (islands, pills, buttons, key caps, stat + tiles, bars, skeletons, callouts, the empty and error states). Component-specific rules live in each .svelte file's scoped diff --git a/ui/src/components/KindGlyph.svelte b/ui/src/components/KindGlyph.svelte index e5e9c1b..9783f4c 100644 --- a/ui/src/components/KindGlyph.svelte +++ b/ui/src/components/KindGlyph.svelte @@ -1,57 +1,110 @@ + + 1} + style:width={`${size}px`} + style:height={`${size}px`} + style:border-radius={`${radius}px`} + style:font-size={`${fontSize}px`} title={titled ? kindWord(kind) : undefined} aria-hidden={titled ? undefined : 'true'} ->{letter} + >{#if icon}{:else}{letter}{/if} diff --git a/ui/src/lib/export-svg.ts b/ui/src/lib/export-svg.ts index a534c1f..8d7ed95 100644 --- a/ui/src/lib/export-svg.ts +++ b/ui/src/lib/export-svg.ts @@ -27,24 +27,27 @@ * The measurements that actually place things — heights, widths, columns — are * NOT restated; they are imported from the layout models. * - * ## Light, always + * ## In the theme on screen, as literal hex * - * An image pasted into a PR is read by people whose editors are set both ways, - * and a dark-mode screenshot on GitHub's white comment background reads as a - * mistake. So the export inlines the light token set as literal hex regardless - * of the viewer's theme — there is no `prefers-color-scheme` in a file someone - * else opens. + * Direction D (docs/design/viewer-d.md §3, §3.6) has two token sets, and the + * export paints the one the reader is looking at: the caller passes `theme` + * (the viewer passes the effective theme, System resolved). Light is the + * default, upstream's reasoning for a file read on someone else's page. Either + * way the colours are inlined as literal hex — there is no + * `prefers-color-scheme` in a file someone else opens, and an export must not + * depend on a stylesheet having loaded. `ui/tests/theme-contrast.test.ts` + * checks both palettes against `lib/theme.css`, so the two cannot drift. * * ## Fonts are stacks, not bytes * * Per the spec: no embedding. The consequence is honest and worth stating — - * an exported SVG opened on a machine without IBM Plex Mono falls back through - * the stack to the platform's own monospace, and a PNG rasterised through an - * `` always does, because an SVG loaded as an image may not fetch a - * webfont. Every fallback in the mono stack advances at ~0.6em like Plex Mono - * does, so the monospace grid the code windows depend on survives the swap; - * only the letterforms change. Embedding Plex Mono would add ~90 kB of base64 - * to every export and put us over the PNG budget for nothing. + * an exported SVG opened on a machine without JetBrains Mono falls back + * through the stack to the platform's own monospace, and a PNG rasterised + * through an `` always does, because an SVG loaded as an image may not + * fetch a webfont. Every fallback in the mono stack advances at ~0.6em like + * JetBrains Mono does, so the monospace grid the code windows depend on + * survives the swap; only the letterforms change. Embedding the font would add + * tens of kB of base64 to every export for nothing. * * Tested in `__tests__/ui-export-svg.test.ts`. */ @@ -77,32 +80,89 @@ import { kindLetter, FILLED_KINDS } from './kinds'; /* ---------------------------------------------------------------- tokens -- */ +/** The colours an export paints with, named as upstream's tokens were. */ +export interface ExportPalette { + paper: string; + paper2: string; + press: string; + ink: string; + ink2: string; + ink3: string; + ink4: string; + ruleSoft: string; + ruleFaint: string; + accent: string; + accentSoft: string; + accentLine: string; + codeComment: string; +} + /** - * The light token set (`ui/src/app.css`, the bare `:root` block), as literal - * hex. Copied deliberately rather than read from `getComputedStyle`: an export - * must not depend on a stylesheet having loaded, and must not follow the - * reader's theme into a dark image on a white page. + * Both D token sets (`lib/theme.css`), as literal hex under upstream's names — + * the §3.2 aliases: paper = panel, paper-2 = raised, press = raised, ink = fg, + * ink-2..4 = fg-2..4, rule-soft = line, rule-faint = line-faint, accent = + * primary, accent-soft = primary-soft, accent-line = primary-line, + * code-comment = syn-com. */ -export const EXPORT_COLORS = { - paper: '#f7f6f2', - paper2: '#f1efe8', - press: '#e8e6dd', - ink: '#16150f', - ink2: '#56544a', - ink3: '#87847a', - ink4: '#b4b1a5', - ruleSoft: '#d6d3c8', - ruleFaint: '#e6e3d9', - accent: '#7a2230', - accentSoft: '#f0e3e5', - accentLine: '#d9b3b9', - codeComment: '#6a675d', -} as const; +export const EXPORT_PALETTES: Record<'light' | 'dark', ExportPalette> = { + // D · Daylight (§3.6) + light: { + paper: '#ffffff', + paper2: '#eef1f6', + press: '#eef1f6', + ink: '#1a2130', + ink2: '#434d62', + ink3: '#5f687c', + ink4: '#98a1b3', + ruleSoft: '#d8dde7', + ruleFaint: '#e6e9f0', + accent: '#4155ee', + accentSoft: '#e9edff', + accentLine: '#b9c3f6', + codeComment: '#626a7d', + }, + // D · Nebula (§3.1) + dark: { + paper: '#10141b', + paper2: '#1b2130', + press: '#1b2130', + ink: '#e8ecf4', + ink2: '#a9b2c4', + ink3: '#838da0', + ink4: '#4a5468', + ruleSoft: '#232a38', + ruleFaint: '#191f29', + accent: '#7c93ff', + accentSoft: '#1a2140', + accentLine: '#3b4a8c', + codeComment: '#808aa2', + }, +}; + +/** The light palette — what an export paints when no theme is asked for. */ +export const EXPORT_COLORS: ExportPalette = EXPORT_PALETTES.light; + +/** + * The palette the drawing in progress paints with. Set by `flowSvg` and + * `mapSvg` for the length of one call (they are synchronous), so the drawing + * helpers below need no extra parameter. + */ +let C: ExportPalette = EXPORT_COLORS; + +function withPalette(theme: 'light' | 'dark' | undefined, draw: () => T): T { + const previous = C; + C = EXPORT_PALETTES[theme ?? 'light']; + try { + return draw(); + } finally { + C = previous; + } +} export const MONO_STACK = - "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace"; + "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace"; export const SANS_STACK = - "'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif"; + "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; /** Clear space between the drawing and the edge of the image (design spec §3.9). */ export const EXPORT_PADDING = 24; @@ -250,6 +310,8 @@ export interface ExportOptions { scale?: number; /** A line of context under the drawing, left of the mark. */ caption?: string | null; + /** Which token set to paint with; light when omitted. */ + theme?: 'light' | 'dark'; } interface Frame { @@ -281,7 +343,7 @@ function document_(frame: Frame, defs: string, body: string, options: ExportOpti x: EXPORT_PADDING, y: markY, size: MARK_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, family: MONO_STACK, }, esc( @@ -296,7 +358,7 @@ function document_(frame: Frame, defs: string, body: string, options: ExportOpti return [ ``, defs === '' ? '' : `${defs}`, - rect(0, 0, width, height, { fill: EXPORT_COLORS.paper }), + rect(0, 0, width, height, { fill: C.paper }), ``, body, '', @@ -306,7 +368,7 @@ function document_(frame: Frame, defs: string, body: string, options: ExportOpti x: width - EXPORT_PADDING, y: markY, size: MARK_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, family: MONO_STACK, anchor: 'end', }, @@ -362,8 +424,8 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s out.push( rect(card.x, card.y, card.width, card.height, { - fill: EXPORT_COLORS.paper, - stroke: current ? EXPORT_COLORS.accent : EXPORT_COLORS.ruleSoft, + fill: C.paper, + stroke: current ? C.accent : C.ruleSoft, }) ); @@ -373,8 +435,8 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s const gy = card.y + GLYPH_TOP; out.push( rect(gx, gy, GLYPH_SIZE, GLYPH_SIZE, { - fill: FILLED_KINDS.has(hop.node.kind) ? EXPORT_COLORS.press : 'none', - stroke: EXPORT_COLORS.ink3, + fill: FILLED_KINDS.has(hop.node.kind) ? C.press : 'none', + stroke: C.ink3, dash: hop.node.kind === 'file' ? '2 2' : undefined, }) ); @@ -386,7 +448,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: gx + GLYPH_SIZE / 2, y: gy + GLYPH_SIZE / 2 + glyphSize * 0.36, size: glyphSize, - fill: EXPORT_COLORS.ink2, + fill: C.ink2, family: MONO_STACK, weight: 500, anchor: 'middle', @@ -406,7 +468,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: nameX, y: card.y + HEAD_BASELINE, size: NAME_SIZE, - fill: EXPORT_COLORS.ink, + fill: C.ink, family: MONO_STACK, weight: 600, }, @@ -417,13 +479,13 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: card.x + card.width - CARD_PAD_X, y: card.y + HEAD_BASELINE, size: LOC_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, family: MONO_STACK, anchor: 'end', }, esc(loc) ), - `` + `` ); // --- source window ------------------------------------------------------- @@ -438,7 +500,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: card.x + CARD_PAD_X, y: card.y + HEADER_HEIGHT + 6 + CODE_BASELINE, size: CODE_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, }, esc(truncate(why, card.width - CARD_PAD_X * 2, CODE_SIZE, SANS_ADVANCE)) ) @@ -473,7 +535,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s const claimed = assignRefs(lineTokens, refs.get(n) ?? []); if (n === callRef?.line || n === card.stopLine) { body.push( - rect(card.x, top, card.width, CODE_LINE_HEIGHT, { fill: EXPORT_COLORS.accentSoft }) + rect(card.x, top, card.width, CODE_LINE_HEIGHT, { fill: C.accentSoft }) ); } body.push( @@ -482,7 +544,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: card.x + GUTTER - 10, y: top + CODE_BASELINE, size: LINE_NO_SIZE, - fill: EXPORT_COLORS.ink4, + fill: C.ink4, family: MONO_STACK, anchor: 'end', }, @@ -512,7 +574,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s x: textX, y: top + CODE_BASELINE, size: CODE_SIZE, - fill: EXPORT_COLORS.ink, + fill: C.ink, family: MONO_STACK, preserve: true, }, @@ -526,7 +588,7 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s const u = underline as { from: number; length: number }; const x1 = textX + monoWidth(u.from, CODE_SIZE); body.push( - `` + `` ); } }); @@ -539,13 +601,13 @@ function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): s function tokenSpan(text: string, token: Token, isRef: boolean): string { if (text === '') return ''; const escaped = esc(text); - if (isRef) return `${escaped}`; + if (isRef) return `${escaped}`; switch (token.cls) { case 'comment': - return `${escaped}`; + return `${escaped}`; case 'string': case 'number': - return `${escaped}`; + return `${escaped}`; case 'keyword': return `${escaped}`; default: @@ -626,8 +688,8 @@ function flowCapSvg(cap: FlowEndCapLayout, dimmed: boolean): string { const { rows, height } = capRows(cap); const out: string[] = [ rect(cap.x, cap.y, cap.width, Math.max(cap.height, height), { - fill: EXPORT_COLORS.paper, - stroke: EXPORT_COLORS.ruleSoft, + fill: C.paper, + stroke: C.ruleSoft, dash: '3 3', }), ]; @@ -640,10 +702,10 @@ function flowCapSvg(cap: FlowEndCapLayout, dimmed: boolean): string { const step = mono ? END_CAP_ROW : END_CAP_LINE; const fill = row.kind === 'form' || row.kind === 'lead' - ? EXPORT_COLORS.ink + ? C.ink : row.kind === 'soft' - ? EXPORT_COLORS.ink3 - : EXPORT_COLORS.ink2; + ? C.ink3 + : C.ink2; out.push( textEl( { @@ -676,11 +738,11 @@ function flowLinkSvg( : `M${round(sx)},${round(sy)} C${round((sx + tx) / 2)},${round(sy)} ${round((sx + tx) / 2)},${round(ty)} ${round(tx)},${round(ty)}`; const out: string[] = [ - ``, + ``, ]; if (!link.cap) { out.push( - `` + `` ); } const labelX = (sx + tx) / 2; @@ -692,7 +754,7 @@ function flowLinkSvg( x: labelX, y: labelY - 8 - (link.labelLines.length - 1 - i) * 13, size: LINK_LABEL_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, family: MONO_STACK, anchor: 'middle', }, @@ -707,7 +769,7 @@ function flowLinkSvg( x: labelX, y: labelY + 17, size: LINK_LABEL_SIZE, - fill: EXPORT_COLORS.ink3, + fill: C.ink3, family: MONO_STACK, anchor: 'middle', }, @@ -720,6 +782,10 @@ function flowLinkSvg( /** The Flow strip as a standalone SVG. */ export function flowSvg(layout: FlowLayout, options: FlowExportOptions = {}): string { + return withPalette(options.theme, () => drawFlowSvg(layout, options)); +} + +function drawFlowSvg(layout: FlowLayout, options: FlowExportOptions): string { const showAll = options.showAll ?? false; const active = options.activeFlowId ?? null; const onActive = (flows: string[]): boolean => active === null || flows.includes(active); @@ -809,12 +875,12 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st const strokeWidth = selected ? 2 : 1; const out: string[] = [ rect(node.x, node.y, node.width, node.height, { - fill: selected ? EXPORT_COLORS.press : EXPORT_COLORS.paper, + fill: selected ? C.press : C.paper, stroke: dimmed - ? EXPORT_COLORS.ink4 + ? C.ink4 : module.test - ? EXPORT_COLORS.ink3 - : EXPORT_COLORS.ink, + ? C.ink3 + : C.ink, strokeWidth, dash: module.test ? '4 3' : undefined, }), @@ -826,7 +892,7 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st x: node.x + MODULE_PAD_X, y: node.y + 17, size: MODULE_NAME_SIZE, - fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink, + fill: dimmed ? C.ink4 : C.ink, family: MONO_STACK, weight: 500, }, @@ -837,7 +903,7 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st x: node.x + MODULE_PAD_X, y: node.y + 31.5, size: MODULE_META_SIZE, - fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3, + fill: dimmed ? C.ink4 : C.ink3, }, // `node.island`, matching the canvas: an exported map that counts a // module the screen said nothing depends on is a different picture. @@ -848,7 +914,7 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st if (node.weight > 0) { out.push( rect(node.x, node.y + node.height - 4, node.width * node.weight, 4, { - fill: EXPORT_COLORS.ink, + fill: C.ink, fillOpacity: dimmed ? 0.1 : 0.3, }) ); @@ -877,13 +943,17 @@ function mapEdgeSvg( const midY = (sy + ty) / 2; const path = `M${round(sx)},${round(sy)} C${round(sx)},${round(midY)} ${round(tx)},${round(midY)} ${round(tx)},${round(ty)}`; if (edge.back) { - return ``; + return ``; } - return ``; + return ``; } /** The Map as a standalone SVG. */ export function mapSvg(layout: MapLayout, options: MapExportOptions = {}): string { + return withPalette(options.theme, () => drawMapSvg(layout, options)); +} + +function drawMapSvg(layout: MapLayout, options: MapExportOptions): string { const selected = options.selected ?? null; const nodes = new Map(layout.nodes.map((n) => [n.id, n])); const neighbours = @@ -924,13 +994,13 @@ export function mapSvg(layout: MapLayout, options: MapExportOptions = {}): strin // canvas' back viewport portal puts them. for (const row of layout.layers) { body.push( - `` + `` ); if (row.label !== null) { const y = row.index === 0 ? row.y + 40 : row.y - 36; body.push( textEl( - { x: ruleLeft, y, size: LAYER_LABEL_SIZE, fill: EXPORT_COLORS.ink3 }, + { x: ruleLeft, y, size: LAYER_LABEL_SIZE, fill: C.ink3 }, esc(row.label) ) ); diff --git a/ui/src/lib/highlight.ts b/ui/src/lib/highlight.ts index 3c9344e..298a290 100644 --- a/ui/src/lib/highlight.ts +++ b/ui/src/lib/highlight.ts @@ -139,6 +139,25 @@ export function tokensByLine( * is free once the tree has been walked, and re-deriving it in a consumer would * not be. */ +/** Text that is nothing but punctuation and operators — `syn-punct` in D. */ +const PUNCTUATION = /^[\s(){}[\];,.:<>=+\-*/&|!?^%~@#$\\'"`]+$/; + +/** + * The class a rendered token takes in direction D (docs/design/viewer-d.md §3: + * `syn-kw`, `syn-str`, `syn-num`, `syn-com`, `syn-type`, `syn-punct`). + * + * {@link tokenClass} plus the two D colours it leaves out: a `type` token, and + * an `other` token that is only punctuation. An `other` token holding anything + * else — whitespace, or a whole unclassified line from a plain answer — stays + * unclassed, so an unhighlighted file reads in plain ink rather than in the + * punctuation colour. + */ +export function tokenClassFor(token: Pick): string | null { + if (token.cls === 'type') return 't-t'; + if (token.cls === 'other') return token.text.trim() !== '' && PUNCTUATION.test(token.text) ? 't-p' : null; + return tokenClass(token.cls); +} + export function tokenClass(cls: TokenClass): string | null { switch (cls) { case 'comment': diff --git a/ui/src/lib/icons.ts b/ui/src/lib/icons.ts new file mode 100644 index 0000000..e6f9e04 --- /dev/null +++ b/ui/src/lib/icons.ts @@ -0,0 +1,116 @@ +/*! + * Icon geometry from Lucide 0.544.0 (lucide-static), the set docs/design/viewer-d.md §6 + * names. Copied, not imported: the viewer needs 66 shapes, not a dependency. + * + * ISC License + * + * Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2023 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2025. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * --- + * + * The MIT License (MIT) (for portions derived from Feather) + * + * Copyright (c) 2013-2023 Cole Bemis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** Inner SVG of each icon on a 24 x 24 viewBox, stroked by the caller. */ +export const ICON_SHAPES = { + 'activity': "", + 'arrow-left': " ", + 'arrow-right': " ", + 'bookmark': "", + 'box': " ", + 'braces': " ", + 'check': "", + 'chevron-down': "", + 'chevron-left': "", + 'chevron-right': "", + 'circle-alert': " ", + 'circle-check': " ", + 'circle-dashed': " ", + 'circle-x': " ", + 'command': "", + 'copy': " ", + 'corner-down-right': " ", + 'corner-left-up': " ", + 'corner-up-left': " ", + 'database': " ", + 'ellipsis': " ", + 'external-link': " ", + 'eye-off': " ", + 'file': " ", + 'file-code-2': " ", + 'filter': "", + 'flask-conical': " ", + 'folder': "", + 'footprints': " ", + 'ghost': " ", + 'git-branch': " ", + 'git-commit-horizontal': " ", + 'git-fork': " ", + 'info': " ", + 'key-round': " ", + 'keyboard': " ", + 'layers': " ", + 'layout-dashboard': " ", + 'link': " ", + 'list-tree': " ", + 'lock': " ", + 'maximize': " ", + 'menu': " ", + 'minus': "", + 'monitor': " ", + 'monitor-smartphone': " ", + 'moon': "", + 'mouse-pointer-click': " ", + 'network': " ", + 'package': " ", + 'plus': " ", + 'radio': " ", + 'refresh-cw': " ", + 'route': " ", + 'search': " ", + 'send': " ", + 'server': " ", + 'settings': " ", + 'shield': "", + 'sparkles': " ", + 'sun': " ", + 'triangle-alert': " ", + 'users': " ", + 'workflow': " ", + 'x': " ", + 'zap': "", +} as const; + +export type IconName = keyof typeof ICON_SHAPES; diff --git a/ui/src/lib/kinds.ts b/ui/src/lib/kinds.ts index 0aa5f0f..7fb6ebf 100644 --- a/ui/src/lib/kinds.ts +++ b/ui/src/lib/kinds.ts @@ -1,8 +1,10 @@ /** - * Kind glyphs — design spec §2.3. + * Kind letters — the character a kind tile carries. * - * A 16x16 hollow square with a mono letter. Container/type kinds get a - * --press fill so a class reads as a box and a function as an outline. + * Direction D draws them as soft filled tiles coloured by kind family + * (docs/design/viewer-d.md §7; `KindGlyph.svelte`). `FILLED_KINDS` is + * upstream's container/type split, which the SVG export still draws with: a + * filled box for a type, an outline for a function. */ /** NodeKind values the engine emits (src/types.ts). */ diff --git a/ui/src/lib/theme-choice.svelte.ts b/ui/src/lib/theme-choice.svelte.ts new file mode 100644 index 0000000..5ebc420 --- /dev/null +++ b/ui/src/lib/theme-choice.svelte.ts @@ -0,0 +1,86 @@ +/** + * The reader's theme choice — System, Dark or Light (docs/design/viewer-d.md + * §3.6, "Switching"). + * + * The tokens already follow `prefers-color-scheme` on their own; this only + * records an explicit choice and applies it as `data-theme` on , the + * attribute `lib/theme.css` lets win over the OS. System removes the attribute + * again, which hands the decision back to the media query. + * + * The choice lives in `localStorage` under one key. A browser that refuses + * storage (a locked-down profile, a private window that throws) still gets a + * working switch for the session; it just does not remember it. + */ + +export type ThemeChoice = 'system' | 'dark' | 'light'; + +/** The storage key, named in the design spec. */ +export const THEME_STORAGE_KEY = 'codegraph-ui.theme'; + +export const THEME_CHOICES: readonly ThemeChoice[] = ['system', 'dark', 'light']; + +/** The label a control shows for each choice. */ +export const THEME_LABEL: Record = { + system: 'System', + dark: 'Dark', + light: 'Light', +}; + +function isChoice(value: unknown): value is ThemeChoice { + return value === 'system' || value === 'dark' || value === 'light'; +} + +function readStored(): ThemeChoice { + try { + const value = globalThis.localStorage?.getItem(THEME_STORAGE_KEY); + return isChoice(value) ? value : 'system'; + } catch { + return 'system'; + } +} + +function writeStored(choice: ThemeChoice): void { + try { + if (choice === 'system') globalThis.localStorage?.removeItem(THEME_STORAGE_KEY); + else globalThis.localStorage?.setItem(THEME_STORAGE_KEY, choice); + } catch { + // Storage refused: the choice holds for this page only. + } +} + +function applyToDocument(choice: ThemeChoice): void { + const root = globalThis.document?.documentElement; + if (!root) return; + if (choice === 'system') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', choice); +} + +/** What the page is painted in right now, the OS preference included. */ +export function effectiveTheme(choice: ThemeChoice): 'dark' | 'light' { + if (choice !== 'system') return choice; + const dark = globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; + return dark ? 'dark' : 'light'; +} + +class ThemeState { + choice = $state(readStored()); + + set(choice: ThemeChoice): void { + this.choice = choice; + writeStored(choice); + applyToDocument(choice); + } + + /** The next choice in System → Dark → Light order, for a one-button control. */ + cycle(): void { + const at = THEME_CHOICES.indexOf(this.choice); + this.set(THEME_CHOICES[(at + 1) % THEME_CHOICES.length] ?? 'system'); + } +} + +export const theme = new ThemeState(); + +/** Applies the stored choice before the app mounts (see `main.ts`). */ +export function applyStoredTheme(): void { + applyToDocument(readStored()); +} diff --git a/ui/src/lib/theme.css b/ui/src/lib/theme.css index 2d755c3..9a5bcde 100644 --- a/ui/src/lib/theme.css +++ b/ui/src/lib/theme.css @@ -1,120 +1,195 @@ /* ===================================================================== - @colbymchenry/codegraph-ui — design tokens - - The engine's paper/ink editorial system, as specified in - docs/design/codegraph-ui-design-spec.md §2.2: flat, hairline rules, - square corners everywhere, no shadows, no gradients, sentence case, - one oxblood accent, one amber (the "untested" badge). - - This file is the package's whole theming surface. Import it once and - override any variable on a narrower selector — the components read - nothing else. What is NOT themable is geometry: 34px rail rows, the - 300/320px rails, the 20px code line. Those are measured against each - other by the Symbol view's layout pass, and a host that moves one of - them moves a callee row away from the line it points at. - - Colour and type only. Every token is defined once on the bare :root - below and only REDEFINED in the two dark blocks, so a host that sets - `--accent` on its own container gets it in both schemes. + codegraph ui — design tokens, direction D "Modern Tech" + + The numbers are docs/design/viewer-d.md: §3 colour (`D · Nebula`, dark), + §3.6 the light set (`D · Daylight`), §4 effects, §5 type. Every component + paints from these names; nothing below a token picks its own hex. + + Two sets, one mechanism (upstream's): the bare :root carries Daylight, + `prefers-color-scheme: dark` swaps in Nebula unless the reader chose light, + and an explicit `data-theme` — on for the viewer's own switch, on a + wrapper for a host that embeds a reader — wins over the OS either way. + + Derived values (effects, gradients, the 15 aliases, Svelte Flow's + variables) are declared on `:root, [data-theme]` rather than on :root + alone. A custom property resolves its var() references where it is + declared, so a derived value declared only on :root would carry :root's + palette into a subtree that asked for the other one. + + Geometry is not themable: the 20px code line, the 64px nav rail, the rail + widths. The Symbol view measures those against each other. ===================================================================== */ -/* ---------- tokens: light / paper (the bare :root set) ---------- */ +/* ---------- D · Daylight (light) — the bare :root set ---------- */ :root { - --paper: #f7f6f2; - --paper-2: #f1efe8; - --press: #e8e6dd; - --press-2: #dedbd0; - --ink: #16150f; - --ink-2: #56544a; - --ink-3: #87847a; - --ink-4: #b4b1a5; - --rule: #16150f; - --rule-soft: #d6d3c8; - --rule-faint: #e6e3d9; - --accent: #7a2230; - --accent-ink: #5e1a25; - --accent-soft: #f0e3e5; - --accent-line: #d9b3b9; - --amber: #8a5a0b; - --amber-soft: #f3e9d2; - - /* The one code colour that is not a plain re-use of the ink ramp. - The spec asks for comments at --ink-3; measured against --paper that - is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1, - both under the 4.5:1 an AA reading of 12.5px body text needs. This is - the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on - all three backgrounds a code line can have (paper 5.23, paper-2 4.92, - accent-soft 4.53) while staying quieter than --ink-2, which strings - and numbers use — so the recession order the spec describes is - unchanged, only legible. Dark needed the mirror step UP (4.51 on - accent-soft, where --ink-3 was 4.10). */ - --code-comment: #6a675d; - - --sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; - --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; - --code-size: 12.5px; - --code-lh: 20px; + --bg: #ebeef4; + --rail: #e4e8f0; + --panel: #ffffff; + --card: #f7f8fb; + --raised: #eef1f6; + --overlay: #ffffff; + --line: #d8dde7; + --line-strong: #bcc4d3; + --line-faint: #e6e9f0; + --fg: #1a2130; + --fg-2: #434d62; + --fg-3: #5f687c; + --fg-4: #98a1b3; + --primary: #4155ee; + --primary-2: #7640ed; + --primary-soft: #e9edff; + --primary-line: #b9c3f6; + --primary-ink: #3340aa; + --primary-deep: #4c5fe8; + --primary-2-deep: #7a4fe6; + --on-primary: #ffffff; + --cyan: #0b7280; + --cyan-soft: #dcf2f5; + --cyan-line: #8accd6; + --green: #187648; + --green-soft: #ddf3e7; + --green-deep: #1f9a5e; + --amber: #975a00; + --amber-soft: #fbeed6; + --red: #bf332b; + --red-soft: #fbe4e2; + --violet: #7747df; + --violet-soft: #efe8fd; + --syn-kw: #7444da; + --syn-str: #1b7943; + --syn-num: #a15717; + --syn-com: #626a7d; + --syn-type: #0d7487; + --syn-punct: #5f6a7e; - /* App-shell geometry, shared by the grid and by anything that has to - offset itself under the bars (sticky rail headers, SVG overlays). */ - --topbar-h: 48px; - --trailbar-h: 34px; + /* §3.6 effects: softer shadows, glows at 0.6x (primary) and 0.5x (data). */ + --sh-card: 0 4px 14px 0 rgb(16 24 40 / 8%); + --sh-pop: 0 12px 32px 0 rgb(16 24 40 / 16%); + --glow-blur: 14px; + --glow-k: 0.6; + --glow-data-blur: 6px; + --glow-data-k: 0.5; + --canvas-base: var(--card); + --dots: #d3d9e4; + --ambient-1: #c7d0ff; + --ambient-2: #e0d2ff; color-scheme: light dark; } -/* ---------- tokens: dark / ink ---------- - Every colour is defined on the bare :root above; these blocks only - redefine. `:not([data-theme="light"])` lets an explicit light choice - win over the OS preference. */ +/* ---------- D · Nebula (dark) ---------- + The same names redefined. `:not([data-theme='light'])` lets an explicit + light choice win over the OS preference. */ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { - --paper: #16150f; - --paper-2: #1c1a14; - --press: #23211a; - --press-2: #2c2a22; - --ink: #f3f1ea; - --ink-2: #b8b5a8; - --ink-3: #87847a; - --ink-4: #5d5b52; - --rule: #f3f1ea; - --rule-soft: #34322a; - --rule-faint: #26241d; - --accent: #d48b96; - --accent-ink: #e5a5ae; - --accent-soft: #33201f; - --accent-line: #6b3a42; - --amber: #d9a94a; - --amber-soft: #2e2716; - --code-comment: #8e8b81; + --bg: #0a0c10; + --rail: #0d1016; + --panel: #10141b; + --card: #151a23; + --raised: #1b2130; + --overlay: #1f2636; + --line: #232a38; + --line-strong: #303a4d; + --line-faint: #191f29; + --fg: #e8ecf4; + --fg-2: #a9b2c4; + --fg-3: #838da0; + --fg-4: #4a5468; + --primary: #7c93ff; + --primary-2: #a07cff; + --primary-soft: #1a2140; + --primary-line: #3b4a8c; + --primary-ink: #b9c6ff; + --primary-deep: #4465ff; + --primary-2-deep: #7f4eff; + --on-primary: #ffffff; + --cyan: #3dd6e8; + --cyan-soft: #0e2a31; + --cyan-line: #1f5966; + --green: #3fd68e; + --green-soft: #0e2a1f; + --green-deep: #2bb673; + --amber: #f5b544; + --amber-soft: #2e240e; + --red: #f2706b; + --red-soft: #2f1517; + --violet: #b48cff; + --violet-soft: #231a3d; + --syn-kw: #b79cff; + --syn-str: #8fd9a8; + --syn-num: #f5b57a; + --syn-com: #808aa2; + --syn-type: #7fcfe6; + --syn-punct: #8c96ab; + --sh-card: 0 6px 20px 0 rgb(0 0 0 / 32%); + --sh-pop: 0 16px 40px 0 rgb(0 0 0 / 50%); + --glow-blur: 18px; + --glow-k: 1; + --glow-data-blur: 8px; + --glow-data-k: 1; + --canvas-base: var(--panel); + --dots: #2c3445; + --ambient-1: #3a4ca8; + --ambient-2: #553f9e; + color-scheme: dark; } } /* An explicit choice, wherever it is made. `:root[data-theme]` is the viewer's own switch; the bare attribute selector is what `` - sets on its wrapper — custom properties inherit, so redefining them on a - container re-themes that subtree without touching :root. A host can therefore - put a light reader inside a dark application, and the reverse. */ + sets on its wrapper, so a host can put a dark reader inside a light page and + the reverse. */ :root[data-theme='dark'], [data-theme='dark'] { - --paper: #16150f; - --paper-2: #1c1a14; - --press: #23211a; - --press-2: #2c2a22; - --ink: #f3f1ea; - --ink-2: #b8b5a8; - --ink-3: #87847a; - --ink-4: #5d5b52; - --rule: #f3f1ea; - --rule-soft: #34322a; - --rule-faint: #26241d; - --accent: #d48b96; - --accent-ink: #e5a5ae; - --accent-soft: #33201f; - --accent-line: #6b3a42; - --amber: #d9a94a; - --amber-soft: #2e2716; - --code-comment: #8e8b81; + --bg: #0a0c10; + --rail: #0d1016; + --panel: #10141b; + --card: #151a23; + --raised: #1b2130; + --overlay: #1f2636; + --line: #232a38; + --line-strong: #303a4d; + --line-faint: #191f29; + --fg: #e8ecf4; + --fg-2: #a9b2c4; + --fg-3: #838da0; + --fg-4: #4a5468; + --primary: #7c93ff; + --primary-2: #a07cff; + --primary-soft: #1a2140; + --primary-line: #3b4a8c; + --primary-ink: #b9c6ff; + --primary-deep: #4465ff; + --primary-2-deep: #7f4eff; + --on-primary: #ffffff; + --cyan: #3dd6e8; + --cyan-soft: #0e2a31; + --cyan-line: #1f5966; + --green: #3fd68e; + --green-soft: #0e2a1f; + --green-deep: #2bb673; + --amber: #f5b544; + --amber-soft: #2e240e; + --red: #f2706b; + --red-soft: #2f1517; + --violet: #b48cff; + --violet-soft: #231a3d; + --syn-kw: #b79cff; + --syn-str: #8fd9a8; + --syn-num: #f5b57a; + --syn-com: #808aa2; + --syn-type: #7fcfe6; + --syn-punct: #8c96ab; + --sh-card: 0 6px 20px 0 rgb(0 0 0 / 32%); + --sh-pop: 0 16px 40px 0 rgb(0 0 0 / 50%); + --glow-blur: 18px; + --glow-k: 1; + --glow-data-blur: 8px; + --glow-data-k: 1; + --canvas-base: var(--panel); + --dots: #2c3445; + --ambient-1: #3a4ca8; + --ambient-2: #553f9e; color-scheme: dark; } @@ -122,71 +197,184 @@ the OS is painting dark. `:root[data-theme='light']` needs no block — the media query above already excludes it. */ [data-theme='light'] { - --paper: #f7f6f2; - --paper-2: #f1efe8; - --press: #e8e6dd; - --press-2: #dedbd0; - --ink: #16150f; - --ink-2: #56544a; - --ink-3: #87847a; - --ink-4: #b4b1a5; - --rule: #16150f; - --rule-soft: #d6d3c8; - --rule-faint: #e6e3d9; - --accent: #7a2230; - --accent-ink: #5e1a25; - --accent-soft: #f0e3e5; - --accent-line: #d9b3b9; - --amber: #8a5a0b; - --amber-soft: #f3e9d2; - --code-comment: #6a675d; + --bg: #ebeef4; + --rail: #e4e8f0; + --panel: #ffffff; + --card: #f7f8fb; + --raised: #eef1f6; + --overlay: #ffffff; + --line: #d8dde7; + --line-strong: #bcc4d3; + --line-faint: #e6e9f0; + --fg: #1a2130; + --fg-2: #434d62; + --fg-3: #5f687c; + --fg-4: #98a1b3; + --primary: #4155ee; + --primary-2: #7640ed; + --primary-soft: #e9edff; + --primary-line: #b9c3f6; + --primary-ink: #3340aa; + --primary-deep: #4c5fe8; + --primary-2-deep: #7a4fe6; + --on-primary: #ffffff; + --cyan: #0b7280; + --cyan-soft: #dcf2f5; + --cyan-line: #8accd6; + --green: #187648; + --green-soft: #ddf3e7; + --green-deep: #1f9a5e; + --amber: #975a00; + --amber-soft: #fbeed6; + --red: #bf332b; + --red-soft: #fbe4e2; + --violet: #7747df; + --violet-soft: #efe8fd; + --syn-kw: #7444da; + --syn-str: #1b7943; + --syn-num: #a15717; + --syn-com: #626a7d; + --syn-type: #0d7487; + --syn-punct: #5f6a7e; + --sh-card: 0 4px 14px 0 rgb(16 24 40 / 8%); + --sh-pop: 0 12px 32px 0 rgb(16 24 40 / 16%); + --glow-blur: 14px; + --glow-k: 0.6; + --glow-data-blur: 6px; + --glow-data-k: 0.5; + --canvas-base: var(--card); + --dots: #d3d9e4; + --ambient-1: #c7d0ff; + --ambient-2: #e0d2ff; color-scheme: light; } -/* ---------- Svelte Flow ---------- - The Map and the Flow strip draw on @xyflow/svelte, which ships its own - blue-on-white palette in `--xy-*` variables. Mapping them onto the ink - ramp here — rather than in each canvas — is what stops a host from - seeing library defaults in the gaps our custom node and edge - components do not paint: the pane behind the cards, the controls, the - minimap, the selection ring, the attribution. +/* ---------- derived: type, effects, gradients, aliases ---------- */ +:root, +[data-theme] { + /* §5: Inter for the interface, JetBrains Mono for code, names and numbers. */ + --sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + --mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, + monospace; + --code-size: 12.5px; + --code-lh: 20px; - Set on :root so it reaches the portalled panes too. A host that wants - the library's own look overrides these after importing this file. */ -:root { - --xy-background-color: var(--paper); - --xy-background-pattern-color: var(--rule-faint); + /* §5 type roles, as `font` shorthands: `font: var(--t-label)`. */ + --t-display: 600 24px / 31.92px var(--sans); + --t-h1: 600 20px / 28px var(--sans); + --t-h2: 600 15px / 22.05px var(--sans); + --t-label: 600 13px / 17.94px var(--sans); + --t-body: 400 13px / 20.02px var(--sans); + --t-body-500: 500 13px / 20.02px var(--sans); + --t-small: 400 12px / 15.96px var(--sans); + --t-small-500: 500 12px / 15.96px var(--sans); + --t-micro: 600 10.5px / 13.97px var(--sans); + --t-caption: 400 11px / 13.97px var(--sans); + --t-big-num: 600 22px / 27.94px var(--sans); + --t-title-mono: 600 20px / 28px var(--mono); + --t-code: 400 12.5px / 20px var(--mono); + --t-mono: 400 12px / 15.96px var(--mono); + --t-mono-500: 500 12px / 15.96px var(--mono); + --t-mono-sm: 400 11px / 13.97px var(--mono); + --t-kbd: 500 10.5px / 13.97px var(--mono); + --t-tile: 700 10px / 12px var(--mono); + --t-lineno: 400 11px / 20px var(--mono); - --xy-edge-stroke: var(--ink-3); - --xy-edge-stroke-selected: var(--accent); - --xy-edge-stroke-width: 1; - --xy-connectionline-stroke: var(--ink-3); + /* §4 SH.glow(op) — 0 0 18 0 primary @ op (Daylight: blur 14, 0.6 x op). One + variable per opacity the spec uses, so a component names its glow. */ + --glow-20: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(20% * var(--glow-k)), transparent); + --glow-22: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(22% * var(--glow-k)), transparent); + --glow-25: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(25% * var(--glow-k)), transparent); + --glow-30: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(30% * var(--glow-k)), transparent); + --glow-35: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(35% * var(--glow-k)), transparent); + --glow-40: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(40% * var(--glow-k)), transparent); + --glow-45: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(45% * var(--glow-k)), transparent); + --glow-50: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(50% * var(--glow-k)), transparent); + --glow-55: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--primary) calc(55% * var(--glow-k)), transparent); + /* SH.glowCyan(op) / SH.glowGreen(op) — 0 0 8 0 @ op (Daylight: blur 6, 0.5 x op). */ + --glow-cyan-45: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--cyan) calc(45% * var(--glow-data-k)), transparent); + --glow-cyan-55: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--cyan) calc(55% * var(--glow-data-k)), transparent); + --glow-cyan-60: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--cyan) calc(60% * var(--glow-data-k)), transparent); + --glow-cyan-80: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--cyan) calc(80% * var(--glow-data-k)), transparent); + --glow-cyan-90: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--cyan) calc(90% * var(--glow-data-k)), transparent); + --glow-green-60: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--green) calc(60% * var(--glow-data-k)), transparent); + --glow-green-80: 0 0 var(--glow-data-blur) 0 color-mix(in srgb, var(--green) calc(80% * var(--glow-data-k)), transparent); + /* The same cyan glows for SVG strokes, which take no box-shadow: a + drop-shadow() deviation is half a box-shadow blur radius. */ + --glow-cyan-45-f: drop-shadow(0 0 calc(var(--glow-data-blur) / 2) color-mix(in srgb, var(--cyan) calc(45% * var(--glow-data-k)), transparent)); + --glow-cyan-55-f: drop-shadow(0 0 calc(var(--glow-data-blur) / 2) color-mix(in srgb, var(--cyan) calc(55% * var(--glow-data-k)), transparent)); + --glow-cyan-60-f: drop-shadow(0 0 calc(var(--glow-data-blur) / 2) color-mix(in srgb, var(--cyan) calc(60% * var(--glow-data-k)), transparent)); + --glow-cyan-80-f: drop-shadow(0 0 calc(var(--glow-data-blur) / 2) color-mix(in srgb, var(--cyan) calc(80% * var(--glow-data-k)), transparent)); + --glow-cyan-90-f: drop-shadow(0 0 calc(var(--glow-data-blur) / 2) color-mix(in srgb, var(--cyan) calc(90% * var(--glow-data-k)), transparent)); + --glow-50-f: drop-shadow(0 0 calc(var(--glow-blur) / 2) color-mix(in srgb, var(--primary) calc(50% * var(--glow-k)), transparent)); - --xy-node-color: var(--ink); - --xy-node-background-color: var(--paper); - --xy-node-border: 1px solid var(--rule-soft); + /* §4 gradients. GRAD.brand runs left to right on a horizontal bar and corner + to corner on anything else; GRAD.button is the only one text sits on. */ + --grad-brand: linear-gradient(90deg, var(--primary), var(--primary-2)); + --grad-brand-v: linear-gradient(180deg, var(--primary), var(--primary-2)); + --grad-brand-d: linear-gradient(to bottom right, var(--primary), var(--primary-2)); + --grad-button: linear-gradient(to bottom right, var(--primary-deep), var(--primary-2-deep)); + --grad-data: linear-gradient(90deg, var(--cyan), var(--primary)); + --grad-green: linear-gradient(90deg, var(--green-deep), var(--green)); + + /* The canvas behind Map, Flow, Screens and Steps: a dot grid at pitch 20 and + two ambient glows, top left and bottom right. */ + --canvas: radial-gradient(ellipse 34% 60% at 10% 6%, color-mix(in srgb, var(--ambient-1) 35%, transparent), transparent), + radial-gradient(ellipse 28% 50% at 95% 98%, color-mix(in srgb, var(--ambient-2) 35%, transparent), transparent), + radial-gradient(circle, var(--dots) 1.05px, transparent 1.6px) 0 0 / 20px 20px, + var(--canvas-base); + + /* §3.2 — the 15 aliases, so a rule written against upstream's names paints + with D's. New rules use the token names. `--accent-ink` is upstream's + sixteenth name; it maps the way §3.2 maps `--accent`. */ + --paper: var(--panel); + --paper-2: var(--raised); + --press: var(--raised); + --press-2: var(--line); + --ink: var(--fg); + --ink-2: var(--fg-2); + --ink-3: var(--fg-3); + --ink-4: var(--fg-4); + --rule: var(--line-strong); + --rule-soft: var(--line); + --rule-faint: var(--line-faint); + --accent: var(--primary); + --accent-soft: var(--primary-soft); + --accent-line: var(--primary-line); + --accent-ink: var(--primary-ink); + --code-comment: var(--syn-com); + + /* ---------- Svelte Flow ---------- + @xyflow/svelte ships its own palette in `--xy-*`; mapping it here keeps + library defaults out of the gaps our node and edge components do not + paint: the pane, the controls, the minimap, the selection ring. */ + --xy-background-color: transparent; + --xy-background-pattern-color: var(--dots); + --xy-edge-stroke: var(--line-strong); + --xy-edge-stroke-selected: var(--cyan); + --xy-edge-stroke-width: 1; + --xy-connectionline-stroke: var(--fg-3); + --xy-node-color: var(--fg); + --xy-node-background-color: var(--card); + --xy-node-border: 1px solid var(--line); --xy-node-boxshadow-hover: none; --xy-node-boxshadow-selected: none; - --xy-selection-background-color: var(--accent-soft); - --xy-selection-border: 1px solid var(--accent-line); - + --xy-selection-background-color: color-mix(in srgb, var(--primary-soft) 60%, transparent); + --xy-selection-border: 1px solid var(--primary-line); --xy-handle-background-color: transparent; --xy-handle-border-color: transparent; - - --xy-controls-button-background-color: var(--paper); - --xy-controls-button-background-color-hover: var(--press); - --xy-controls-button-color: var(--ink-2); - --xy-controls-button-color-hover: var(--ink); - --xy-controls-button-border-color: var(--rule-soft); - --xy-controls-box-shadow: none; - - --xy-minimap-background-color: var(--paper-2); - --xy-minimap-mask-background-color: var(--paper); - --xy-minimap-node-background-color: var(--rule-soft); - --xy-minimap-node-stroke-color: var(--ink-3); - + --xy-controls-button-background-color: var(--overlay); + --xy-controls-button-background-color-hover: var(--raised); + --xy-controls-button-color: var(--fg-2); + --xy-controls-button-color-hover: var(--fg); + --xy-controls-button-border-color: var(--line); + --xy-controls-box-shadow: var(--sh-pop); + --xy-minimap-background-color: var(--overlay); + --xy-minimap-mask-background-color: color-mix(in srgb, var(--bg) 60%, transparent); + --xy-minimap-node-background-color: var(--line-strong); + --xy-minimap-node-stroke-color: var(--fg-3); --xy-attribution-background-color: transparent; - - --xy-resize-background-color: var(--accent); - --xy-error-color: var(--accent); + --xy-resize-background-color: var(--primary); + --xy-error-color: var(--red); } diff --git a/ui/src/main.ts b/ui/src/main.ts index 1dde3df..173f2c5 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,15 +1,20 @@ // Fonts are vendored, not fetched: a loopback reader for a local index must // work with the network off, and must not announce the project to a CDN. -import '@fontsource-variable/archivo/wght.css'; -import '@fontsource/ibm-plex-mono/400.css'; -import '@fontsource/ibm-plex-mono/400-italic.css'; -import '@fontsource/ibm-plex-mono/500.css'; -import '@fontsource/ibm-plex-mono/600.css'; +// +// Inter for the interface, JetBrains Mono for code (docs/design/viewer-d.md §5), +// both variable, normal style only: the design sets nothing in italic. +import '@fontsource-variable/inter/wght.css'; +import '@fontsource-variable/jetbrains-mono/wght.css'; import './app.css'; +import { applyStoredTheme } from './lib/theme-choice.svelte'; import { mount } from 'svelte'; import App from './App.svelte'; +// Before the first paint of the app, so a stored Dark or Light choice never +// flashes the OS scheme first. (The CSP forbids an inline script in the head.) +applyStoredTheme(); + const target = document.getElementById('app'); if (!target) throw new Error('codegraph ui: #app host element is missing from index.html'); diff --git a/ui/tests/theme-contrast.test.ts b/ui/tests/theme-contrast.test.ts new file mode 100644 index 0000000..ff992ae --- /dev/null +++ b/ui/tests/theme-contrast.test.ts @@ -0,0 +1,148 @@ +// The two direction D token sets, read out of `src/lib/theme.css` itself, held +// to the WCAG 2.x pairs docs/design/viewer-d.md declares (§3.1 Nebula, §3.6 +// Daylight): every declared pair at 4.5:1 or better. `fg-4` is decorative in +// both sets (§3.5) and is not a declared pair. +// +// Also pinned: the four copies of the sets inside theme.css agree with each +// other, and the SVG export's literal palettes are the same colours. +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { EXPORT_PALETTES } from '../src/lib/export-svg'; + +const CSS = readFileSync(new URL('../src/lib/theme.css', import.meta.url), 'utf8'); + +/** The `--name: #hex;` declarations of the first rule whose selector matches. */ +function block(selector: RegExp): Record { + const match = selector.exec(CSS); + if (!match) throw new Error(`no rule matching ${selector}`); + const open = CSS.indexOf('{', match.index + match[0].length - 1); + let depth = 0; + let end = open; + for (; end < CSS.length; end++) { + if (CSS[end] === '{') depth++; + else if (CSS[end] === '}' && --depth === 0) break; + } + const body = CSS.slice(open + 1, end); + const tokens: Record = {}; + for (const m of body.matchAll(/--([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) { + tokens[m[1] as string] = (m[2] as string).toLowerCase(); + } + return tokens; +} + +// The bare `:root {` set is Daylight; the explicit dark rule is Nebula. +const DAYLIGHT = block(/\n:root \{/); +const NEBULA = block(/:root\[data-theme='dark'\],\s*\n\[data-theme='dark'\] \{/); +const NEBULA_BY_OS = block(/:root:not\(\[data-theme='light'\]\) \{/); +const DAYLIGHT_IN_DARK_PAGE = block(/\n\[data-theme='light'\] \{/); + +/** WCAG 2.x relative luminance of `#rrggbb`. */ +function luminance(hex: string): number { + const channel = (i: number): number => { + const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2); +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; + return (hi + 0.05) / (lo + 0.05); +} + +const SURFACES = ['bg', 'rail', 'panel', 'card', 'raised', 'overlay']; +/** Text and accents that carry meaning on any surface (§3.1, §3.6). */ +const TEXT = ['fg', 'fg-2', 'fg-3', 'primary', 'primary-2', 'primary-ink', 'cyan', 'green', 'amber', 'red', 'violet']; +/** Code sits on the card, the hovered line and the hot line only. */ +const SYNTAX = ['syn-kw', 'syn-str', 'syn-num', 'syn-com', 'syn-type', 'syn-punct']; +const CODE_SURFACES = ['card', 'raised', 'primary-soft']; +/** A tone pill: its text on its own soft fill. */ +const PILLS: Array<[string, string]> = [ + ['primary-ink', 'primary-soft'], + ['cyan', 'cyan-soft'], + ['green', 'green-soft'], + ['amber', 'amber-soft'], + ['red', 'red-soft'], + ['violet', 'violet-soft'], +]; +/** `on-primary` is the only text set on a gradient: GRAD.button's two stops. */ +const BUTTON: Array<[string, string]> = [ + ['on-primary', 'primary-deep'], + ['on-primary', 'primary-2-deep'], +]; + +const AA = 4.5; + +function declaredPairs(): Array<[string, string]> { + const pairs: Array<[string, string]> = []; + for (const fg of TEXT) for (const bg of SURFACES) pairs.push([fg, bg]); + for (const fg of SYNTAX) for (const bg of CODE_SURFACES) pairs.push([fg, bg]); + return [...pairs, ...PILLS, ...BUTTON]; +} + +describe.each([ + ['D · Nebula (dark)', NEBULA], + ['D · Daylight (light)', DAYLIGHT], +])('%s', (_name, tokens) => { + it('declares all 39 tokens', () => { + expect(Object.keys(tokens).length).toBeGreaterThanOrEqual(39); + for (const [fg, bg] of declaredPairs()) { + expect(tokens[fg], fg).toMatch(/^#[0-9a-f]{6}$/); + expect(tokens[bg], bg).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it.each(declaredPairs())('%s on %s holds 4.5:1', (fg, bg) => { + const ratio = contrast(tokens[fg] as string, tokens[bg] as string); + expect(ratio, `${fg} ${tokens[fg]} on ${bg} ${tokens[bg]}: ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(AA); + }); +}); + +describe('the token sets inside theme.css', () => { + it('the OS-dark block is the explicit dark block, value for value', () => { + expect(NEBULA_BY_OS).toEqual(NEBULA); + }); + + it('the light-inside-a-dark-page block is the bare :root set, value for value', () => { + expect(DAYLIGHT_IN_DARK_PAGE).toEqual(DAYLIGHT); + }); + + it('pins the spec values that most often get mistyped', () => { + // §3.4's AA corrections and §3.6's surfaces. + expect(NEBULA['fg-3']).toBe('#838da0'); + expect(NEBULA['syn-com']).toBe('#808aa2'); + expect(NEBULA['primary-deep']).toBe('#4465ff'); + expect(DAYLIGHT.bg).toBe('#ebeef4'); + expect(DAYLIGHT.panel).toBe('#ffffff'); + expect(DAYLIGHT['primary-ink']).toBe('#3340aa'); + }); +}); + +describe('the SVG export palettes', () => { + /** §3.2: upstream's names and the D tokens they alias. */ + const ALIAS: Record = { + paper: 'panel', + paper2: 'raised', + press: 'raised', + ink: 'fg', + ink2: 'fg-2', + ink3: 'fg-3', + ink4: 'fg-4', + ruleSoft: 'line', + ruleFaint: 'line-faint', + accent: 'primary', + accentSoft: 'primary-soft', + accentLine: 'primary-line', + codeComment: 'syn-com', + }; + + it.each([ + ['light', DAYLIGHT], + ['dark', NEBULA], + ] as const)('the %s export paints the theme tokens', (theme, tokens) => { + const palette = EXPORT_PALETTES[theme] as unknown as Record; + for (const [name, token] of Object.entries(ALIAS)) { + expect(palette[name], `${theme} ${name} = ${token}`).toBe(tokens[token]); + } + }); +}); diff --git a/ui/tests/ui-d-highlight.test.ts b/ui/tests/ui-d-highlight.test.ts new file mode 100644 index 0000000..c2f5752 --- /dev/null +++ b/ui/tests/ui-d-highlight.test.ts @@ -0,0 +1,29 @@ +// Direction D colours two token kinds upstream left in plain ink +// (docs/design/viewer-d.md §3: `syn-type`, `syn-punct`), without letting an +// unhighlighted line turn the punctuation colour. +import { describe, it, expect } from 'vitest'; +import { tokenClass, tokenClassFor } from '../src/lib/highlight'; + +describe('tokenClassFor', () => { + it('keeps every class tokenClass already gives', () => { + for (const cls of ['comment', 'string', 'keyword', 'number', 'def'] as const) { + expect(tokenClassFor({ cls, text: 'x' })).toBe(tokenClass(cls)); + } + }); + + it('colours a type reference', () => { + expect(tokenClassFor({ cls: 'type', text: 'Path' })).toBe('t-t'); + }); + + it('colours an other token only when it is all punctuation', () => { + expect(tokenClassFor({ cls: 'other', text: '(' })).toBe('t-p'); + expect(tokenClassFor({ cls: 'other', text: ') -> ' })).toBe('t-p'); + expect(tokenClassFor({ cls: 'other', text: '::' })).toBe('t-p'); + }); + + it('leaves whitespace and whole unclassified lines in plain ink', () => { + expect(tokenClassFor({ cls: 'other', text: ' ' })).toBeNull(); + expect(tokenClassFor({ cls: 'other', text: 'let x = parse(input);' })).toBeNull(); + expect(tokenClassFor({ cls: 'ident', text: 'resolve' })).toBeNull(); + }); +}); diff --git a/ui/tests/ui-export-svg.test.ts b/ui/tests/ui-export-svg.test.ts index e41dd43..c3befd4 100644 --- a/ui/tests/ui-export-svg.test.ts +++ b/ui/tests/ui-export-svg.test.ts @@ -277,7 +277,8 @@ describe('flowSvg', () => { it('keeps fonts as stacks and embeds nothing', () => { const svg = flowSvg(layout); - expect(svg).toContain("'IBM Plex Mono'"); + // Direction D sets code in JetBrains Mono (docs/design/viewer-d.md §5). + expect(svg).toContain("'JetBrains Mono'"); expect(svg).not.toContain('@font-face'); expect(svg).not.toContain('base64'); }); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2067bef..bf16e65 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -34,6 +34,11 @@ export default defineConfig(({ command }) => { // double the bundle in every platform archive for no one's benefit. sourcemap: false, chunkSizeWarningLimit: 1024, + // Never inline an asset as a data: URI. The server's CSP allows fonts + // from 'self' only, and Vite would otherwise inline every font subset + // under 4 kB (JetBrains Mono's cyrillic-ext is 2 kB) — which the browser + // then refuses to load. + assetsInlineLimit: 0, }, server: { host: '127.0.0.1', From 1e1ca15cadb74189de877b2e76b1e744bb74fdb1 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 07:00:13 +0800 Subject: [PATCH 2/6] feat(ui): direction D shell Replace the top tab bar with the design's icon nav rail, the command bar with its search palette and index status, and the trail ribbon, with the theme switch, the shortcut sheet and the viewer facts in the rail's foot. Below 1024px the rail narrows and search folds into a button; below 600px a bottom tab bar and a More sheet take the rail's place. Add the error card and the amber drift callout the views share. --- ui/src/App.svelte | 42 ++- ui/src/components/BrandMark.svelte | 37 +++ ui/src/components/DriftBanner.svelte | 59 ++-- ui/src/components/ErrorCard.svelte | 76 +++++ ui/src/components/MoreSheet.svelte | 132 +++++++++ ui/src/components/NavRail.svelte | 197 +++++++++++++ ui/src/components/PalettePanel.svelte | 65 ++++- ui/src/components/PaletteRows.svelte | 61 ++-- ui/src/components/PhoneTabBar.svelte | 84 ++++++ ui/src/components/SearchPalette.svelte | 66 ++++- ui/src/components/ShortcutSheet.svelte | 68 +++++ ui/src/components/ThemeSwitch.svelte | 174 +++++++++++ ui/src/components/Toast.svelte | 26 +- ui/src/components/TopBar.svelte | 320 ++++++++++++++------- ui/src/components/TrailBar.svelte | 382 ++++++++++++++++++------- ui/src/components/ViewerInfo.svelte | 99 +++++++ ui/src/lib/command.svelte.ts | 20 ++ ui/src/lib/destinations.ts | 97 +++++++ 18 files changed, 1701 insertions(+), 304 deletions(-) create mode 100644 ui/src/components/BrandMark.svelte create mode 100644 ui/src/components/ErrorCard.svelte create mode 100644 ui/src/components/MoreSheet.svelte create mode 100644 ui/src/components/NavRail.svelte create mode 100644 ui/src/components/PhoneTabBar.svelte create mode 100644 ui/src/components/ShortcutSheet.svelte create mode 100644 ui/src/components/ThemeSwitch.svelte create mode 100644 ui/src/components/ViewerInfo.svelte create mode 100644 ui/src/lib/command.svelte.ts create mode 100644 ui/src/lib/destinations.ts diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ba7d829..5e69810 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,6 +1,9 @@ + + + + diff --git a/ui/src/components/DriftBanner.svelte b/ui/src/components/DriftBanner.svelte index 5a44021..b158925 100644 --- a/ui/src/components/DriftBanner.svelte +++ b/ui/src/components/DriftBanner.svelte @@ -1,18 +1,19 @@
- + {file} changed on disk after the last index sync — {@render children()}
diff --git a/ui/src/components/ErrorCard.svelte b/ui/src/components/ErrorCard.svelte new file mode 100644 index 0000000..b88630f --- /dev/null +++ b/ui/src/components/ErrorCard.svelte @@ -0,0 +1,76 @@ + + + + + + diff --git a/ui/src/components/MoreSheet.svelte b/ui/src/components/MoreSheet.svelte new file mode 100644 index 0000000..0cc1d8e --- /dev/null +++ b/ui/src/components/MoreSheet.svelte @@ -0,0 +1,132 @@ + + + + + + + + + diff --git a/ui/src/components/NavRail.svelte b/ui/src/components/NavRail.svelte new file mode 100644 index 0000000..2205096 --- /dev/null +++ b/ui/src/components/NavRail.svelte @@ -0,0 +1,197 @@ + + + + + + + +{#snippet destination(item: Destination)} +
  • + + + +
  • +{/snippet} + + diff --git a/ui/src/components/PalettePanel.svelte b/ui/src/components/PalettePanel.svelte index 2d23aab..5854aa7 100644 --- a/ui/src/components/PalettePanel.svelte +++ b/ui/src/components/PalettePanel.svelte @@ -4,7 +4,7 @@ * * It renders whatever `palette.view` is: the entry points when the box is * empty, the ranked kind groups when it is not. The keyboard lives in - * `TopBar` (the keys are pressed in the input, not here) and arrives as the + * `SearchPalette` (the keys are pressed in the input, not here) and arrives as the * `selected` index; this component's only job beyond drawing is keeping that * row in view when the selection moves past the panel's edge. */ @@ -47,36 +47,73 @@ {:else if palette.pending && view.items.length === 0}

    Searching…

    {:else if view.empty} -

    {view.empty}

    +

    {view.empty}

    {/if} + + diff --git a/ui/src/components/PaletteRows.svelte b/ui/src/components/PaletteRows.svelte index a872ffe..acd320b 100644 --- a/ui/src/components/PaletteRows.svelte +++ b/ui/src/components/PaletteRows.svelte @@ -101,39 +101,53 @@ display: flex; align-items: baseline; gap: 8px; - padding: 6px 10px 4px; - border-bottom: 1px solid var(--rule-faint); - color: var(--ink-3); - font-size: 12px; + padding: 8px 8px 4px; + color: var(--fg-3); + font: var(--t-micro); + letter-spacing: 0.6px; + text-transform: uppercase; } .head-note { overflow: hidden; - color: var(--ink-4); - font-size: 11.5px; + color: var(--fg-4); + font: var(--t-caption); + letter-spacing: 0; text-overflow: ellipsis; + text-transform: none; white-space: nowrap; } + /* §7: result rows 40 high, `card` + `line-faint`, r 8. */ .row { display: grid; width: 100%; - align-items: baseline; - padding: 6px 10px; - border-bottom: 1px solid var(--rule-faint); - color: var(--ink); + min-height: 40px; + align-items: center; + margin: 0 0 4px; + padding: 0 10px; + border: 1px solid var(--line-faint); + border-radius: 8px; + background: var(--card); + color: var(--fg); gap: 10px; - grid-template-columns: 18px 1fr auto; + grid-template-columns: 20px minmax(0, 1fr) auto; text-align: left; } - .row:last-child { - border-bottom: 0; + .row:hover { + border-color: var(--line); + background: var(--raised); } - .row:hover, .row.sel { - background: var(--press); + border-color: var(--primary-line); + background: var(--primary-soft); + box-shadow: var(--glow-20); + } + + .row.sel .nm { + color: var(--primary-ink); } .mid { @@ -143,25 +157,22 @@ } .nm { - font-family: var(--mono); - font-size: 12.5px; + font: var(--t-mono-500); } .nm.gen { - color: var(--ink-4); + color: var(--fg-4); } .sig { - margin-left: 6px; - color: var(--ink-3); - font-family: var(--mono); - font-size: 11.5px; + margin-left: 8px; + color: var(--fg-3); + font: var(--t-mono-sm); } .loc { - color: var(--ink-3); - font-family: var(--mono); - font-size: 11px; + color: var(--fg-3); + font: var(--t-mono-sm); white-space: nowrap; } diff --git a/ui/src/components/PhoneTabBar.svelte b/ui/src/components/PhoneTabBar.svelte new file mode 100644 index 0000000..ecfa69f --- /dev/null +++ b/ui/src/components/PhoneTabBar.svelte @@ -0,0 +1,84 @@ + + + + + + diff --git a/ui/src/components/SearchPalette.svelte b/ui/src/components/SearchPalette.svelte index 61fa459..e6ea898 100644 --- a/ui/src/components/SearchPalette.svelte +++ b/ui/src/components/SearchPalette.svelte @@ -14,6 +14,7 @@ * graph through the installed navigation driver — which is the right default * both for `codegraph ui` and for a host that installed one. */ + import Icon from './Icon.svelte'; import PalettePanel from './PalettePanel.svelte'; import { palette } from '../lib/palette.svelte'; import type { PaletteItem } from '../lib/search-model'; @@ -29,12 +30,15 @@ } let { - placeholder = 'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus', + placeholder = 'Search symbols and files — or ask “how does X reach Y”', label = 'Search symbols and files', onpick, }: Props = $props(); let input: HTMLInputElement | null = $state(null); + + /** The modifier the shortcut uses here: ⌘ on a Mac, Ctrl elsewhere. */ + const MOD = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '') ? '⌘' : 'Ctrl'; let box: HTMLDivElement | null = $state(null); /** Focus and select the box — what `/` and Cmd-K reach. */ @@ -131,7 +135,8 @@ - diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte index c0ec462..3d0e5e6 100644 --- a/ui/src/components/TrailBar.svelte +++ b/ui/src/components/TrailBar.svelte @@ -7,6 +7,7 @@ * having; anything modal would stop the reading to ask about filing. */ import KindGlyph from './KindGlyph.svelte'; + import Icon from './Icon.svelte'; import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte'; import { navigate, symbolHref, flowHref } from '../lib/navigation'; import { trails } from '../lib/trails.svelte'; @@ -18,6 +19,54 @@ let hops = $derived(trail.hops); + // The read-only pill needs to know before Save is pressed whether saving is + // on at all, so the list is asked for as soon as there is a trail to keep. + $effect(() => { + if (hops.length > 0) void trails.ensure(); + }); + + /* ---- fitting the hops: the earliest collapse into a `+N` pill (§2) ---- + JetBrains Mono advances 0.6 em, so a hop's width is arithmetic: 8 + tile + 18 + 8 + 7.2 per character + 10, and 20 for the chevron before it. */ + const HOP_CHROME = 44; + const CHAR_W = 7.2; + const SEP_W = 20; + const MORE_W = 44; + + let hopsWidth = $state(0); + let expanded = $state(false); + + function hopWidth(index: number): number { + const hop = hops[index]; + return (hop ? HOP_CHROME + hopLabel(hop).length * CHAR_W : 0) + (index > 0 ? SEP_W : 0); + } + + /** How many of the earliest hops fold into the `+N` pill. */ + let hidden = $derived.by(() => { + if (expanded || hopsWidth === 0) return 0; + let total = 0; + for (let i = 0; i < hops.length; i++) total += hopWidth(i); + if (total <= hopsWidth) return 0; + let used = MORE_W; + let shown = 0; + for (let i = hops.length - 1; i >= 0; i--) { + const width = hopWidth(i); + if (used + width > hopsWidth && shown > 0) break; + used += width; + shown += 1; + } + return hops.length - shown; + }); + + // A new walk starts folded again. + $effect(() => { + void hops.length; + expanded = false; + }); + + /** The phone's back chip steps to the hop before the current one. */ + let previous = $derived(hops.length > 1 ? hops[hops.length - 2] : null); + let naming = $state(false); let name = $state(''); let nameInput: HTMLInputElement | null = $state(null); @@ -105,57 +154,84 @@ -
    + whose height grows only while the form is open. With no trail there is + nothing to draw, and the islands move up under the command bar (§2). --> +
    +{#if hops.length > 0}
    - Trail - - {#if hops.length === 0} - Step into a call on the right, or up to a caller on the left — the trail records the - path. - {:else} - {#each hops as hop, i (hop.id)} - {#if i > 0} - - {/if} + Trail + + + + {#if previous} + + {/if} + trail · {hops.length} hop{hops.length === 1 ? '' : 's'} + + +
    + {#if hidden > 0} - - {hopLabel(hop)} - + {/if} + {#each hops as hop, i (hop.id)} + {#if i >= hidden} + {#if i > hidden} + + {/if} + + {/if} {/each} - {/if} - - - - {#if hops.length > 1} - - {/if} - {#if hops.length > 0 && trails.canSave && !naming} - - {/if} - {#if hops.length > 0} - - {/if} +
    + +
    + {#if hops.length > 1} + + {/if} + {#if trails.canSave && !naming} + + {:else if !trails.canSave} + + {trails.payload?.readOnly + ? 'Read-only (--read-only) · trails not saved' + : 'Read-only · trails not saved'} + + {/if} + +
    +{/if} {#if naming}
    @@ -171,10 +247,10 @@ spellcheck="false" placeholder="How a request reaches the handler" /> - - + @@ -194,90 +270,127 @@
    diff --git a/ui/src/components/ViewerInfo.svelte b/ui/src/components/ViewerInfo.svelte new file mode 100644 index 0000000..b3bc6b7 --- /dev/null +++ b/ui/src/components/ViewerInfo.svelte @@ -0,0 +1,99 @@ + + + +
    +
    This viewer
    +
    +
    Project
    +
    {stats?.project.root ?? '…'}
    +
    Index
    +
    {indexLine}
    +
    Live
    +
    {liveLine}
    +
    Trails
    +
    {trailsLine}
    +
    +

    Port, read-only and watching are set on the command line: codegraph ui --help.

    +
    + + diff --git a/ui/src/lib/command.svelte.ts b/ui/src/lib/command.svelte.ts new file mode 100644 index 0000000..d02024f --- /dev/null +++ b/ui/src/lib/command.svelte.ts @@ -0,0 +1,20 @@ +/** + * A request for the command palette from somewhere that does not own it. + * + * The palette lives in the command bar, which App owns; a Start-screen + * "Search" button is in a view. A counter rather than a callback, for the same + * reason the live channel uses one: App's effect reads it and focuses the + * palette when it moves, and nothing else has to hold a reference to the bar. + */ + +let searchTick = $state(0); + +export const command = { + get searchTick(): number { + return searchTick; + }, + /** Open the ⌘K palette with the cursor in it. */ + requestSearch(): void { + searchTick += 1; + }, +}; diff --git a/ui/src/lib/destinations.ts b/ui/src/lib/destinations.ts new file mode 100644 index 0000000..b1cc3f5 --- /dev/null +++ b/ui/src/lib/destinations.ts @@ -0,0 +1,97 @@ +/** + * The viewer's destinations, in the nav rail's order (docs/design/viewer-d.md + * §2: Start, Map, Symbol, Flow, Screens, Steps, Dead code, Saved trails). + * + * One list for the rail, the phone's tab bar and its More sheet, so the three + * can never disagree about where a destination goes or when it is the current + * one. Reads the live route, so callers derive from it inside a component. + */ + +import type { IconName } from './icons'; +import { + deadHref, + entryHref, + flowHref, + mapHref, + router, + screensHref, + stepsHref, + symbolHref, +} from './router.svelte'; +import { trail } from './trail.svelte'; + +export type DestinationId = + | 'start' + | 'map' + | 'symbol' + | 'flow' + | 'screens' + | 'steps' + | 'dead' + | 'trails'; + +export interface Destination { + id: DestinationId; + label: string; + /** The phone tab bar's caption — the label, shortened where it must be. */ + short: string; + icon: IconName; + href: string; + active: boolean; +} + +/** + * @param hasScreens the graph holds screen navigation, so `#/` renders the + * Screens view (upstream's rule) and Start is not what `#/` shows. + */ +export function destinations(hasScreens: boolean): Destination[] { + const route = router.route; + const view = route.view; + + // The Symbol destination returns you to where you were reading, not to a + // blank view: the current symbol, else the trail's last hop, else the + // view's own empty screen. Never `#/`, which may render Screens. + let symbolTarget: string; + if (route.view === 'symbol' && route.id !== null) symbolTarget = symbolHref(route.id); + else symbolTarget = symbolHref(trail.current ? trail.current.id : null); + + return [ + { + id: 'start', + label: 'Start', + short: 'Start', + icon: 'layout-dashboard', + href: '#/', + active: view === 'home' && !hasScreens, + }, + { id: 'map', label: 'Map', short: 'Map', icon: 'network', href: mapHref(), active: view === 'map' }, + { + id: 'symbol', + label: 'Symbol', + short: 'Symbol', + icon: 'braces', + href: symbolTarget, + // The File view is reading code too; the boards light Symbol for it. + active: view === 'symbol' || view === 'file', + }, + { id: 'flow', label: 'Flow', short: 'Flow', icon: 'workflow', href: flowHref(), active: view === 'flow' }, + { + id: 'screens', + label: 'Screens', + short: 'Screens', + icon: 'monitor-smartphone', + href: screensHref(), + active: view === 'screens' || (view === 'home' && hasScreens), + }, + { id: 'steps', label: 'Steps', short: 'Steps', icon: 'footprints', href: stepsHref(), active: view === 'steps' }, + { id: 'dead', label: 'Dead code', short: 'Dead code', icon: 'ghost', href: deadHref(), active: view === 'dead' }, + { + id: 'trails', + label: 'Saved trails and entry points', + short: 'Trails', + icon: 'bookmark', + href: entryHref(), + active: view === 'entry', + }, + ]; +} From dc8a215767918a172fa02a2f411a7c7a470fe9e2 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 07:00:13 +0800 Subject: [PATCH 3/6] feat(ui): direction D views and states Lay every view out as the design's islands: Start as a dashboard of what the index holds, Symbol with its line-anchored Calls rail, code card, caller filter and blast radius tiles, File, Flow with an Every hop table, Map with a tabbed inspector, Type hierarchy, Dead code, Entry points and saved trails, and the Screens and Steps empty states. Draw the loading, drift, error, long-name, empty-search, keyboard-focus and read-only states, the tablet drawer and the phone panes and sheet. Layout constants measured against the old fonts and geometry move with the design, each pinned by name in the suites. --- ui/src/components/ExportButtons.svelte | 34 +- ui/src/components/SavedTrails.svelte | 166 ++-- ui/src/components/entry/EntrySection.svelte | 125 +-- ui/src/components/file/CodeArcs.svelte | 7 +- ui/src/components/file/FileCodeBlock.svelte | 102 ++- ui/src/components/file/FileCodeOutline.svelte | 11 +- ui/src/components/file/FileCodeRail.svelte | 15 +- ui/src/components/file/FileModeTabs.svelte | 35 +- ui/src/components/file/FileOutline.svelte | 138 +++- ui/src/components/file/FileRail.svelte | 145 ++-- ui/src/components/flow/FlowCard.svelte | 128 ++- ui/src/components/flow/FlowEndCap.svelte | 42 +- ui/src/components/flow/FlowLink.svelte | 21 +- ui/src/components/map/MapKey.svelte | 127 +-- ui/src/components/map/MapSidePanel.svelte | 731 +++++++++++------- ui/src/components/map/ModuleEdge.svelte | 17 +- ui/src/components/map/ModuleNode.svelte | 103 +-- ui/src/components/symbol/BlastStrip.svelte | 146 ++-- ui/src/components/symbol/CalleeRail.svelte | 259 +++++-- ui/src/components/symbol/CallersRail.svelte | 382 ++++++--- ui/src/components/symbol/Connectors.svelte | 31 +- .../components/symbol/MembersOutline.svelte | 91 ++- ui/src/components/symbol/SourceBlock.svelte | 236 ++++-- ui/src/components/symbol/SymbolHeader.svelte | 331 +++++--- ui/src/components/symbol/TypeHierarchy.svelte | 249 +++--- ui/src/lib/file-model.ts | 7 +- ui/src/lib/flow-model.ts | 8 +- ui/src/lib/hierarchy-model.ts | 24 +- ui/src/lib/map-model.ts | 34 +- ui/src/lib/screens-model.ts | 6 +- ui/src/lib/symbol-model.ts | 60 ++ ui/src/views/DeadCodeView.svelte | 575 +++++++++----- ui/src/views/EntryView.svelte | 148 ++-- ui/src/views/FileCodeView.svelte | 66 +- ui/src/views/FileView.svelte | 226 ++++-- ui/src/views/FlowView.svelte | 499 +++++++++--- ui/src/views/HomeView.svelte | 625 ++++++++++++--- ui/src/views/MapView.svelte | 137 ++-- ui/src/views/NotFoundView.svelte | 6 +- ui/src/views/ScreensView.svelte | 116 ++- ui/src/views/StepsView.svelte | 79 +- ui/src/views/SymbolView.svelte | 555 +++++++++++-- ui/tests/ui-d-symbol-rails.test.ts | 122 +++ ui/tests/ui-steps-model.test.ts | 5 +- 44 files changed, 4921 insertions(+), 2049 deletions(-) create mode 100644 ui/tests/ui-d-symbol-rails.test.ts diff --git a/ui/src/components/ExportButtons.svelte b/ui/src/components/ExportButtons.svelte index ca698c6..4289a73 100644 --- a/ui/src/components/ExportButtons.svelte +++ b/ui/src/components/ExportButtons.svelte @@ -12,6 +12,7 @@ until it is pressed. -->
    - - +
    diff --git a/ui/src/components/SavedTrails.svelte b/ui/src/components/SavedTrails.svelte index cf36ac2..99a01df 100644 --- a/ui/src/components/SavedTrails.svelte +++ b/ui/src/components/SavedTrails.svelte @@ -1,4 +1,5 @@ +
    + Outline - in source order - {total} + source order · nested by owner · {total} +
    @@ -114,18 +120,20 @@ class="orow" class:dimmed={row.dimmed} class:sel={index === selected} - style:padding-left={`${4 + row.indent * 22}px`} + style:padding-left={`${8 + row.indent * 28}px`} onclick={() => onopen(row.entry)} onmouseenter={() => onhover?.(index)} title={`${row.entry.qualifiedName} — line ${row.entry.line}`} > - + {row.entry.name} {row.entry.signature ?? ''} - - {row.entry.line}{#if row.entry.fanIn} · ← {row.entry.fanIn}{/if}{#if row.entry.fanOut} · - → {row.entry.fanOut}{/if} + + {row.entry.fanIn ?? 0} + + {row.entry.fanOut ?? 0} + {row.entry.line} {/each} {#if window_.after > 0}
    {/if} @@ -144,81 +152,137 @@ screen caps what it draws.
    {/if} +
    diff --git a/ui/src/components/file/FileRail.svelte b/ui/src/components/file/FileRail.svelte index 002e29c..176d844 100644 --- a/ui/src/components/file/FileRail.svelte +++ b/ui/src/components/file/FileRail.svelte @@ -14,6 +14,7 @@ read as broken. -->
    - {#if open}
    @@ -93,97 +97,126 @@
    diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte index fdc056a..c64d0f4 100644 --- a/ui/src/components/map/MapSidePanel.svelte +++ b/ui/src/components/map/MapSidePanel.svelte @@ -12,6 +12,8 @@ --> -
    + + +
    + {#if selectedModule && files.length > 0} + Open first file + {/if} + +
    -{#snippet linkList(label: string, links: WireMapLink[], side: 'source' | 'target')} -
    {label}
    +{#snippet linkList(label: string, links: WireMapLink[], side: 'source' | 'target', max: number)} +
    {label} · {links.length}
    {#if links.length > 0} - {#each links as link (link.source + link.target)} -
    - {side === 'target' ? link.target : link.source} - {link.count} -
    + {#each links.slice(0, 8) as link (link.source + link.target)} + {/each} + {#if links.length > 8}
    +{links.length - 8} more
    {/if} {:else}
    nothing
    {/if} {/snippet} diff --git a/ui/src/components/map/ModuleEdge.svelte b/ui/src/components/map/ModuleEdge.svelte index e372c9b..f8a09ec 100644 --- a/ui/src/components/map/ModuleEdge.svelte +++ b/ui/src/components/map/ModuleEdge.svelte @@ -49,20 +49,25 @@ /> diff --git a/ui/src/components/symbol/BlastStrip.svelte b/ui/src/components/symbol/BlastStrip.svelte index f9923b4..923087b 100644 --- a/ui/src/components/symbol/BlastStrip.svelte +++ b/ui/src/components/symbol/BlastStrip.svelte @@ -35,34 +35,43 @@
    - Blast radius - {blast.direct} direct dependent{blast.direct === 1 ? '' : 's'} - {blast.withinHops} within {blast.hops} hops - {blast.files} file{blast.files === 1 ? '' : 's'} - {blast.testFiles} test file{blast.testFiles === 1 ? '' : 's'} - {#if blast.routes > 0} - {blast.routes} route{blast.routes === 1 ? '' : 's'} - {/if} + Blast radius + vs widest {maxWithin.toLocaleString()}
    -
    - - -
    - -
    - dark: direct dependents · light: within {blast.hops} hops — scaled to the widest radius in the - index{#if scale?.estimated}{' '}(measured across its {scale.sampled} most-depended-on symbols){/if} +
    +
    + Direct + {blast.direct.toLocaleString()} + +
    +
    + ≤ {blast.hops} hops + {blast.withinHops.toLocaleString()} + +
    +
    + Prod files + {Math.max(0, blast.files - blast.testFiles).toLocaleString()} +
    +
    + Test files + {blast.testFiles.toLocaleString()} + +
    + {#if blast.routes > 0} +
    {plural(blast.routes, 'route')} within reach
    + {/if} {#if blast.topFiles.length > 0}
    - What would need re-checking if this changed + What would need re-checking
    {#each blast.topFiles as entry (entry.file)}
    @@ -85,70 +94,42 @@
    diff --git a/ui/src/components/symbol/CalleeRail.svelte b/ui/src/components/symbol/CalleeRail.svelte index af1e454..bffd742 100644 --- a/ui/src/components/symbol/CalleeRail.svelte +++ b/ui/src/components/symbol/CalleeRail.svelte @@ -11,10 +11,11 @@ where a line ended up. This component draws what it is told. --> -
    - Calls {model.rows.length} - step down → + +
    +
    + Calls {model.rows.length} + {#if model.outsideCalls > 0}+{model.outsideCalls} leave the index{/if} +
    + {#if model.rows.length === 0 && model.uncertain.length === 0} +
    + + {emptyReason} +
    + {/if} + {#if leaving.length > 0} +
    +
    Leaves the indexnot followed
    +
    + {#each leaving as name (name)}{name}{/each} +
    +
    + {/if}
    {#each model.rows as row, i (row.relation.node.id)} @@ -69,13 +117,13 @@ onmouseenter={() => hot.set(node.id)} onmouseleave={() => hot.clear(node.id)} > - +
    {node.name}{#if row.lines.length > 1} ×{row.lines.length}{/if}
    - {node.file === focalFile ? 'same file' : node.file} + {`${node.file === focalFile ? 'same file' : baseName(node.file)}${row.anchor !== null ? ` · :${row.anchor}` : ''}`} {#if row.words.length > 0}{row.words.join(', ')}{/if} {#if row.relation.hub}hub · {row.relation.fanIn}{/if} {#if row.via} {/if} -{#if model.rows.length === 0 && model.uncertain.length === 0} -
    {emptyReason}
    -{:else if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0} +{#if model.rows.length > 0 || model.uncertain.length > 0}{#if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0}
    {#if model.outsideCalls > 0} +{plural(model.outsideCalls, 'more call')} into symbols outside the index{#if model.outsideTypeRefs > 0}{' '}· @@ -145,32 +191,94 @@
    +{model.hiddenGroups} more callee{model.hiddenGroups === 1 ? '' : 's'} not shown. {/if}
    -{/if} +{/if}{/if} diff --git a/ui/src/components/symbol/CallersRail.svelte b/ui/src/components/symbol/CallersRail.svelte index da0f966..b1b6f95 100644 --- a/ui/src/components/symbol/CallersRail.svelte +++ b/ui/src/components/symbol/CallersRail.svelte @@ -12,6 +12,7 @@ side moved. -->
    - Called by {model.total} - ← step up + Called by {model.total}
    +{#if model.total > 0} + +{/if} + {#if model.total === 0}
    Nothing in the graph calls or references this symbol{exported ? ' — it is exported, so callers may live outside the index (or it is an entry point).' : '.'}
    +{:else if filtering && listed === 0} +
    No caller matches “{query.trim()}”.
    {/if} {#each model.groups as group, groupIndex (group.file)}
    @@ -91,8 +117,8 @@ onmouseenter={() => hot.set(node.id)} onmouseleave={() => hot.clear(node.id)} > - -
    + +
    {rowName(node)}
    {#if row.words.length > 0}{row.words.join(', ')}{/if} @@ -111,8 +137,8 @@ > {/each} {#if row.via}via {row.via}{/if} - {#if isOrigin}you came from here{/if}
    + {#if isOrigin}
    you came from here
    {/if}
    {/each} @@ -120,17 +146,18 @@ {/each} {#if model.tests.rows.length > 0} -
    +
    - Tests · {plural(model.tests.calls, 'call')} from {plural(model.tests.files.length, 'file')} + + Tests + {plural(model.tests.calls, 'call')} · {plural(model.tests.files.length, 'file')} +
    {#each model.tests.rows as row (row.relation.node.id)} {@const node = row.relation.node}
    hot.clear(node.id)} > -
    -
    {rowName(node)}
    -
    {node.file}
    -
    + {rowName(node)} + {basename(node.file)}
    {/each}
    @@ -156,18 +181,20 @@ {/if} {#if model.uncertain.length > 0} -
    +
    - Uncertain · {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'}, - confidence < 0.6 + Uncertain + {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'} · < 0.6 +
    {#each model.uncertain as row (row.relation.node.id)} {@const node = row.relation.node}
    hot.clear(node.id)} > -
    -
    {rowName(node)}
    -
    - {basename(node.file)} - {#if row.relation.confidence !== null} - {row.relation.confidence} - {/if} -
    -
    + {rowName(node)} + {basename(node.file)}{#if row.relation.confidence !== null} · {row.relation.confidence}{/if}
    {/each}
    @@ -205,143 +225,246 @@ {/if} diff --git a/ui/src/components/symbol/Connectors.svelte b/ui/src/components/symbol/Connectors.svelte index d0ef9f7..17027bb 100644 --- a/ui/src/components/symbol/Connectors.svelte +++ b/ui/src/components/symbol/Connectors.svelte @@ -21,6 +21,17 @@ } let { connectors, width, height }: Props = $props(); + + // §7: the hot connector is drawn last, so it crosses over the resting ones. + let ordered = $derived.by(() => { + const resting: Array<{ connector: Connector; key: string }> = []; + const lit: Array<{ connector: Connector; key: string }> = []; + connectors.forEach((connector, i) => { + const entry = { connector, key: `${connector.targetId}:${i}` }; + (hot.is(connector.targetId) ? lit : resting).push(entry); + }); + return [...resting, ...lit]; + });
    + Members - {total} + {total} · ← in → out
    @@ -50,7 +53,7 @@ onclick={() => onopen(row.member)} title={`${row.member.qualifiedName} — ${row.member.file}:${row.member.line}`} > - + {row.member.name} {#if row.member.overrides} @@ -59,10 +62,7 @@ {row.member.overrides.baseTypeName} {/if}{row.member.signature ?? ''} - - {#if row.member.fanIn}← {row.member.fanIn}{/if}{#if row.member.fanIn && row.member.fanOut}  - {/if}{#if row.member.fanOut}→ {row.member.fanOut}{/if} - + ← {row.member.fanIn}→ {row.member.fanOut} {/each}
    @@ -72,80 +72,101 @@ Showing {rows.length} of {total} members — open the file to see the rest.
    {/if} +
    diff --git a/ui/src/components/symbol/SourceBlock.svelte b/ui/src/components/symbol/SourceBlock.svelte index e888794..cf500eb 100644 --- a/ui/src/components/symbol/SourceBlock.svelte +++ b/ui/src/components/symbol/SourceBlock.svelte @@ -16,7 +16,9 @@ callee's own name whatever boundaries a grammar chose. --> -
    +
    + {#if file} +
    + + {file.slice(file.lastIndexOf('/') + 1)} + {range} + + {#if calls > 0} + {calls} call{calls === 1 ? '' : 's'}{#if hotLines > 0} · {hotLines} hot{/if} + {/if} + +
    + {/if} +
    {#each chunks as chunk (chunk.lines[0]?.n ?? -1)} {#if chunk.gapBefore > 0}
    ⋯ {chunk.gapBefore} lines without calls
    @@ -123,11 +174,11 @@ role="link" tabindex="0" title={ref.title} - onclick={() => onfollow(ref)} + onclick={() => onfollow(ref, line.n)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onfollow(ref); + onfollow(ref, line.n); } }} onmouseenter={() => hot.set(ref.targetId)} @@ -147,37 +198,96 @@ {#if block.tailGap > 0}
    ⋯ {block.tailGap} more lines
    {/if} +
    diff --git a/ui/src/components/symbol/SymbolHeader.svelte b/ui/src/components/symbol/SymbolHeader.svelte index bc0658e..7fd5d24 100644 --- a/ui/src/components/symbol/SymbolHeader.svelte +++ b/ui/src/components/symbol/SymbolHeader.svelte @@ -1,17 +1,24 @@ -
    - -

    {node.name}

    - {kindPhrase(node)} - - {node.file}:{node.line}–{node.endLine} - · {plural(node.lines, 'line')} + + +
    + + +

    (tipOpen = cut)} + onmouseleave={() => (tipOpen = false)} + onfocus={() => (tipOpen = cut)} + onblur={() => (tipOpen = false)} + > + {node.name} +

    + + {#each kindPills as pill (pill)}{pill}{/each} + + + + + + + {#if tipOpen} + + {/if}
    -{#if payload.ancestors.length > 0} -
    - in {#each payload.ancestors as ancestor, i (ancestor.id)}{#if i > 0} › {/if}{/each} -
    -{/if} - -
    - {#if node.exported}exported{/if} +
    + {#if payload.hierarchy?.polymorphic} + + Polymorphic · {plural(payload.hierarchy.implementers, 'implementation')} + + {/if} + {#if payload.members.total > 0} + {plural(payload.members.total, 'member')} + {/if} {#if payload.counts.hub} - - hub · {plural(payload.counts.callers, 'caller')} + + Hub · {plural(payload.counts.callers, 'caller')} {/if} - - {testBadge.text} + {#if payload.counts.callees > 0} + {plural(payload.counts.callees, 'callee')} + {/if} + + {testBadge.text} + + + {plural(node.lines, 'line')} · {node.line}–{node.endLine}
    -{#if node.signature} +{#if signature && node.signature}
    {node.name}{node.signature}
    {/if} @@ -163,136 +253,149 @@ {/if} diff --git a/ui/src/components/symbol/TypeHierarchy.svelte b/ui/src/components/symbol/TypeHierarchy.svelte index 8ba659d..495edcd 100644 --- a/ui/src/components/symbol/TypeHierarchy.svelte +++ b/ui/src/components/symbol/TypeHierarchy.svelte @@ -16,6 +16,7 @@ synthesized hop. --> -
    - Type hierarchy - {counts} - supertypes above · subtypes below -
    - {#if model.headline} -

    {model.headline}

    + +

    {model.headline}

    {/if} -
    -
    -
    +
    + + Type hierarchy + {#if counts}{counts}{/if} + supertypes above · subtypes below +
    + +
    +
    + + + {#each view.rows as row (row.node.id + row.side)} + {#if row.side === 'focus'} +
    + + {row.node.name} +
    + {:else} + + {/if} {/each} - - - {#each view.rows as row (row.node.id + row.side)} - {#if row.side === 'focus'} -
    - - {row.node.name} -
    - {:else} - - {/if} - {/each} -
    +
    +
    + + {#if model.foldFrom !== null} + + {/if} + + {#if model.note} +
    {model.note}
    + {/if}
    -{#if model.foldFrom !== null} - -{/if} + diff --git a/ui/src/lib/file-model.ts b/ui/src/lib/file-model.ts index 258be47..57c2037 100644 --- a/ui/src/lib/file-model.ts +++ b/ui/src/lib/file-model.ts @@ -29,8 +29,11 @@ const QUIET_KINDS = new Set(['property', 'field', 'enum_member', 'variable', 'co /** Above this many rows the outline is windowed rather than fully rendered. */ export const OUTLINE_VIRTUAL_THRESHOLD = 250; -/** Fixed row height the windowed outline measures with — pinned in the CSS. */ -export const OUTLINE_ROW_HEIGHT = 28; +/** + * Fixed row pitch the windowed outline measures with — pinned in the CSS. + * Direction D draws 28px rows at a 30px pitch (docs/design/viewer-d.md §8 D-03). + */ +export const OUTLINE_ROW_HEIGHT = 30; /* ----------------------------------------------------------------- rails -- */ diff --git a/ui/src/lib/flow-model.ts b/ui/src/lib/flow-model.ts index 16031f9..188c009 100644 --- a/ui/src/lib/flow-model.ts +++ b/ui/src/lib/flow-model.ts @@ -42,8 +42,10 @@ export const LINK_WIDTH = 86; export const COLUMN_PITCH = CARD_WIDTH + LINK_WIDTH; /** - * Advance of IBM Plex Mono at the 11px a connector label is set in, and the - * clear space kept either side of the longest line. + * Advance of JetBrains Mono at the 11px a connector label is set in (`mono-sm`, + * 0.6 em: 6.60px, measured in Chrome with the bundled font on 2026-10-03; + * upstream's IBM Plex Mono measured 6.65), and the clear space kept either side + * of the longest line. * * A gap only ever GROWS past {@link LINK_WIDTH}: 86px holds `calls` and * `line 2029` comfortably, but a synthesized hop's `registered at App.tsx:3764` @@ -52,7 +54,7 @@ export const COLUMN_PITCH = CARD_WIDTH + LINK_WIDTH; * the very card the label was explaining. The label is the evidence for a hop * nobody can see in the source, so the picture makes room for it. */ -const LABEL_CHAR_WIDTH = 6.65; +const LABEL_CHAR_WIDTH = 6.6; const LABEL_PAD = 18; /** Card header: `10px 12px 6px` padding around one 18px row, plus a rule. */ diff --git a/ui/src/lib/hierarchy-model.ts b/ui/src/lib/hierarchy-model.ts index 0580b33..11649e9 100644 --- a/ui/src/lib/hierarchy-model.ts +++ b/ui/src/lib/hierarchy-model.ts @@ -15,21 +15,27 @@ import type { WireHierarchy, WireHierarchyNode, WireNodeDetail, WireNodeRef } from './wire'; -/** Row height, in px. Fixed, so connector geometry is arithmetic. */ -export const HIER_ROW_H = 24; +/** + * Row height, in px. Fixed, so connector geometry is arithmetic. Direction D + * draws 26 (docs/design/viewer-d.md §8 D-06; upstream's spec drew 24). + */ +export const HIER_ROW_H = 26; -/** Indent per descendant level, in px (design spec §3.10). */ -export const HIER_INDENT = 22; +/** Indent per descendant level, in px — 28 in D (§8 D-06; upstream 22). */ +export const HIER_INDENT = 28; -/** Left edge of a row's kind glyph, measured from the row's own indent. */ +/** Left edge of a row's kind tile, measured from the row's own indent. */ export const HIER_GLYPH_X = 18; +/** The kind tile's edge in the tree — 20 in D (upstream's glyph was 16). */ +export const HIER_TILE = 20; + /** - * Where a row's connector leaves it: the centre of its 16px kind glyph. Lines - * hang off the glyph rather than off the row, so the trunk of a fan reads as - * coming out of the type rather than out of the margin. + * Where a row's connector leaves it: the centre of its kind tile. Lines hang + * off the tile rather than off the row, so the trunk of a fan reads as coming + * out of the type rather than out of the margin. */ -export const HIER_PORT_X = HIER_GLYPH_X + 8; +export const HIER_PORT_X = HIER_GLYPH_X + HIER_TILE / 2; /** * Subtypes drawn before the rest fold away. diff --git a/ui/src/lib/map-model.ts b/ui/src/lib/map-model.ts index 2249109..cfeddd1 100644 --- a/ui/src/lib/map-model.ts +++ b/ui/src/lib/map-model.ts @@ -45,9 +45,10 @@ import type { WireMapLink, WireMapModule, WireMapPayload } from './api'; // Geometry, from the design spec. Changing these changes the picture. -export const NODE_HEIGHT = 40; -export const LAYER_GAP = 74; -export const NODE_GAP = 34; +/** Direction D map node: 52 high, rows 96 apart (52 + 44), 28 between boxes (§8 D-05). */ +export const NODE_HEIGHT = 52; +export const LAYER_GAP = 44; +export const NODE_GAP = 28; export const PADDING = 44; /** Least horizontal room a layer gets per module, so a sparse row still spreads. */ const MIN_SLOT = 230; @@ -59,16 +60,16 @@ const MIN_SLOT = 230; export const PORT_PITCH = 12; const MIN_NODE_WIDTH = 110; /** - * IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate. + * JetBrains Mono's real advance for the D label (`mono-500`, 12px): 0.6 em, + * measured in Chrome with the bundled variable font on 2026-10-03 — 7.20px + * a character. (Upstream measured IBM Plex Mono at 13px: 7.81.) * - * The prototype drew labels as SVG text that spilled harmlessly past the - * rectangle, so 7.3 was close enough there. An HTML box clips instead, and at - * 7.3 a 27-character id like `src/resolution/(root files)` lost its last - * characters to an ellipsis — measured in the browser: 211px of text in 205px - * of box. Padding is the box's own 9px each side plus its 1px borders. + * The box clips, so an under-estimate costs a label its last characters to an + * ellipsis. Padding is the D-05 recipe: the label starts at 44 (tile 22 at 12, + * then 10), and the box ends 18 past the text. */ -const CHAR_WIDTH = 7.81; -const LABEL_PADDING = 22; +const CHAR_WIDTH = 7.2; +const LABEL_PADDING = 62; /** Links below this weight stay hidden until a module they touch is selected. */ export const MIN_WEIGHT = 4; @@ -87,9 +88,14 @@ export const MIN_WEIGHT_WITH_TESTS = 6; */ const DECLARED_BASIS_COVERAGE = 0.4; -/** Approximate advance of the 11px sans meta line, measured against Archivo. */ -const META_CHAR_WIDTH = 5.9; -const META_PADDING = 24; +/** + * Advance of the 11px meta line in Inter (`caption`), measured in Chrome on + * 2026-10-03: 4.96–5.45px a character across this repository's meta strings, + * lower-case-heavy ones widest. 5.5 keeps every one inside its box. (Upstream + * measured Archivo: 5.9.) Same left inset as the label. + */ +const META_CHAR_WIDTH = 5.5; +const META_PADDING = 62; /** * A box wide enough for BOTH of its lines. diff --git a/ui/src/lib/screens-model.ts b/ui/src/lib/screens-model.ts index b3aa0db..4db388b 100644 --- a/ui/src/lib/screens-model.ts +++ b/ui/src/lib/screens-model.ts @@ -57,7 +57,11 @@ const LEVEL_NEST = 0.26; /** Points a curve is sampled at for hit-testing; at 116px tall, under a pixel off. */ const HIT_SAMPLES = 24; -/** IBM Plex Mono at 10.5px advances ~6.3px per character; the pill adds 6px each side. */ +/** + * JetBrains Mono at 10.5px (`pill`) advances 6.30px per character — 0.6 em, + * measured in Chrome with the bundled font on 2026-10-03, the same as + * upstream's IBM Plex Mono figure. The pill adds 6px each side. + */ export const PILL_CHAR_WIDTH = 6.3; export const PILL_PADDING = 12; export const PILL_HEIGHT = 17; diff --git a/ui/src/lib/symbol-model.ts b/ui/src/lib/symbol-model.ts index de15087..8b048ac 100644 --- a/ui/src/lib/symbol-model.ts +++ b/ui/src/lib/symbol-model.ts @@ -471,8 +471,68 @@ export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel { }; } +/** + * The Called by rail narrowed to the rows a "Filter callers" query names + * (docs/design/viewer-d.md §8 D-02): a row stays when its name, qualified name + * or file contains the query, case-insensitively, and a file group with no row + * left goes. The headline `total` does not move — the filter narrows what is + * listed, not what the graph says calls this. + */ +export function filterCallerRail(model: CallerRailModel, query: string): CallerRailModel { + const needle = query.trim().toLowerCase(); + if (needle === '') return model; + const keep = (row: CallerRow): boolean => { + const node = row.relation.node; + return ( + node.name.toLowerCase().includes(needle) || + node.qualifiedName.toLowerCase().includes(needle) || + node.file.toLowerCase().includes(needle) + ); + }; + const testRows = model.tests.rows.filter(keep); + return { + ...model, + groups: model.groups + .map((group) => ({ ...group, rows: group.rows.filter(keep) })) + .filter((group) => group.rows.length > 0), + uncertain: model.uncertain.filter(keep), + tests: { + rows: testRows, + calls: testRows.reduce((sum, row) => sum + row.relation.edgeCount, 0), + files: [...new Set(testRows.map((row) => row.relation.node.file))].sort(), + }, + }; +} + /* ----------------------------------------------------------- connectors -- */ +/** §7 callee row: 44 high, and at least 8 between two rows pushed apart. */ +export const CALLEE_ROW_HEIGHT = 44; +export const CALLEE_ROW_GAP = 8; + +/** + * Where each callee row sits: centred on its call-site line, never above the + * row before it — `y = max(lineY − 22, previous + 52)` (§8 D-02). A row with no + * line to point at (`null`) stacks under its predecessor. Order beats exactness: + * a rail whose rows jump around relative to the body stops being a reading of + * the code, and the connector still runs to the line. + * + * @param centres the call-site line's vertical centre per row, in rail space + * @param start where the first row may begin (under the rail header) + */ +export function calleeRowTops(centres: ReadonlyArray, start: number): number[] { + const tops: number[] = []; + let y = start; + for (const centre of centres) { + const wanted = centre !== null ? centre - CALLEE_ROW_HEIGHT / 2 : y; + y = Math.max(wanted, y); + tops.push(y); + y += CALLEE_ROW_HEIGHT + CALLEE_ROW_GAP; + } + return tops; +} + + /** One hairline from a gutter port to a callee row. Geometry comes from the view. */ export interface Connector { /** SVG path data — a single cubic from the port to the row. */ diff --git a/ui/src/views/DeadCodeView.svelte b/ui/src/views/DeadCodeView.svelte index 1206cdd..c982acc 100644 --- a/ui/src/views/DeadCodeView.svelte +++ b/ui/src/views/DeadCodeView.svelte @@ -21,6 +21,7 @@ * says so in its own count line. See `lib/map-model.ts`. */ import KindGlyph from '../components/KindGlyph.svelte'; + import Icon from '../components/Icon.svelte'; import { fetchDeadCode, ApiFailure, type WireDeadCode, type WireDeadCodeRow } from '../lib/api'; import { deadHref, fileHref, navigate, symbolHref } from '../lib/navigation'; import { live } from '../lib/live.svelte'; @@ -70,46 +71,69 @@ let scale = $derived(deadCodeScale(payload)); let phrases = $derived(exclusionPhrases(payload)); + /** Size bars (§8 D-07): each row against the largest one listed, 140px wide. */ + let maxLines = $derived( + Math.max(1, ...(payload?.groups ?? []).flatMap((g) => g.rows.map((r) => r.lines))) + ); + let maxExcluded = $derived(Math.max(1, ...(payload?.excluded ?? []).map((e) => e.count))); + let listedShare = $derived( + payload && payload.candidates > 0 ? (100 * payload.rows.total) / payload.candidates : 0 + ); + function open(row: WireDeadCodeRow): void { navigate(symbolHref(row.id, { line: row.line })); } -
    +
    +
    -

    Dead code

    -

    - Symbols no import, call or reference in this index reaches, largest first. Everything below - is what the graph can see; the notes under the list are what it cannot. -

    + +
    +

    Dead code

    +

    symbols nothing in the index reaches · largest first · grouped by file

    +
    + +
    - + +

    {DEAD_CODE_CAVEAT}macros, trait objects and reflection leave no edge

    {#if failure}

    Could not read the list — {failure}

    {:else if loading && payload === null} -

    Reading the graph…

    +
    Reading the graph…
    {:else if payload} {#if exported} -

    - Exported symbols are on this list. Nothing in this repository references them, but anything +

    + Exported symbols are on this list. Nothing in this repository references them, but anything outside it can — a published package, another service, a script. Read each one before you - believe it. + believe it.

    {/if} @@ -121,12 +145,13 @@ {#each payload.groups as group (group.file)}
    + {group.file} {groupMeta(group)}
    {#each group.rows as row (row.id)}
    - +
    {row.file}:{row.line}{row.file.slice(row.file.lastIndexOf('/') + 1)}:{row.line} - {#if row.exported}exported{/if} + {#if row.exported}exported{/if}
    {deadCodeRowMeta(row)}
    {#if row.members.items.length > 0} @@ -155,6 +180,7 @@
    {/if}
    +
    {/each}
    @@ -162,190 +188,233 @@
    {/if} -
    + {#if payload.rows.truncated} +

    + Showing {payload.rows.shown} of {payload.rows.total} — the rest are in the index, not on + this list. +

    + {/if} + {/if} +
    + + +
    diff --git a/ui/src/views/EntryView.svelte b/ui/src/views/EntryView.svelte index 0eba6fe..40c4b9e 100644 --- a/ui/src/views/EntryView.svelte +++ b/ui/src/views/EntryView.svelte @@ -17,6 +17,7 @@ * at rest, the empty screen and this panel, so all three agree on the order. */ import EntrySection from '../components/entry/EntrySection.svelte'; + import Icon from '../components/Icon.svelte'; import SavedTrails from '../components/SavedTrails.svelte'; import { palette } from '../lib/palette.svelte'; import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model'; @@ -85,11 +86,12 @@
    -

    Entry points

    + +

    Saved trails and entry points

    Where a flow starts{project ? ` in ${project}` : ''} — every list below is read out of the graph, not guessed from a filename. Open a row to read the code, or use - Flow › to draw the path from it to a second symbol. + Flow to draw the path from it to a second symbol.

    @@ -112,11 +114,11 @@ /> armed && draw(armed.name, reaches)}>Draw the flow - + or pick the other end with → here
    {/if} @@ -131,7 +133,7 @@ {#if palette.entriesFailure}

    Could not read the entry points — {palette.entriesFailure}

    {:else if !palette.entriesSettled} -

    Reading the graph…

    +

    Reading the graph…

    {:else if panel.empty}

    {panel.empty}

    {:else} @@ -144,40 +146,58 @@
    diff --git a/ui/src/views/FileCodeView.svelte b/ui/src/views/FileCodeView.svelte index b5337a5..2de6d86 100644 --- a/ui/src/views/FileCodeView.svelte +++ b/ui/src/views/FileCodeView.svelte @@ -30,6 +30,8 @@ import FileCodeRail from '../components/file/FileCodeRail.svelte'; import FileModeTabs from '../components/file/FileModeTabs.svelte'; import KindGlyph from '../components/KindGlyph.svelte'; + import Icon from '../components/Icon.svelte'; + import ErrorCard from '../components/ErrorCard.svelte'; import DriftBanner from '../components/DriftBanner.svelte'; import { ApiFailure, @@ -425,22 +427,21 @@ {#if failure} -
    -
    -

    {failure.code === 'not-found' ? 'Not in the index' : 'Could not load this file'}

    -

    {path}

    -

    {failure.message}

    - {#if failure.guidance}

    {failure.guidance}

    {/if} -
    +
    +
    {:else if loading || !payload} -
    -

    Loading…

    +
    +
    Reading the file from the index…
    {:else}
    0}> {#if outlineRows.length > 0} -