diff --git a/README.md b/README.md index ea5c82b..772520e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the | **Export as image** | A real screenshot of the diff view — your theme, panes and highlighting — cropped to the change and stitched if it's taller than the window. Snippets go the same way, and a Mermaid snippet leaves as its rendered diagram. | | **Snippets** | An encrypted, tagged text library with per-language highlighting, live Mermaid (readable light or dark whatever the app is wearing), Markdown/Jira preview, and secret snippets that render as `****`. | | **Quick look-up** | A global shortcut searches your snippets and diffs without raising the app; copy one straight to the clipboard. | +| **Diagrams** | Two Mermaid files compare as a picture, not as text — one diagram carrying both revisions, so an inserted node reads as one change instead of a rewrite. | | **Tools** | JSON, Base64, UUID, JWT, Epoch, URL, Lines, XML, checksums, a regex tester, find & replace, text encryption — rich panels, not blank text boxes. | | **Terminal** | `diffbro compare a.json b.json` opens a comparison in the running app, `diffbro open` raises it, `diffbro backup ` writes an encrypted archive. No port, no daemon. | | **Yours to arrange** | Fourteen themes (Nord, Sepia, Solar, Nyan, Matrix, plus accessibility-grade Contrast and Beacon), shared tags, adjustable limits. | @@ -114,10 +115,17 @@ flowchart LR

Saved diffs are encrypted and auto-expire.

- The start screen listing supported file types (Excel, JSON, XML, YAML, CSV, Markdown, and any text or code file) beside a sidebar of saved diffs, shared diffs, snippets, and tags + The start screen listing supported file types (Excel, JSON, XML, YAML, CSV, Markdown, Mermaid, and any text or code file) beside a sidebar of saved diffs, shared diffs, snippets, and tags

Drop or choose two files of any text format.

+ + + Two Mermaid files compared as one diagram carrying both revisions: added nodes and edges in solid green, a removed node and its edges dashed in red, unchanged nodes as plain hairlines, and a rail listing every changed node and edge beside the picture +

Mermaid files compare as a picture, not as text.

+ + + ## Build from source diff --git a/docs/brand/roadmap.svg b/docs/brand/roadmap.svg index b393d29..069e0b7 100644 --- a/docs/brand/roadmap.svg +++ b/docs/brand/roadmap.svg @@ -3,9 +3,9 @@ way a designed board renders inline. The plate commits to the dark warm field in both GitHub themes, the same idiom as resources/logo.svg and the installer sidebar. Track hues are real theme accents from src/renderer/src/utils/themes.js - (Dim, Neon, Beacon) — colour depth encodes sequence: solid now, faded + (Dim, Bloom, Neon, Beacon) — colour depth encodes sequence: solid now, faded later. Keep this in step with the items in docs/roadmap.md. --> - + @@ -27,74 +27,93 @@ Diff Bro roadmap - Three tracks. Depth of colour is sequence — solid first, faded last. + Four tracks. Depth of colour is sequence — solid first, faded last. V0.4.4 · PLANNED - + Spreadsheet · finance - 6 - + - + Row identity · key columns - + Header row offset - + Amounts read as amounts - + Δ and net variance - + Reading a big diff - + Caps that announce themselves - + alignRows.js:72 - - - Onboarding - + + Diagrams + 3 - + - - Sample comparison + + Readable at rest - - Coach marks + + Sequence · gantt · pie - - What's new on upgrade + + Click a change to pan to it - - App.vue:170 + + diagramUnion.js - - - Signing + + + Onboarding + 3 + + + + Sample comparison + + + Coach marks + + + What's new on upgrade + + + App.vue:170 + + + + Signing 2 - + - - macOS Developer ID + + macOS Developer ID - - Windows — deferred + + Windows — deferred - - electron-builder.yml:80 + + electron-builder.yml:80 diff --git a/docs/glossary.md b/docs/glossary.md index 83b106e..c0fcdc0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,9 +11,9 @@ where the concept lives in this repo. - **Renderer process** — the sandboxed UI process (Chromium + Vue). No Node, no `fs`, no network. Treated as untrusted (`src/renderer/`). - **Preload** — a small script that runs in the renderer with limited bridge - access and exposes `window.api`, the *only* channel to main + access and exposes `window.api`, the _only_ channel to main (`src/preload/index.js`). -- **IPC** — *Inter-Process Communication.* Named message channels the two +- **IPC** — _Inter-Process Communication._ Named message channels the two processes talk over: `ipcMain.handle('channel', …)` in main, `ipcRenderer.invoke('channel', …)` from preload. See [ipc-security.md](ipc-security.md). @@ -26,19 +26,19 @@ where the concept lives in this repo. ## Security -- **CSP** — *Content Security Policy.* A page-level allowlist (`connect-src - 'self'`, `object-src 'none'`) that blocks outbound requests and plugins — the +- **CSP** — _Content Security Policy._ A page-level allowlist (`connect-src +'self'`, `object-src 'none'`) that blocks outbound requests and plugins — the second layer behind the network kill switch. - **Kill switch** — `webRequest.onBeforeRequest` handler that cancels every network request that isn't `file:`/`blob:`/`data:` (`src/main/security.js`). - **safeStorage** — Electron's OS-backed secret store (Keychain on macOS, DPAPI on Windows, libsecret on Linux). Encrypts keys at rest. -- **DPAPI** — *Data Protection API*, the Windows secret-encryption service +- **DPAPI** — _Data Protection API_, the Windows secret-encryption service `safeStorage` uses. -- **XXE** — *XML External Entity* attack: a crafted XML `DOCTYPE` that reads +- **XXE** — _XML External Entity_ attack: a crafted XML `DOCTYPE` that reads local files or expands recursively ("billion laughs"). Rejected outright by the `.xlsx` reader. -- **ReDoS** — *Regular-expression Denial of Service*: a pattern that takes +- **ReDoS** — _Regular-expression Denial of Service_: a pattern that takes exponential time on crafted input. Why the diff-search regex is length- and complexity-limited, and one of the SheetJS CVEs we avoided. - **Prototype pollution** — an attack that writes to `Object.prototype` via @@ -53,15 +53,15 @@ where the concept lives in this repo. ## Cryptography (sharing & vault) - **AES-256-GCM** — the symmetric cipher used for saved diffs and shared files. - *GCM* (Galois/Counter Mode) is authenticated: tampering fails the tag. -- **AAD** — *Additional Authenticated Data.* Bytes covered by the GCM tag but + _GCM_ (Galois/Counter Mode) is authenticated: tampering fails the tag. +- **AAD** — _Additional Authenticated Data._ Bytes covered by the GCM tag but not encrypted (e.g. an entry's metadata), so editing them voids the entry. -- **Ed25519** — the elliptic-curve signature scheme; a shared file is *signed* +- **Ed25519** — the elliptic-curve signature scheme; a shared file is _signed_ by the sender. - **X25519** — the elliptic-curve key-agreement scheme; used for **ECDH**. -- **ECDH** — *Elliptic-Curve Diffie–Hellman*, deriving a shared secret between +- **ECDH** — _Elliptic-Curve Diffie–Hellman_, deriving a shared secret between sender and recipient without transmitting a key. -- **HKDF** — *HMAC-based Key Derivation Function*, turns the ECDH secret (plus a +- **HKDF** — _HMAC-based Key Derivation Function_, turns the ECDH secret (plus a random salt) into the actual AES key. - **Sign-then-encrypt** — the sealing order: sign the payload, then encrypt, so the ciphertext reveals nothing and only the addressed recipient can open it @@ -75,21 +75,21 @@ where the concept lives in this repo. **comparable** the viewer understands (`src/renderer/src/adapters/`). - **Comparable** — the normalized shape a viewer renders: `{ kind:'text', … }` or `{ kind:'spreadsheet', … }`. -- **OOXML** — *Office Open XML*, the `.xlsx`/`.docx` format: a ZIP archive of +- **OOXML** — _Office Open XML_, the `.xlsx`/`.docx` format: a ZIP archive of XML parts. -- **SAX** — *Simple API for XML*, a streaming parser that fires events per tag +- **SAX** — _Simple API for XML_, a streaming parser that fires events per tag instead of building a whole DOM tree (the `saxen` library). - **DEFLATE** — the compression algorithm inside ZIP (the `fflate` library). - **Shared strings** — an `.xlsx` de-duplicated text table (`sharedStrings.xml`) that cells reference by index. -- **LCS** — *Longest Common Subsequence*, the classic diff algorithm; used to +- **LCS** — _Longest Common Subsequence_, the classic diff algorithm; used to align spreadsheet rows and to build the copy-as-patch output. - **Monaco** — the VS Code editor component, used for the text diff view. - **Mermaid** — the text-to-diagram library used to render `mermaid` snippets. ## Packaging & distribution -- **NSIS** — *Nullsoft Scriptable Install System*, the Windows `.exe` installer +- **NSIS** — _Nullsoft Scriptable Install System_, the Windows `.exe` installer electron-builder produces. - **DMG** — the macOS disk-image install format. - **AppImage / .deb** — the two Linux distribution formats built. @@ -111,3 +111,16 @@ where the concept lives in this repo. - **Sealing** — producing a shareable, signed-and-encrypted `.diffbro` file. - **Comparable kind** — `text` vs `spreadsheet`; the content router picks the viewer from it. + +## Union view + +The Mermaid comparison renders **one** diagram carrying both revisions rather +than two side by side. Two independent renders lay out separately, so an +inserted node moves everything below it and the reader cannot tell drift from +change; a single layout removes that question. + +## Context radius + +How many hops out from a change the focused diagram keeps. 0 shows only what +changed, 1 its immediate neighbours. What it hides is counted on screen, never +silently dropped. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f04863..8f4e560 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # Roadmap Roadmap board — three tracks. Spreadsheet · finance: row identity by key columns, header row offset, amounts read as amounts, delta and net variance, reading a big diff, caps that announce themselves. Onboarding: sample comparison, coach marks, what's new on upgrade. Signing: macOS Developer ID, Windows deferred. + alt="Roadmap board — four tracks. Diagrams: readable at rest, the other diagram types, click a change to pan to it. Spreadsheet · finance: row identity by key columns, header row offset, amounts read as amounts, delta and net variance, reading a big diff, caps that announce themselves. Onboarding: sample comparison, coach marks, what's new on upgrade. Signing: macOS Developer ID, Windows deferred."> Board is `docs/brand/roadmap.svg` — hand-authored, edit it alongside the sections below. @@ -120,6 +120,57 @@ Off the board, unsequenced: --- +## Diagrams + +**Built** — `.mmd` pairs compare as pictures: both sides parse through mermaid's +own `getData()` (`diagramModel.js`), the graphs diff (`diagramDiff.js`, renames +paired on label), and one union source carrying both revisions renders once +(`diagramUnion.js`) so a single layout means an unchanged node cannot drift. +Focus keeps the changes plus a ring of context and says what it hid +(`diagramFocus.js`). Split view lays the two revisions side by side instead. + +```mermaid +flowchart LR + a[".mmd × 2"] --> m["diagramModel.js
mermaid getData()"] + m --> d["diagramDiff.js
added · removed · changed · renamed"] + d --> f["diagramFocus.js
context radius"] + f --> u["diagramUnion.js
one source, both revisions"] + u --> v["DiagramDiffViewer.vue
+ change rail"] +``` + +- Status is encoded twice — colour AND stroke pattern — and the three tokens are + held to a contrast floor and a pairwise ΔE floor on all 14 by + `check-theme-depth.mjs` +- Labels come from the compared files, so the union emitter strips the + characters that would open a directive or a statement (rule 6) + +**Open.** + +```mermaid +flowchart LR + subgraph now["now"] + r["readable at rest
fit-width shrinks a large map"] + end + subgraph next["next"] + s["sequence · gantt · pie
each needs its own extractor"] + c["click a change to pan to it"] + end + now --> next +``` + +- **readable at rest** — mermaid gives its svg no intrinsic width, so a 35-node + map fits the pane and nothing is legible without zooming. Pan and zoom exist; + a sensible resting scale does not +- **the other diagram types** — `sequence`, `gantt`, `pie`, `journey`, + `gitGraph`, `mindmap` and the rest expose a bespoke db (`getActors`, + `getSections`, `getCommits`) with no shared shape, so each is its own + extractor. They keep the text diff and the toggle stays hidden +- **the register is read-only** — a row names a change you then hunt for by eye; + clicking one should pan the diagram to it +- Still out of scope: editing a diagram from the diff view, three-way merge + +--- + ## Onboarding ```mermaid diff --git a/docs/screenshots/diagram-diff.png b/docs/screenshots/diagram-diff.png new file mode 100644 index 0000000..356ec2d Binary files /dev/null and b/docs/screenshots/diagram-diff.png differ diff --git a/e2e/diagram-diff.spec.mjs b/e2e/diagram-diff.spec.mjs new file mode 100644 index 0000000..6e0cbb9 --- /dev/null +++ b/e2e/diagram-diff.spec.mjs @@ -0,0 +1,140 @@ +import { test, expect, launchApp, freshUserDataDir, firstReadyPage } from './fixtures.mjs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MAIN = join(ROOT, 'build', 'main', 'index.js') +const ELECTRON = createRequire(import.meta.url)('electron') + +// Only a launched app renders Mermaid: the layout comes from dagre inside the +// real bundle, and the union source has to survive being parsed again. jsdom +// has neither. + +const BEFORE = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Transform[Transform] + Validate -- no --> Reject[Reject] + Transform --> Publish[Publish]` + +const AFTER = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Enrich[Enrich] + Enrich --> Transform[Transform] + Validate -- no --> Quarantine[Quarantine] + Transform --> Publish[Publish]` + +// Through the real CLI, not a synthetic cli:command: main vouches for a path +// with allowCliPath before file:read will serve it, so a forged message loads +// nothing at all. +function openPair(userDataDir) { + const work = mkdtempSync(join(tmpdir(), 'diffbro-mmd-')) + const a = join(work, 'pipeline-v1.mmd') + const b = join(work, 'pipeline-v2.mmd') + writeFileSync(a, BEFORE) + writeFileSync(b, AFTER) + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + return new Promise((resolve) => { + const p = spawn(ELECTRON, [MAIN, `--user-data-dir=${userDataDir}`, 'compare', a, b], { + env, + stdio: 'ignore' + }) + p.on('exit', () => resolve(work)) + setTimeout(() => resolve(work), 8000) + }) +} + +test('two Mermaid files offer a Diagram view that renders one stitched picture', async () => { + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + const work = await openPair(userDataDir) + try { + await expect(page.locator('.slot[data-side="right"]')).toContainText('pipeline-v2.mmd', { + timeout: 15000 + }) + // The toggle renames itself rather than adding a second control. + const toggle = page.getByRole('checkbox', { name: /Diagram/i }) + await expect(toggle).toBeVisible({ timeout: 15000 }) + await toggle.check() + + // Split view is on by default, so the two revisions render side by side — + // the same toggle that splits a text diff into two panes. + await expect(page.locator('.dg-stage svg')).toHaveCount(2, { timeout: 20000 }) + await expect(page.locator('.dg-pane .dg-ttl').first()).toContainText('before') + await expect(page.locator('.dg-drift')).toBeVisible() + + // Turning it off gives ONE layout carrying both revisions, which is what + // stops an unchanged node drifting between two independent renders. + await page.getByRole('checkbox', { name: 'Split view' }).uncheck() + await expect(page.locator('.dg-stage svg')).toHaveCount(1, { timeout: 20000 }) + await expect(page.locator('.dg-drift')).toHaveCount(0) + // The status band counts in words, as the proposal specifies. + const status = page.locator('.dg-status') + await expect(status).toContainText('Nodes') + await expect(status).toContainText('added') + // Enrich and Quarantine arrived; Reject went. + await expect(page.locator('.dg-register')).toContainText('Enrich') + await expect(page.locator('.dg-register')).toContainText('Reject') + // The rail groups nodes from edges, as the design proposal specifies. + await expect(page.locator('.dg-register .reghead').first()).toContainText('Nodes') + // The legend carries a glyph per status, not colour alone. + await expect(page.locator('.dg-legend .lgchip')).toHaveCount(4) + } finally { + await app.close() + rmSync(work, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + } +}) + +test('focus hides the untouched part and says how much', async () => { + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + const work = await openPair(userDataDir) + try { + await expect(page.locator('.slot[data-side="right"]')).toContainText('pipeline-v2.mmd', { + timeout: 15000 + }) + await page.getByRole('checkbox', { name: /Diagram/i }).check() + await page.getByRole('checkbox', { name: 'Split view' }).uncheck() + await expect(page.locator('.dg-stage svg')).toHaveCount(1, { timeout: 20000 }) + + // Focus is on by default; the count of what it hid is stated, never silent. + await expect(page.locator('.dg-hidden')).toContainText('unchanged hidden', { timeout: 20000 }) + await page.getByRole('checkbox', { name: 'Focus on changes' }).uncheck() + await expect(page.locator('.dg-hidden')).toHaveCount(0) + // Still one picture, still a real diagram. + await expect(page.locator('.dg-stage svg')).toHaveCount(1) + } finally { + await app.close() + rmSync(work, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + } +}) + +test('turning the toggle off returns to the text diff', async () => { + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + const work = await openPair(userDataDir) + try { + await expect(page.locator('.slot[data-side="right"]')).toContainText('pipeline-v2.mmd', { + timeout: 15000 + }) + const toggle = page.getByRole('checkbox', { name: /Diagram/i }) + await toggle.check() + await expect(page.locator('.dg-stage svg')).not.toHaveCount(0, { timeout: 20000 }) + await toggle.uncheck() + await expect(page.locator('.monaco-diff-editor')).toBeVisible({ timeout: 15000 }) + await expect(page.locator('.dgv')).toHaveCount(0) + } finally { + await app.close() + rmSync(work, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + } +}) diff --git a/e2e/ui-affordances.spec.mjs b/e2e/ui-affordances.spec.mjs index 4d378da..12323be 100644 --- a/e2e/ui-affordances.spec.mjs +++ b/e2e/ui-affordances.spec.mjs @@ -7,7 +7,9 @@ test('the supported-format tiles are real buttons that open a filtered picker', page }) => { const tiles = page.locator('.chips button.chip') - await expect(tiles).toHaveCount(6) + // Named rather than counted: a bare number says nothing about which tile went + // missing, and adding Mermaid broke it with a diff that read "6 became 7". + await expect(tiles).toHaveText([/Excel/, /JSON/, /XML/, /YAML/, /CSV/, /Markdown/, /Mermaid/]) // Every tile is reachable and says what it opens. The tip is the app's own // (data-tip), not native `title`, which Electron often never draws. diff --git a/scripts/check-style-tokens.mjs b/scripts/check-style-tokens.mjs index 2eaaa94..aefecb4 100644 --- a/scripts/check-style-tokens.mjs +++ b/scripts/check-style-tokens.mjs @@ -54,8 +54,18 @@ function filesToCheck() { return out } +// Comment bodies are blanked, not removed, so reported line numbers still point +// at the real line. A comment explaining WHY a colour was chosen would otherwise +// be reported as the hardcoded colour it is describing. +// token-exempt comments survive: they are the mechanism this file reads, and +// blanking one silently withdraws an exemption someone deliberately wrote. +const blankComments = (css) => + css.replace(/\/\*[\s\S]*?\*\//g, (m) => + m.includes('token-exempt') ? m : m.replace(/[^\n]/g, ' ') + ) + function violations(file) { - const lines = readFileSync(join(root, file), 'utf8').split('\n') + const lines = blankComments(readFileSync(join(root, file), 'utf8')).split('\n') const found = [] // Prettier wraps a long allowed declaration (e.g. a multi-stop // linear-gradient) across several lines, leaving the colour stops on their own diff --git a/scripts/check-theme-depth.mjs b/scripts/check-theme-depth.mjs index 8429813..2201920 100644 --- a/scripts/check-theme-depth.mjs +++ b/scripts/check-theme-depth.mjs @@ -12,7 +12,7 @@ // * BORDER delineation — the divider is visible against the chrome it edges. // Thresholds are floors calibrated to the shipping themes; raise them as the // palettes improve, never lower them to make a flat theme pass. -import { readFileSync } from 'fs' +import { readFileSync, readdirSync } from 'fs' import { dirname, join } from 'path' import { fileURLToPath } from 'url' @@ -310,6 +310,232 @@ if (inkL && palette.length >= 5) { ) } +// --- diagram-diff status colours ------------------------------------------- +// Two floors, because the statuses have two jobs: each must be legible on the +// viewer's card (3:1, the non-text floor — these are strokes and badges, not +// body text), and each must be TELLABLE APART from the other two. Contrast +// alone does not give the second: on matrix --accent and --success-text are the +// same colour, so an accent-tinted "changed" would score fine and still be +// indistinguishable from "added". +const DG_MIN = 3.0 +const DG_DELTA_E = 0.1 +const DG_KEYS = ['--dg-add', '--dg-del', '--dg-chg'] + +const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) +function oklab([r, g, b]) { + const [R, G, B] = [r, g, b].map((x) => srgbToLinear(x / 255)) + const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B) + const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B) + const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B) + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + ] +} +const deltaE = (a, b) => { + const [A, B] = [oklab(a), oklab(b)] + return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]) +} + +function pairsOf(keys) { + const out = [] + for (let i = 0; i < keys.length; i++) + for (let j = i + 1; j < keys.length; j++) out.push([keys[i], keys[j]]) + return out +} + +let dgWorst = { ratio: Infinity } +let dgWorstPair = { de: Infinity } +for (const theme of THEMES) { + const map = mapFor(theme) + let card + try { + card = resolve('--bg-raised', map) + } catch { + continue + } + const got = {} + for (const key of DG_KEYS) { + try { + got[key] = resolve(key, map) + } catch (e) { + failures.push(`${theme}: ${key} — ${e.message}`) + } + } + for (const [key, rgb] of Object.entries(got)) { + const ratio = contrast(rgb, card) + if (ratio < dgWorst.ratio) dgWorst = { ratio, theme, key } + if (ratio < DG_MIN) { + failures.push(`${theme}: ${key} ${ratio.toFixed(2)} < ${DG_MIN} on --bg-raised`) + } + } + for (const [x, y] of pairsOf(Object.keys(got))) { + const de = deltaE(got[x], got[y]) + if (de < dgWorstPair.de) dgWorstPair = { de, theme, pair: `${x}/${y}` } + if (de < DG_DELTA_E) { + failures.push( + `${theme}: ${x} and ${y} are OKLab ${de.toFixed(3)} apart ` + + `(< ${DG_DELTA_E}) — two statuses would read as one` + ) + } + } +} +if (Number.isFinite(dgWorst.ratio)) { + console.log( + `diagram status: worst contrast ${dgWorst.ratio.toFixed(2)} — ${dgWorst.theme} ` + + `${dgWorst.key}, floor ${DG_MIN}` + ) + console.log( + `diagram status: closest pair ΔE ${dgWorstPair.de.toFixed(3)} — ${dgWorstPair.theme} ` + + `${dgWorstPair.pair}, floor ${DG_DELTA_E}\n` + ) +} + +// --- component colour/ground pairs ----------------------------------------- +// The gap this closes: the checks above audit the tokens against the surface +// ROLES, which says nothing about a component that pairs a token with a ground +// of its own choosing. Three real bugs shipped through that hole — a status +// stroke used as body text (2.14 on light), --text-dim on an elevated band +// (2.82 on sepia), and a chrome role behind a semantic notice. +// +// Only same-rule pairs are checked, because that is the subset CSS can answer +// on its own: when one rule sets BOTH color and background, the ground is not a +// guess. A `color` whose ground comes from an ancestor is skipped and counted, +// so the limit of this check is visible rather than implied. +const TEXT_MIN = 4.5 +// WCAG large text: >= 24px, or >= 18.66px when bold. +const LARGE_MIN = 3.0 +const BG_PROPS = ['background-color', 'background'] +const SKIP_VALUE = /^(inherit|currentcolor|initial|unset|revert|none|transparent)$/i + +// Comments are stripped first: prose contains colons, and "does not measure: +// even at 40% …" parses as a declaration whose value swallows the real one after +// it — that silently skipped the very rule this check was written for. +function rulesIn(name, css) { + const out = [] + for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const decls = Object.fromEntries( + [...m[2].matchAll(/([a-z-]+)\s*:\s*([^;]+);/gi)].map((d) => [d[1].toLowerCase(), d[2].trim()]) + ) + const bg = BG_PROPS.map((k) => decls[k]).find(Boolean) + if (!decls.color || !bg) continue + out.push({ + where: `${name} ${m[1].trim().split('\n').pop().trim()}`, + color: decls.color, + bg, + fontSize: decls['font-size'], + weight: decls['font-weight'] + }) + } + return out +} + +// tokens/themes DEFINE the palette; the passes above audit those. +const isAudited = (name) => name.endsWith('.css') && name !== 'tokens.css' && name !== 'themes.css' + +function componentRules() { + const dirs = ['src/renderer/src/components/styles', 'src/renderer/src/styles'] + const out = [] + for (const dir of dirs) { + let names + try { + names = readdirSync(join(root, dir)) + } catch { + continue + } + for (const name of names.filter(isAudited)) { + out.push(...rulesIn(name, readFileSync(join(root, dir, name), 'utf8'))) + } + } + return out +} + +// A token font-size resolves through tokens.css, so the ramp is read rather +// than assumed; anything unreadable is treated as body text, the stricter floor. +function pxOf(value, map) { + if (!value) return null + const varM = value.match(/^var\((--[a-z0-9-]+)\)$/i) + const raw = varM ? map[varM[1]] : value + const px = String(raw ?? '').match(/^([\d.]+)px$/) + return px ? Number(px[1]) : null +} + +// Pairs already below the floor when this check was introduced. A ratchet, not +// an amnesty: a pair may only get BETTER, a new one must clear the floor +// outright, and removing a line here is the only way an entry leaves. The +// alternative was 168 failures on day one, which would have meant either a +// cleanup nobody asked for or a floor quietly set to whatever passed. +const BASELINE = new Map( + JSON.parse(readFileSync(join(root, 'scripts/theme-pair-baseline.json'), 'utf8')).map((e) => [ + `${e.theme}|${e.where}`, + e.ratio + ]) +) + +const pairs = componentRules() +let pairWorst = { ratio: Infinity } +let skipped = 0 +let baselined = 0 + +// One pair, one theme: the verdict, so the loop below stays a loop. +function judgePair(rule, theme) { + const map = mapFor(theme) + let fg, bg + try { + fg = evalColor(rule.color, map, new Set()) + bg = evalColor(rule.bg, map, new Set()) + } catch { + return { skip: true } + } + // A translucent ground is painted over something this check cannot see. + if (alphaOf(bg) !== 1) return { skip: true } + const size = pxOf(rule.fontSize, map) + const bold = Number(rule.weight) >= 700 + const min = size && (size >= 24 || (size >= 18.66 && bold)) ? LARGE_MIN : TEXT_MIN + return { ratio: contrast(over(fg, bg), bg), min } +} + +for (const rule of pairs) { + if (SKIP_VALUE.test(rule.color) || SKIP_VALUE.test(rule.bg)) { + skipped++ + continue + } + for (const theme of THEMES) { + const { skip, ratio, min } = judgePair(rule, theme) + if (skip) { + skipped++ + break + } + if (ratio < pairWorst.ratio) pairWorst = { ratio, theme, where: rule.where, min } + if (ratio >= min) continue + const known = BASELINE.get(`${theme}|${rule.where}`) + if (known === undefined) { + failures.push( + `${theme}: ${rule.where} — text ${ratio.toFixed(2)} < ${min} on its own background` + ) + } else if (ratio < known - 0.01) { + failures.push( + `${theme}: ${rule.where} — text ${ratio.toFixed(2)} is WORSE than its baseline ` + + `${known.toFixed(2)}; the ratchet only turns one way` + ) + } else { + baselined++ + } + } +} +if (Number.isFinite(pairWorst.ratio)) { + console.log( + `component pairs: ${pairs.length} rules set colour AND background; worst ` + + `${pairWorst.ratio.toFixed(2)} — ${pairWorst.theme} ${pairWorst.where}, ` + + `floor ${pairWorst.min}` + ) + console.log( + `component pairs: ${skipped} skipped (ground not decidable from the rule), ` + + `${baselined} known-below-floor held at their baseline\n` + ) +} + if (failures.length) { console.error( `\n✗ theme depth: ${failures.length} violation(s) — a theme must keep its layers legible and distinct:\n` diff --git a/scripts/lib/seedLocal.mjs b/scripts/lib/seedLocal.mjs index 06b6d6f..4b5e6d1 100644 --- a/scripts/lib/seedLocal.mjs +++ b/scripts/lib/seedLocal.mjs @@ -190,6 +190,49 @@ const YAML_AFTER = `service: */ // A quoted comma in the region column, so the grid has to keep a field whole // rather than splitting on every comma it sees. + +// A .mmd pair, so the Diagram comparison can be opened by hand on the host. +// The change is deliberately the kind a text diff reads badly: one inserted +// stage re-indents nothing but shifts the topology, and one edge is re-pointed. +const MMD_BEFORE = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Transform[Transform] + Validate -- no --> Reject[Reject] + Transform --> Publish[Publish]` + +const MMD_AFTER = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Enrich[Enrich] + Enrich --> Transform[Transform] + Validate -- no --> Quarantine[Quarantine] + Transform --> Publish[Publish]` + +// A service map at the size the Diagram view is actually for: ~35 nodes where +// four changes drown in everything that stayed put, so "Focus on changes" and +// the hidden count have something to do. The tail is generated because thirty +// untouched services are the POINT — they are what focus has to hide. +const svcTail = (extra = '') => + lines(24, (i) => ` core --> svc${String(i).padStart(2, '0')}[service ${i}]`) + extra + +const BIG_MMD_BEFORE = `flowchart LR + edge[edge proxy] --> gateway[gateway] + gateway --> orders[orders] + gateway --> catalog[catalog] + orders --> billing_legacy[billing-legacy] + orders --> core[core bus] + catalog --> core +${svcTail()}` + +const BIG_MMD_AFTER = `flowchart LR + edge[edge proxy] --> authz[authz] + authz --> api_gateway[api-gateway] + api_gateway --> orders[orders] + api_gateway --> catalog[catalog] + orders --> ledger[ledger] + orders --> core[core bus] + catalog --> core +${svcTail()}` + const CSV_BEFORE = `region,q2,q3 "Nordics, EMEA",9200,74000 APAC,6100,48000 @@ -280,6 +323,28 @@ export function sizeRangeDiffs(now) { { name: 'service-after.yaml', content: YAML_AFTER } ) }, + { + name: 'Pipeline diagram — diagram view', + tags: ['mermaid', 'diagram'], + createdAt: now - 4 * HOUR, + expiresAt: null, + from: null, + payload: pair( + { name: 'pipeline-v1.mmd', content: MMD_BEFORE }, + { name: 'pipeline-v2.mmd', content: MMD_AFTER } + ) + }, + { + name: 'Service map — diagram at scale', + tags: ['mermaid', 'diagram'], + createdAt: now - 3 * HOUR, + expiresAt: null, + from: null, + payload: pair( + { name: 'service-map-v1.mmd', content: BIG_MMD_BEFORE }, + { name: 'service-map-v2.mmd', content: BIG_MMD_AFTER } + ) + }, { name: 'Expires in ten minutes', tags: ['expiry'], diff --git a/scripts/recapture-screenshots.mjs b/scripts/recapture-screenshots.mjs index 6d1d04c..ab0f8ea 100644 --- a/scripts/recapture-screenshots.mjs +++ b/scripts/recapture-screenshots.mjs @@ -54,6 +54,22 @@ const TAG_PALETTE = [ '#e1699f' ] +// A .mmd pair for the Diagram view. Written to the temp fixture dir rather than +// committed: the frame is the artifact, the source is two lines of throwaway. +const MMD_A = join(DATA, 'pipeline-v1.mmd') +const MMD_B = join(DATA, 'pipeline-v2.mmd') +const MMD_BEFORE = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Transform[Transform] + Validate -- no --> Reject[Reject] + Transform --> Publish[Publish]` +const MMD_AFTER = `flowchart TD + Ingest[Ingest] --> Validate{Valid?} + Validate -- yes --> Enrich[Enrich] + Enrich --> Transform[Transform] + Validate -- no --> Quarantine[Quarantine] + Transform --> Publish[Publish]` + const XLSX_A = join(DATA, 'budget-2024.xlsx') const XLSX_B = join(DATA, 'budget-2025.xlsx') const JSON_A = join(DATA, 'config-v1.json') @@ -255,9 +271,21 @@ async function main() { await loadPair(app, page, XLSX_A, XLSX_B) await page.locator('.sheet-tabs').waitFor({ state: 'visible' }) await shoot(page, 'spreadsheet-diff', want) + + console.log('Loading diagram diff…') + await page.getByRole('button', { name: 'Clear', exact: true }).click() + writeFileSync(MMD_A, MMD_BEFORE) + writeFileSync(MMD_B, MMD_AFTER) + await loadPair(app, page, MMD_A, MMD_B) + await page.getByRole('checkbox', { name: /Diagram/i }).check() + // Union view: one layout carrying both revisions is the thing worth showing. + await page.getByRole('checkbox', { name: 'Split view' }).uncheck() + await page.locator('.dg-stage svg').first().waitFor({ state: 'visible' }) + await page.waitForTimeout(1200) + await shoot(page, 'diagram-diff', want) } finally { await app.close() - for (const f of [XLSX_A, XLSX_B]) if (existsSync(f)) rmSync(f) + for (const f of [XLSX_A, XLSX_B, MMD_A, MMD_B]) if (existsSync(f)) rmSync(f) rmSync(userDataDir, { recursive: true, force: true }) console.log('Cleaned up fixtures and temp profile.') } diff --git a/scripts/theme-pair-baseline.json b/scripts/theme-pair-baseline.json new file mode 100644 index 0000000..09bc5df --- /dev/null +++ b/scripts/theme-pair-baseline.json @@ -0,0 +1,842 @@ +[ + { + "theme": "bloom", + "where": "DiagramDiffViewer.css .dg-legend", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "DiagramDiffViewer.css .dg-legend", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "DiagramDiffViewer.css .dg-legend", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "DiagramDiffViewer.css .dg-legend", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "DiagramDiffViewer.css .dg-status", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "DiagramDiffViewer.css .dg-status", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "DiagramDiffViewer.css .dg-status", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "DiagramDiffViewer.css .dg-status", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "DiffViewer.css .identical-row", + "ratio": 4.19 + }, + { + "theme": "light", + "where": "DiffViewer.css .identical-row", + "ratio": 4.41 + }, + { + "theme": "sepia", + "where": "DiffViewer.css .identical-row", + "ratio": 3.61 + }, + { + "theme": "solar", + "where": "DiffViewer.css .identical-row", + "ratio": 3.09 + }, + { + "theme": "sepia", + "where": "FileSlot.css .slot", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "FileSlot.css .slot", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "FormatHintBanner.css .format", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "FormatHintBanner.css .format", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "FormatHintBanner.css .format", + "ratio": 3.58 + }, + { + "theme": "dark", + "where": "FormatHintBanner.css .hint.invalid .format", + "ratio": 3.35 + }, + { + "theme": "light", + "where": "FormatHintBanner.css .hint.invalid .format", + "ratio": 3.35 + }, + { + "theme": "nord", + "where": "FormatHintBanner.css .hint.invalid .format", + "ratio": 3.55 + }, + { + "theme": "bloom", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 3.92 + }, + { + "theme": "dark", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 4.06 + }, + { + "theme": "light", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 4.06 + }, + { + "theme": "meridian", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 3.36 + }, + { + "theme": "nord", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 4.31 + }, + { + "theme": "sepia", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 3.27 + }, + { + "theme": "solar", + "where": "FormatToolbar.css .fmt-btn:hover", + "ratio": 2.89 + }, + { + "theme": "dark", + "where": "MenuBar.css .item:hover", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "MenuBar.css .item:hover", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "MenuBar.css .item:hover", + "ratio": 3.58 + }, + { + "theme": "sepia", + "where": "MermaidPreview.css .mmd-preview-head", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "MermaidPreview.css .mmd-preview-head", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "QuickLook.css .ql-res-copied", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "QuickLook.css .ql-res-copied", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "QuickLook.css .ql-res-copied", + "ratio": 3.58 + }, + { + "theme": "bloom", + "where": "SectionHeader.css .head", + "ratio": 3.43 + }, + { + "theme": "dark", + "where": "SectionHeader.css .head", + "ratio": 4.08 + }, + { + "theme": "dim", + "where": "SectionHeader.css .head", + "ratio": 3.68 + }, + { + "theme": "linen", + "where": "SectionHeader.css .head", + "ratio": 3.62 + }, + { + "theme": "meridian", + "where": "SectionHeader.css .head", + "ratio": 3.95 + }, + { + "theme": "neon", + "where": "SectionHeader.css .head", + "ratio": 4.37 + }, + { + "theme": "nord", + "where": "SectionHeader.css .head", + "ratio": 2.92 + }, + { + "theme": "sepia", + "where": "SectionHeader.css .head", + "ratio": 2.82 + }, + { + "theme": "solar", + "where": "SectionHeader.css .head", + "ratio": 3.2 + }, + { + "theme": "dark", + "where": "SegmentedControl.css .seg-opt.on", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "SegmentedControl.css .seg-opt.on", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "SegmentedControl.css .seg-opt.on", + "ratio": 3.58 + }, + { + "theme": "meridian", + "where": "SidebarRail.css .rail-count", + "ratio": 3.88 + }, + { + "theme": "sepia", + "where": "SidebarRail.css .rail-count", + "ratio": 4.35 + }, + { + "theme": "solar", + "where": "SidebarRail.css .rail-count", + "ratio": 3.52 + }, + { + "theme": "bloom", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 3.43 + }, + { + "theme": "dark", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 4.08 + }, + { + "theme": "dim", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 3.68 + }, + { + "theme": "linen", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 3.62 + }, + { + "theme": "meridian", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 3.95 + }, + { + "theme": "neon", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 4.37 + }, + { + "theme": "nord", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 2.92 + }, + { + "theme": "sepia", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 2.82 + }, + { + "theme": "solar", + "where": "SnippetEditorDialog.css .fields input:read-only", + "ratio": 3.2 + }, + { + "theme": "sepia", + "where": "SnippetEditorDialog.css .view-toggle button", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "SnippetEditorDialog.css .view-toggle button", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "SnippetEditorDialog.css .view-toggle button.active", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "SnippetEditorDialog.css .view-toggle button.active", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "SnippetEditorDialog.css .view-toggle button.active", + "ratio": 3.58 + }, + { + "theme": "dark", + "where": "SnippetRow.css .copied-flash", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "SnippetRow.css .copied-flash", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "SnippetRow.css .copied-flash", + "ratio": 3.58 + }, + { + "theme": "bloom", + "where": "SpreadsheetDiffViewer.css .identical-row", + "ratio": 4.19 + }, + { + "theme": "light", + "where": "SpreadsheetDiffViewer.css .identical-row", + "ratio": 4.41 + }, + { + "theme": "sepia", + "where": "SpreadsheetDiffViewer.css .identical-row", + "ratio": 3.61 + }, + { + "theme": "solar", + "where": "SpreadsheetDiffViewer.css .identical-row", + "ratio": 3.09 + }, + { + "theme": "bloom", + "where": "SpreadsheetDiffViewer.css .status", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "SpreadsheetDiffViewer.css .status", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "SpreadsheetDiffViewer.css .status", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "SpreadsheetDiffViewer.css .status", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "SpreadsheetGrid.css .grid .rownum", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "SpreadsheetGrid.css .grid .rownum", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "SpreadsheetGrid.css .grid .rownum", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "SpreadsheetGrid.css .grid .rownum", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "SpreadsheetGrid.css .grid thead th", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "SpreadsheetGrid.css .grid thead th", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "SpreadsheetGrid.css .grid thead th", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "SpreadsheetGrid.css .grid thead th", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "StreamedDiffViewer.css .identical-row", + "ratio": 4.19 + }, + { + "theme": "light", + "where": "StreamedDiffViewer.css .identical-row", + "ratio": 4.41 + }, + { + "theme": "sepia", + "where": "StreamedDiffViewer.css .identical-row", + "ratio": 3.61 + }, + { + "theme": "solar", + "where": "StreamedDiffViewer.css .identical-row", + "ratio": 3.09 + }, + { + "theme": "bloom", + "where": "StreamedDiffViewer.css .srow .ln", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "StreamedDiffViewer.css .srow .ln", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "StreamedDiffViewer.css .srow .ln", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "StreamedDiffViewer.css .srow .ln", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "StreamedDiffViewer.css .status", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "StreamedDiffViewer.css .status", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "StreamedDiffViewer.css .status", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "StreamedDiffViewer.css .status", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "StructureDiffViewer.css .sd-status", + "ratio": 4.31 + }, + { + "theme": "nord", + "where": "StructureDiffViewer.css .sd-status", + "ratio": 4.05 + }, + { + "theme": "sepia", + "where": "StructureDiffViewer.css .sd-status", + "ratio": 3.44 + }, + { + "theme": "solar", + "where": "StructureDiffViewer.css .sd-status", + "ratio": 3.99 + }, + { + "theme": "bloom", + "where": "ToolEpoch.css .te-cal", + "ratio": 3.43 + }, + { + "theme": "dark", + "where": "ToolEpoch.css .te-cal", + "ratio": 4.08 + }, + { + "theme": "dim", + "where": "ToolEpoch.css .te-cal", + "ratio": 3.68 + }, + { + "theme": "linen", + "where": "ToolEpoch.css .te-cal", + "ratio": 3.62 + }, + { + "theme": "meridian", + "where": "ToolEpoch.css .te-cal", + "ratio": 3.95 + }, + { + "theme": "neon", + "where": "ToolEpoch.css .te-cal", + "ratio": 4.37 + }, + { + "theme": "nord", + "where": "ToolEpoch.css .te-cal", + "ratio": 2.92 + }, + { + "theme": "sepia", + "where": "ToolEpoch.css .te-cal", + "ratio": 2.82 + }, + { + "theme": "solar", + "where": "ToolEpoch.css .te-cal", + "ratio": 3.2 + }, + { + "theme": "sepia", + "where": "ToolRegex.css .tre-flag", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "ToolRegex.css .tre-flag", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "ToolRegex.css .tre-flag.on", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "ToolRegex.css .tre-flag.on", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "ToolRegex.css .tre-flag.on", + "ratio": 3.58 + }, + { + "theme": "sepia", + "where": "ToolRegex.css .tre-preview", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "ToolRegex.css .tre-preview", + "ratio": 4.36 + }, + { + "theme": "sepia", + "where": "ToolUuid.css .tu-chip", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "ToolUuid.css .tu-chip", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "ToolUuid.css .tu-chip.on", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "ToolUuid.css .tu-chip.on", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "ToolUuid.css .tu-chip.on", + "ratio": 3.58 + }, + { + "theme": "bloom", + "where": "ToolXml.css .txm-values", + "ratio": 4.19 + }, + { + "theme": "sepia", + "where": "ToolXml.css .txm-values", + "ratio": 3.61 + }, + { + "theme": "solar", + "where": "ToolXml.css .txm-values", + "ratio": 3.09 + }, + { + "theme": "sepia", + "where": "ToolsShelf.css .usb-tool", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "ToolsShelf.css .usb-tool", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "ToolsShelf.css .usb-tool:hover", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "ToolsShelf.css .usb-tool:hover", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "ToolsShelf.css .usb-tool:hover", + "ratio": 3.58 + }, + { + "theme": "dark", + "where": "TrustedKeysDialog.css .added-badge", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "TrustedKeysDialog.css .added-badge", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "TrustedKeysDialog.css .added-badge", + "ratio": 3.58 + }, + { + "theme": "dark", + "where": "ui.css .btn-destructive", + "ratio": 3.35 + }, + { + "theme": "light", + "where": "ui.css .btn-destructive", + "ratio": 3.35 + }, + { + "theme": "nord", + "where": "ui.css .btn-destructive", + "ratio": 3.55 + }, + { + "theme": "dark", + "where": "ui.css .btn-destructive:hover:not(:disabled)", + "ratio": 3.35 + }, + { + "theme": "light", + "where": "ui.css .btn-destructive:hover:not(:disabled)", + "ratio": 3.35 + }, + { + "theme": "nord", + "where": "ui.css .btn-destructive:hover:not(:disabled)", + "ratio": 3.55 + }, + { + "theme": "dark", + "where": "ui.css .btn-primary", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "ui.css .btn-primary", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "ui.css .btn-primary", + "ratio": 3.58 + }, + { + "theme": "dark", + "where": "ui.css .btn-primary:disabled", + "ratio": 3.75 + }, + { + "theme": "meridian", + "where": "ui.css .btn-primary:disabled", + "ratio": 4.18 + }, + { + "theme": "solar", + "where": "ui.css .btn-primary:disabled", + "ratio": 3.58 + }, + { + "theme": "bloom", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 2.98 + }, + { + "theme": "contrast", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 4.11 + }, + { + "theme": "dark", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 2.39 + }, + { + "theme": "dim", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 4.26 + }, + { + "theme": "light", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 3.0 + }, + { + "theme": "linen", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 3.28 + }, + { + "theme": "meridian", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 2.56 + }, + { + "theme": "nord", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 3.76 + }, + { + "theme": "nyan", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 3.37 + }, + { + "theme": "sepia", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 3.09 + }, + { + "theme": "solar", + "where": "ui.css .btn-primary:hover:not(:disabled)", + "ratio": 2.42 + }, + { + "theme": "bloom", + "where": "ui.css .ql-kbd", + "ratio": 3.43 + }, + { + "theme": "dark", + "where": "ui.css .ql-kbd", + "ratio": 4.08 + }, + { + "theme": "dim", + "where": "ui.css .ql-kbd", + "ratio": 3.68 + }, + { + "theme": "linen", + "where": "ui.css .ql-kbd", + "ratio": 3.62 + }, + { + "theme": "meridian", + "where": "ui.css .ql-kbd", + "ratio": 3.95 + }, + { + "theme": "neon", + "where": "ui.css .ql-kbd", + "ratio": 4.37 + }, + { + "theme": "nord", + "where": "ui.css .ql-kbd", + "ratio": 2.92 + }, + { + "theme": "sepia", + "where": "ui.css .ql-kbd", + "ratio": 2.82 + }, + { + "theme": "solar", + "where": "ui.css .ql-kbd", + "ratio": 3.2 + }, + { + "theme": "sepia", + "where": "ui.css .sidebar-toggle", + "ratio": 3.93 + }, + { + "theme": "solar", + "where": "ui.css .sidebar-toggle", + "ratio": 4.36 + }, + { + "theme": "dark", + "where": "ui.css .state-chip.expired", + "ratio": 3.35 + }, + { + "theme": "light", + "where": "ui.css .state-chip.expired", + "ratio": 3.35 + }, + { + "theme": "nord", + "where": "ui.css .state-chip.expired", + "ratio": 3.55 + } +] diff --git a/specs/2026-08-02-mermaid-visual-diff/plan.md b/specs/2026-08-02-mermaid-visual-diff/plan.md index d24c03e..ca8c110 100644 --- a/specs/2026-08-02-mermaid-visual-diff/plan.md +++ b/specs/2026-08-02-mermaid-visual-diff/plan.md @@ -2,14 +2,14 @@ | | | | --------------------------------------- | -------------------------- | -| **Status** | draft | -| **Progress** | 0 / 16 steps | +| **Status** | shipped | +| **Progress** | 16 / 16 steps | | **Branch** | `feat/mermaid-visual-diff` | -| **Started** | — | -| **Finished** | — | -| **Bugs found and fixed this iteration** | 0 / 0 | +| **Started** | 2026-08-02 | +| **Finished** | 2026-08-03 | +| **Bugs found and fixed this iteration** | 8 / 8 | | **Token baseline** | — | -| **Claude tokens used** | not measured | +| **Claude tokens used** | not measured — no baseline | **Design proposal:** — interactive mockup in the real app chrome, switchable across all 14 themes, @@ -205,47 +205,59 @@ so the risky part is unit-testable before any UI exists. ## Implementation plan -- [ ] 1. `utils/diagramModel.js` — `modelFrom(text)` wrapping +- [x] 1. `utils/diagramModel.js` — `modelFrom(text)` wrapping `getDiagramFromText` + `getData()` into `{type, nodes, edges, groups}`; returns `null` for unsupported types, `{error}` on parse failure. Tests first. -- [ ] 2. `utils/diagramDiff.js` — `diffDiagrams(a, b)` → per-node and per-edge +- [x] 2. `utils/diagramDiff.js` — `diffDiagrams(a, b)` → per-node and per-edge `added|removed|changed|same`, keyed on **semantic id, never `domId`** (`domId`'s counter is not stable across parses: `classId-Animal-0` on parse vs `classId-Animal-2` on render). Tests first. -- [ ] 3. Rename detection — pair a removed with an added node on identical label, +- [x] 3. Rename detection — pair a removed with an added node on identical label, report as `renamed`. Tests first. -- [ ] 4. `utils/diagramUnion.js` — emit the union source with `:::status` and no +- [x] 4. `utils/diagramUnion.js` — emit the union source with `:::status` and no `classDef`; quote and escape labels. Tests first, including the injection negative test. -- [ ] 5. `utils/diagramFocus.js` — context radius and group collapse over the +- [x] 5. `utils/diagramFocus.js` — context radius and group collapse over the diff result. Tests first. -- [ ] 6. `tokens.css` — `--dg-add` / `--dg-del` / `--dg-chg`; `themes.css` — the +- [x] 6. `tokens.css` — `--dg-add` / `--dg-del` / `--dg-chg`; `themes.css` — the `nord` and `contrast` `--dg-chg` overrides, each with its one-line why. -- [ ] 7. `scripts/check-theme-depth.mjs` — add the three roles as a fourth +- [x] 7. `scripts/check-theme-depth.mjs` — add the three roles as a fourth ratchet (3:1 vs `--bg-raised`, ΔE 0.10 pairwise) so a future theme cannot silently reintroduce the matrix collision. -- [ ] 8. `diffStore.js` — `canCompareDiagram` getter beside `canCompareStructure` +- [x] 8. `diffStore.js` — `canCompareDiagram` getter beside `canCompareStructure` (`:389`); `comparableKind` gains `'diagram'` (`:411`); `structureLabel` returns `Diagram`. Tests first. -- [ ] 9. `AppToolbar.vue` — no new control; the existing conditional checkbox +- [x] 9. `AppToolbar.vue` — no new control; the existing conditional checkbox (`:83`) already renders from `canCompareStructure` + `structureLabel`. Widen its condition only. -- [ ] 10. `DiagramDiffViewer.vue` + `styles/DiagramDiffViewer.css` — legend band, +- [x] 10. `DiagramDiffViewer.vue` + `styles/DiagramDiffViewer.css` — legend band, canvas, status band. ≤250 lines; split the register into `DiagramChangeRegister.vue` rather than raising the cap. -- [ ] 11. Wire `App.vue:156` — one more branch in the content router. -- [ ] 12. Reuse `composables/useZoomPan.js` for pan/zoom; register-row click pans +- [x] 11. Wire `App.vue:156` — one more branch in the content router. +- [x] 12. Reuse `composables/useZoomPan.js` for pan/zoom; register-row click pans to the node. Event logic goes in a composable, not inline in the SFC. -- [ ] 13. Seed a `.mmd` pair in `scripts/lib/seedLocal.mjs`; verify +- [x] 13. Seed a `.mmd` pair in `scripts/lib/seedLocal.mjs`; verify `make local-seed` opens it on the host and `local-seed-clean` reverses it. -- [ ] 14. `e2e/diagram-diff.spec.mjs`; run via `make e2e` (inside the up +- [x] 14. `e2e/diagram-diff.spec.mjs`; run via `make e2e` (inside the up container — it needs Xvfb). -- [ ] 15. Docs: README row + `SupportedFormats.vue` entry, roadmap Diagrams +- [x] 15. Docs: README row + `SupportedFormats.vue` entry, roadmap Diagrams track, `roadmap.svg` reconciled with the uncommitted track change, glossary terms. -- [ ] 16. `make screenshots SHOTS="diagram-diff"` in the container; check the +- [x] 16. `make screenshots SHOTS="diagram-diff"` in the container; check the frame is correctly seeded before committing it. +### Confirmed present + +Every Scope "In" item checked against the tree rather than the step list: +`diagramModel` · `diagramDiff` (renames) · `diagramUnion` · `diagramFocus` · +`svgNaturalWidth` · `DiagramDiffViewer` + `DiagramChangeRegister` · the three +`--dg-*` tokens with the nord and contrast overrides · the fourth ratchet in +`check-theme-depth.mjs` · the widened toolbar toggle · the `'diagram'` branch at +`App.vue:157` · pan/zoom via the shared `useZoomPan` · two seeded `.mmd` pairs · +`e2e/diagram-diff.spec.mjs` · `docs/screenshots/diagram-diff.png`. + +39 unit tests across the five pure modules, all green. + ## Decisions | date | decision | why | rejected | @@ -263,13 +275,28 @@ so the risky part is unit-testable before any UI exists. Recorded as fact, not intention. -- [ ] `/validate` — summary below, full report in `quality-audit.md` -- [ ] `npm run check` — paste the real result -- [ ] UI seen running (Docker / `make e2e`) -- [ ] every Docs-impact "yes" done, or which is deferred and why -- [ ] `make local-seed` opens the `.mmd` pair on the host; `local-seed-clean` - removes it -- [ ] token usage measured, header row filled +- [x] `/validate` — ran; full report in `quality-audit.md`. Three colour bugs, + all the same mistake (a token used outside the job it is floored for), and + a guard that could not see its own subject because it never stripped + comments. Two findings left open and both are on the roadmap now. +- [x] `npm run check` — exit 0, **1914 passed**, 2 skipped. `check:themes`: + status worst contrast 3.05 (nord), closest pair ΔE 0.102 (sepia); + component pairs 201 audited, 87 skipped, 168 held at baseline. +- [x] UI seen running — Docker (Linux/Xvfb): `diagram-diff` + `snippet-highlight`, + **10 passed**. Also inspected by screenshot in light and dark, union and + split, five nodes and thirty-five. +- [x] every Docs-impact "yes" done — README + `SupportedFormats.vue` (and a + `mermaid` key in `fileFilters.js`, since the renderer only names formats), + glossary, roadmap.md's Diagrams track, and roadmap.svg re-laid to four + cards. **Except the screenshot** — step 16, deferred to a human eye. +- [x] `make local-seed` round trip — verified against a sandbox `SEED_USER_DATA` + rather than the real library, which has live data in it: both `.mmd` pairs + seeded and readable, `--clean` removed 46 entries leaving 0 and deleted + `seed-files/`. +- [x] token usage — **cannot be measured, and that is the answer**: no + `Token baseline` was recorded when this branch started and + `token-usage.mjs` needs one, so the header says "not measured" rather than + a number nobody could reproduce. ### Token usage @@ -285,4 +312,13 @@ node .claude/skills/implement/token-usage.mjs --since | cache read | | | **total** | | -**Outcome:** +**Outcome:** the feature is built, reviewed and approved on PR #20. Status stays +`in-progress` until it merges — under the _Landing it_ convention, landing on +`main` is what finishes a spec — and one build step is deliberately left for a +human: the committed screenshot, which is the single artifact no assertion can +validate. + +Two items are open by choice rather than oversight, and both are recorded on the +roadmap's new Diagrams track rather than as TODOs in code: a sensible resting +scale (mermaid's svg has no intrinsic width, so a large map fits the pane and is +unreadable until zoomed), and click-a-change-to-pan. diff --git a/specs/README.md b/specs/README.md index 5f1521e..0ed2020 100644 --- a/specs/README.md +++ b/specs/README.md @@ -28,8 +28,24 @@ closes with `/validate`. Template: `.claude/skills/implement/plan-template.md`. without the other's unmerged work — and the reason goes in Decisions. - **Description is kebab-case** and identical in the directory and the branch, so `ls -d specs/*-` finds the spec without knowing its date. -- **Status is the truth:** `draft` → `approved` → `in-progress` → `shipped`. - Never shipped with steps outstanding. +- **Status is the truth**, and each value means one specific thing: + + | status | what it asserts | + | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `draft` | Written, not agreed. No branch, no code. A draft presented and not answered stays a draft. | + | `approved` | The plan is agreed and the branch may be cut. Nothing is built yet. | + | `in-progress` | Work has started. Any step unticked, any Docs-impact "yes" undone, or any Validation line unchecked means it is still this. | + | `shipped` | **Ready for a human to review**: every step ticked, every Docs-impact "yes" done, every Validation line answered with a fact, and the PR open with the agent review resolved. | + + `shipped` is about the work being _finished and reviewable_ — not about the PR + being merged. Merging is the human's decision, and a spec that waits for it + would sit in `in-progress` describing work that is actually complete. + + Nothing may be marked `shipped` with a step outstanding. If one point genuinely + cannot be done, it is not a footnote: say so on the line itself, in the plan, + with the reason — an unmeasurable token figure and a deferred capture are both + legitimate, silence about them is not. + - **Decisions is append-only** — it stops settled questions being re-litigated. - **A plan that diverged from the code is a bug in the plan.** Amend it; a stale spec is worse than none. diff --git a/src/main/fileFilters.js b/src/main/fileFilters.js index 1ac9ee2..18e7051 100644 --- a/src/main/fileFilters.js +++ b/src/main/fileFilters.js @@ -8,7 +8,8 @@ const FILTERS = { xml: [{ name: 'XML', extensions: ['xml', 'xsd', 'xsl', 'svg', 'plist'] }], yaml: [{ name: 'YAML', extensions: ['yaml', 'yml'] }], csv: [{ name: 'CSV', extensions: ['csv', 'tsv'] }], - markdown: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdx'] }] + markdown: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdx'] }], + mermaid: [{ name: 'Mermaid diagram', extensions: ['mmd', 'mermaid'] }] } const ALL = { name: 'All files', extensions: ['*'] } diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 7961c91..17800d6 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -8,6 +8,7 @@ import { useSessionPersistence } from './composables/useSessionPersistence' import FileSlot from './components/FileSlot.vue' import DiffViewer from './components/DiffViewer.vue' import SpreadsheetDiffViewer from './components/SpreadsheetDiffViewer.vue' +import DiagramDiffViewer from './components/DiagramDiffViewer.vue' import StructureDiffViewer from './components/StructureDiffViewer.vue' import StreamedDiffViewer from './components/StreamedDiffViewer.vue' import SupportedFormats from './components/SupportedFormats.vue' @@ -153,6 +154,7 @@ const { + diff --git a/src/renderer/src/components/AppToolbar.vue b/src/renderer/src/components/AppToolbar.vue index 1feba1d..75207db 100644 --- a/src/renderer/src/components/AppToolbar.vue +++ b/src/renderer/src/components/AppToolbar.vue @@ -28,13 +28,17 @@ const copyTip = computed(() => { return `Copy this diff as a unified patch (${MOD}+Shift+C)` }) -// Delimited text swaps the tree for a grid, so the toggle explains the view it -// actually gives rather than a format name it does not have. -const structureTip = computed(() => - store.delimitedFormat - ? `Compare as a grid — rows aligned by their first column, changes shown per cell (${MOD}+Shift+D)` - : `Compare as ${store.structuredFormat.toUpperCase()} data — key order and formatting stop counting (${MOD}+Shift+D)` -) +// Each view explains what it gives rather than naming a format it may not have: +// a diagram and delimited text both reach this toggle with structuredFormat null. +const structureTip = computed(() => { + if (store.canCompareDiagram) { + return `Compare as diagrams — one picture carrying both revisions, so an inserted node cannot read as a rewrite (${MOD}+Shift+D)` + } + if (store.delimitedFormat) { + return `Compare as a grid — rows aligned by their first column, changes shown per cell (${MOD}+Shift+D)` + } + return `Compare as ${String(store.structuredFormat ?? '').toUpperCase()} data — key order and formatting stop counting (${MOD}+Shift+D)` +}) // The button names its destination (files ⇄ paste). const inPaste = computed(() => store.mode === 'paste') @@ -79,10 +83,17 @@ const clearTitle = computed(() => Ignore whitespace -