From 3b66b37ddf3d4530be6f1c6fb37e3e108e27967d Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 3 Jul 2026 23:39:18 -0700 Subject: [PATCH 1/3] Fix causal footer posture language --- .../0147-unmaterialized-file-frontier.md | 16 ++--- docs/topics/ui/README.md | 2 +- docs/topics/ui/editor-chrome.md | 16 +++-- docs/topics/ui/editor-chrome.svg | 2 +- docs/topics/ui/overview.svg | 2 +- spec/workspace-command-line.spec.mjs | 7 +- spec/workspace-footer.spec.mjs | 8 +-- spec/workspace-text-boundaries.spec.mjs | 4 +- spec/workspace-worldline-ux.spec.mjs | 19 +++--- src/app/workspace/workspace-footer-posture.ts | 66 ++++++++++++++++++- src/app/workspace/worldline-graph.ts | 41 ++++++++++-- src/app/workspace/worldline-phase-view.ts | 32 ++++++--- 12 files changed, 164 insertions(+), 51 deletions(-) diff --git a/docs/design/0147-unmaterialized-file-frontier.md b/docs/design/0147-unmaterialized-file-frontier.md index 56fdbecb..e7ed3b2a 100644 --- a/docs/design/0147-unmaterialized-file-frontier.md +++ b/docs/design/0147-unmaterialized-file-frontier.md @@ -210,13 +210,13 @@ The buffer is real even before disk exists. The editor opens immediately with an empty buffer. The footer names the state: ```text -/repo/foo.txt [clean | main | fs:unmaterialized | target:main] +/repo/foo.txt [basis:reading | head:basis | worldline:main | export:pending | admit:main | tick:t0] ``` After typing: ```text -/repo/foo.txt [dirty | main+local | fs:unmaterialized | target:main] +/repo/foo.txt [basis:reading | head:local | worldline:main | export:pending | admit:main | tick:t0] ``` The history/worldline drawer shows: @@ -224,10 +224,10 @@ The history/worldline drawer shows: ```text Worldlines projection: canonical@t0 + local optimistic | braid active | phase:unconfirmed -s phase r name basis head delta evidence note -> settled C main main 0 +0/-0 canonical@t0 clear - unconfirmed L local canonical@t0 - +local/-0 request:3 optimistic - unconfirmed B visible braid main+local - +local/-0 canonical@t0 active +s phase r name basis head span evidence note +> settled C main main 0 tick:t0 canonical@t0 clear + unconfirmed L local canonical@t0 - local request:3 optimistic + unconfirmed B visible braid main+local - local canonical@t0 active ``` ### First Save @@ -393,7 +393,7 @@ Deliverables: - split host path observation from file load; - open missing paths with empty initial text; -- footer/history label `fs:unmaterialized`; +- footer/history label `export:pending`; - command-line regression for `:edit missing.txt`; - no host file write before save. @@ -442,7 +442,7 @@ Deliverables: Lower modes must expose the same facts without relying on color: -- footer text uses explicit `fs:unmaterialized`, `external-frontier`, and +- footer text uses explicit `export:pending`, `external-frontier`, and `braid active` labels; - history drawer rows include `External Edit` as text, not just color; - `:why` states whether the visible buffer is canonical, local, external, or diff --git a/docs/topics/ui/README.md b/docs/topics/ui/README.md index afc5cc30..c1e040d1 100644 --- a/docs/topics/ui/README.md +++ b/docs/topics/ui/README.md @@ -26,7 +26,7 @@ Jim UI documents use three terminal width profiles: | Page | Surface | | --- | --- | | [Title Screen](title-screen.md) | Startup scene, title browser, and first file-open path. | -| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, dirty state, and cursor position. | +| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, causal posture, and cursor position. | | [Settings Menu](settings-menu.md) | F2 settings drawer, keyboard controls, diagnostics, and change feedback. | | [Command Line And Completions](command-line-and-completions.md) | Normal-mode `:` input, command help, file completion, and inline suggestions. | | [Drawers And Panels](drawers-and-panels.md) | File tree, Graft outline, Echo history, diagnostics, and inline why panels. | diff --git a/docs/topics/ui/editor-chrome.md b/docs/topics/ui/editor-chrome.md index b0b9a7e5..f5251b84 100644 --- a/docs/topics/ui/editor-chrome.md +++ b/docs/topics/ui/editor-chrome.md @@ -1,7 +1,7 @@ # Editor Chrome Editor chrome is the persistent frame around the current buffer: source -viewport, gutter, header title, footer hints, dirty markers, and coordinate +viewport, gutter, header title, footer hints, causal markers, and coordinate readouts. Editor chrome layout across wide, narrow, and xs terminal profiles. @@ -44,8 +44,9 @@ Line number modes: | `Off` | Hide line numbers. | Future gutter work should add theme-token-controlled dimming and modified-line -markers. Modified-line markers need a real saved-buffer baseline; they should -not be faked from the generic dirty flag. +markers. Modified and removed line markers should be projections of Echo edit +receipts relative to the current causal basis or checkpoint; they should not be +derived from Git diff, host-file comparison, or a single projection-changed flag. ## Footer @@ -54,16 +55,17 @@ The footer is the main low-friction status surface. It should show: - current mode; - `line:col` cursor position when a source cursor is active; - mode-specific hints; -- dirty and materialization posture; -- target branch or runtime posture when available; +- causal basis and head posture; +- export or materialization posture; +- worldline, admission, and tick posture when available; - command-line input when `:` mode is active. When settings or another non-source overlay owns focus, the footer should show that surface's focus state instead of leaking the editor cursor coordinate. The lower-right status segment currently reports workspace and worldline -posture. It is not a line-diff counter unless the implementation has a -saved-text baseline to compare against. +posture. It is not a Git diff counter; modified and removed line evidence must +come from Echo receipts admitted after the displayed basis. ## Implementation Map diff --git a/docs/topics/ui/editor-chrome.svg b/docs/topics/ui/editor-chrome.svg index 681a99e8..08d473bd 100644 --- a/docs/topics/ui/editor-chrome.svg +++ b/docs/topics/ui/editor-chrome.svg @@ -23,7 +23,7 @@ this is a change INSERT 5:17 [text input - esc normal - ctrl+s save] - [dirty | main | fs:unmaterialized] + [basis:reading | head:local | export:pending | tick:t0] narrow diff --git a/docs/topics/ui/overview.svg b/docs/topics/ui/overview.svg index 649443da..edfa3692 100644 --- a/docs/topics/ui/overview.svg +++ b/docs/topics/ui/overview.svg @@ -56,7 +56,7 @@ diagnostics can sit beside the active drawer NORMAL 6:1 [i insert - : command - F2 settings] - [dirty | main | fs:materialized] + [basis:reading | head:local | export:host | tick:t0] narrow diff --git a/spec/workspace-command-line.spec.mjs b/spec/workspace-command-line.spec.mjs index 0f46e7a3..37241c72 100644 --- a/spec/workspace-command-line.spec.mjs +++ b/spec/workspace-command-line.spec.mjs @@ -537,7 +537,10 @@ test("enter dispatches edit for missing paths as unmaterialized buffers", async assert.equal(opened.textAuthority.materialization, "unmaterialized"); assert.equal(opened.editor.dirty, false); assert.deepEqual(opened.editor.lines, [""]); - assert.match(rendered, /\/repo\/foo\.txt\s+\[clean \| main \| fs:unmaterialized/); + assert.match( + rendered, + /\/repo\/foo\.txt\s+\[basis:reading \| head:basis \| worldline:main \| export:pending/, + ); }); test("enter dispatches write and wq commands through production save", async () => { @@ -724,7 +727,7 @@ test("blocked production wq remains open with honest materialization status", as assert.equal(blockedModel.textAuthority.dirty, true); assert.equal(blockedModel.textAuthority.materialization, "unmaterialized"); assert.equal(blockedModel.editor.dirty, true); - assert.match(footer, /dirty \| main \| fs:unmaterialized/); + assert.match(footer, /basis:reading \| head:local \| worldline:main \| export:pending/); }); test("pending production intent queues wq save without arming quit confirmation", async () => { diff --git a/spec/workspace-footer.spec.mjs b/spec/workspace-footer.spec.mjs index 6180cecf..03161857 100644 --- a/spec/workspace-footer.spec.mjs +++ b/spec/workspace-footer.spec.mjs @@ -161,11 +161,11 @@ test('workspace footer renders command-line hints on the painted secondary row', test('workspace footer pins editor posture to the lower-right corner when it fits', async () => { const footer = await loadFooterModule(); - const posture = 'clean | main | fs:materialized | target:main | +0/-0'; + const posture = 'basis:reading | head:basis | worldline:main | export:host | admit:main | tick:t0'; const surface = footer.renderWorkspaceFooter({ ...idleNormalState(), textPosture: posture, - }, 96, {}); + }, 132, {}); const secondary = rowText(surface, 1); assert.equal(secondary.startsWith('/repo/notes/todo.md'), true); @@ -176,10 +176,10 @@ test('workspace footer pins editor posture to the lower-right corner when it fit test('workspace footer posture fit uses terminal display width for wide glyphs', async () => { const footerPosture = await loadFooterPostureModule(); const editorPath = '/repo/界.md'; - const textPosture = 'dirty | main'; + const textPosture = 'basis:reading | head:local'; const requiredWidth = visibleLength(`${editorPath} [${textPosture}]`); - assert.equal(requiredWidth, 26); + assert.equal(requiredWidth, 40); assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth - 1), false); assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth), true); }); diff --git a/spec/workspace-text-boundaries.spec.mjs b/spec/workspace-text-boundaries.spec.mjs index 35444a91..f1145810 100644 --- a/spec/workspace-text-boundaries.spec.mjs +++ b/spec/workspace-text-boundaries.spec.mjs @@ -333,8 +333,8 @@ test('footer renders production text posture without exposing text authority', a const footerContext = text.split('\n').at(-1) ?? ''; assert.equal(footerContext.startsWith('/repo/notes.md'), true); - assert.match(footerContext, /\[clean \| main \| fs:materialized/); - assert.equal(footerContext.endsWith('target:main | +0/-0]'), true); + assert.match(footerContext, /\[basis:reading \| head:basis \| worldline:main \| export:host/); + assert.equal(footerContext.endsWith('admit:main | tick:t0]'), true); }); test('source highlighting consumes reading material after production edit', async () => { diff --git a/spec/workspace-worldline-ux.spec.mjs b/spec/workspace-worldline-ux.spec.mjs index 1c535649..4f082a89 100644 --- a/spec/workspace-worldline-ux.spec.mjs +++ b/spec/workspace-worldline-ux.spec.mjs @@ -133,7 +133,7 @@ test("workspace footer separates causal posture from filesystem materialization" importDist("app", "workspace", "worldline-state.js"), ]); const model = mockTitleScreenModel(titleScreen, { - columns: 100, + columns: 132, rows: 12, editor: mockEditor(editorMode, { dirty: true, @@ -163,8 +163,11 @@ test("workspace footer separates causal posture from filesystem materialization" const footerContext = rendered.split("\n").at(-1); assert.equal(footerContext.startsWith("/repo/notes.md"), true); - assert.match(footerContext, /\[dirty \| strand:draft \| fs:unmaterialized \| target:main/); - assert.equal(footerContext.endsWith("+0/-0]"), true); + assert.match( + footerContext, + /\[basis:reading \| head:local \| worldline:strand:draft \| export:pending \| admit:main/, + ); + assert.equal(footerContext.endsWith("tick:t1]"), true); }); test("worldline drawer shows unconfirmed optimistic braid while Echo edit is in flight", async () => { @@ -178,9 +181,9 @@ test("worldline drawer shows unconfirmed optimistic braid while Echo edit is in const rendered = surfaceText(drawers.renderDrawer("history", harness.model, 96, 9)); assert.match(rendered, /projection:\s+canonical@t0 \+ local optimistic \| braid active \| phase:unconfirmed/); - assert.match(rendered, />\s+settled\s+C\s+main\s+main\s+0\s+\+0\/-0\s+canonical@t0/); - assert.match(rendered, /unconfirmed\s+L\s+local\s+canonical@t0\s+-\s+\+local\/-0\s+request:2\s+optimistic/); - assert.match(rendered, /unconfirmed\s+B\s+visible braid\s+main\+local\s+-\s+\+local\/-0\s+canonical@t0\s+active/); + assert.match(rendered, />\s+settled\s+C\s+main\s+main\s+0\s+tick:t0\s+canonical@t0/); + assert.match(rendered, /unconfirmed\s+L\s+local\s+canonical@t0\s+-\s+local\s+request:2\s+optimistic/); + assert.match(rendered, /unconfirmed\s+B\s+visible braid\s+main\+local\s+-\s+local\s+canonical@t0\s+active/); }); test("worldline drawer keeps conflicted optimistic braid visible after obstruction", async () => { @@ -197,8 +200,8 @@ test("worldline drawer keeps conflicted optimistic braid visible after obstructi const rendered = surfaceText(drawers.renderDrawer("history", harness.model, 96, 9)); assert.match(rendered, /phase:conflicted/); - assert.match(rendered, /conflicted\s+L\s+local\s+canonical@t0\s+-\s+\+local\/-0\s+\/repo\/notes\.md:/); - assert.match(rendered, /conflicted\s+B\s+visible braid\s+main\+local\s+-\s+\+local\/-0\s+canonical@t0\s+active/); + assert.match(rendered, /conflicted\s+L\s+local\s+canonical@t0\s+-\s+local\s+\/repo\/notes\.md:/); + assert.match(rendered, /conflicted\s+B\s+visible braid\s+main\+local\s+-\s+local\s+canonical@t0\s+active/); assert.match(harness.renderText(), /X/); }); diff --git a/src/app/workspace/workspace-footer-posture.ts b/src/app/workspace/workspace-footer-posture.ts index 465bd540..6a086472 100644 --- a/src/app/workspace/workspace-footer-posture.ts +++ b/src/app/workspace/workspace-footer-posture.ts @@ -1,5 +1,8 @@ -import { workspaceTextAuthorityPosture } from './workspace-text-authority.js'; -import { WorkspaceTextAuthorityKinds } from './workspace-text-authority.js'; +import { + WorkspaceTextAuthorityKinds, + type WorkspaceTextAuthority, + type WorkspaceTextAuthorityOpened, +} from './workspace-text-authority.js'; import { workspaceWorldlineContextLabel, workspaceWorldlineMaterialization, @@ -9,9 +12,26 @@ import { import type { WorkspaceModel } from './model.js'; import { jeditCommandFooterSummary } from './command-provenance.js'; +const BASIS_NONE = 'basis:none'; +const BASIS_PENDING_OPEN = 'basis:pending-open'; +const BASIS_OBSTRUCTED = 'basis:obstructed'; +const BASIS_OPEN = 'basis:open'; +const BASIS_READING = 'basis:reading'; +const BASIS_CHECKPOINT = 'basis:checkpoint'; +const BASIS_EXPORT = 'basis:export'; +const HEAD_NONE = 'head:none'; +const HEAD_PENDING_OPEN = 'head:pending-open'; +const HEAD_OBSTRUCTED = 'head:obstructed'; +const HEAD_BASIS = 'head:basis'; +const HEAD_LOCAL = 'head:local'; +const HEAD_RECEIPT = 'head:receipt'; +const HEAD_CHECKPOINT = 'head:checkpoint'; +const HEAD_EXPORT = 'head:export'; +const HEAD_PREFIX = 'head:'; + export function workspaceFooterTextPosture(model: WorkspaceModel): string { return [ - workspaceTextAuthorityPosture(model.textAuthority), + workspaceFooterTextAuthorityPosture(model.textAuthority), workspaceWorldlineContextLabel({ worldline: model.worldline, materialization: workspaceFooterMaterialization(model), @@ -36,3 +56,43 @@ function workspaceFooterMaterialization( ? model.textAuthority.materialization : workspaceWorldlineMaterialization(model.editor?.dirty); } + +function workspaceFooterTextAuthorityPosture( + authority: WorkspaceTextAuthority, +): string { + return [ + workspaceFooterTextBasis(authority), + workspaceFooterTextHead(authority), + ].join(' | '); +} + +function workspaceFooterTextBasis(authority: WorkspaceTextAuthority): string { + if (authority.kind === WorkspaceTextAuthorityKinds.None) return BASIS_NONE; + if (authority.kind === WorkspaceTextAuthorityKinds.PendingOpen) return BASIS_PENDING_OPEN; + if (authority.kind === WorkspaceTextAuthorityKinds.Obstructed) return BASIS_OBSTRUCTED; + return openedTextBasis(authority); +} + +function workspaceFooterTextHead(authority: WorkspaceTextAuthority): string { + if (authority.kind === WorkspaceTextAuthorityKinds.None) return HEAD_NONE; + if (authority.kind === WorkspaceTextAuthorityKinds.PendingOpen) return HEAD_PENDING_OPEN; + if (authority.kind === WorkspaceTextAuthorityKinds.Obstructed) return HEAD_OBSTRUCTED; + return openedTextHead(authority); +} + +function openedTextBasis(authority: WorkspaceTextAuthorityOpened): string { + if (authority.lastCheckpointId != null) return BASIS_CHECKPOINT; + if (authority.lastExportReadingId != null) return BASIS_EXPORT; + return authority.cache == null ? BASIS_OPEN : BASIS_READING; +} + +function openedTextHead(authority: WorkspaceTextAuthorityOpened): string { + if (authority.pendingIntentStatus != null) { + return `${HEAD_PREFIX}${authority.pendingIntentStatus}`; + } + if (authority.lastReceiptId != null) return HEAD_RECEIPT; + if (authority.dirty) return HEAD_LOCAL; + if (authority.lastCheckpointId != null) return HEAD_CHECKPOINT; + if (authority.lastExportReadingId != null) return HEAD_EXPORT; + return HEAD_BASIS; +} diff --git a/src/app/workspace/worldline-graph.ts b/src/app/workspace/worldline-graph.ts index 868bb6ea..1b212024 100644 --- a/src/app/workspace/worldline-graph.ts +++ b/src/app/workspace/worldline-graph.ts @@ -7,6 +7,7 @@ import { MAIN_WORLDLINE_NAME, WorkspaceWorldlineNodeKinds, WorkspaceWorldlinePostureKinds, + WorkspaceWorldlineMaterializationKinds, workspaceWorldlinePostureLabel, type WorkspaceWorldlineGraphNode, type WorkspaceWorldlineMaterializationKind, @@ -29,6 +30,14 @@ const HEAD_CELL_WIDTH = 5; const DELTA_CELL_WIDTH = 9; const CONFLICT_CELL_WIDTH = 10; const FIRST_ROW = 0; +const WORLDLINE_PREFIX = 'worldline:'; +const EXPORT_HOST = 'export:host'; +const EXPORT_PENDING = 'export:pending'; +const EXPORT_NONE = 'export:none'; +const ADMIT_PREFIX = 'admit:'; +const TICK_PREFIX = 'tick:t'; +const TICKS_PREFIX = 'ticks:t'; +const TICK_SPAN_SEPARATOR = '->t'; export interface WorkspaceWorldlineContextState { readonly worldline: WorkspaceWorldlineState; @@ -40,10 +49,10 @@ export function workspaceWorldlineContextLabel( ): string { const node = currentWorldlineGraphNode(state.worldline); return [ - workspaceWorldlinePostureLabel(state.worldline.posture), - `fs:${state.materialization}`, - `target:${state.worldline.posture.admissionTarget}`, - node == null ? undefined : worldlineDeltaLabel(node), + `${WORLDLINE_PREFIX}${workspaceWorldlinePostureLabel(state.worldline.posture)}`, + workspaceWorldlineExportLabel(state.materialization), + `${ADMIT_PREFIX}${state.worldline.posture.admissionTarget}`, + node == null ? undefined : worldlineTickSpanLabel(node), ].filter((part): part is string => part != null && part.length > EMPTY_LENGTH).join(' | '); } @@ -147,6 +156,30 @@ function worldlineDeltaLabel(node: WorkspaceWorldlineGraphNode): string { return `+${node.ahead}/-${node.behind}`; } +function workspaceWorldlineExportLabel( + materialization: WorkspaceWorldlineMaterializationKind, +): string { + if (materialization === WorkspaceWorldlineMaterializationKinds.Materialized) { + return EXPORT_HOST; + } + return materialization === WorkspaceWorldlineMaterializationKinds.Unmaterialized + ? EXPORT_PENDING + : EXPORT_NONE; +} + +function worldlineTickSpanLabel(node: WorkspaceWorldlineGraphNode): string | undefined { + if (node.basisTick == null && node.headTick == null) { + return undefined; + } + if (node.basisTick == null) { + return `${TICK_PREFIX}${node.headTick}`; + } + if (node.headTick == null || node.basisTick === node.headTick) { + return `${TICK_PREFIX}${node.basisTick}`; + } + return `${TICKS_PREFIX}${node.basisTick}${TICK_SPAN_SEPARATOR}${node.headTick}`; +} + function braidStatusLabel(node: WorkspaceWorldlineGraphNode): string { if (node.kind !== WorkspaceWorldlineNodeKinds.Braid) { return ''; diff --git a/src/app/workspace/worldline-phase-view.ts b/src/app/workspace/worldline-phase-view.ts index 4cf6d62c..ffbff1d8 100644 --- a/src/app/workspace/worldline-phase-view.ts +++ b/src/app/workspace/worldline-phase-view.ts @@ -21,7 +21,7 @@ const RAIL_CELL_WIDTH = 1; const NAME_CELL_WIDTH = 14; const BASIS_CELL_WIDTH = 14; const HEAD_CELL_WIDTH = 5; -const DELTA_CELL_WIDTH = 9; +const SPAN_CELL_WIDTH = 9; const EVIDENCE_CELL_WIDTH = 18; const SELECTED_CELL_WIDTH = 1; const NO_EVIDENCE = '-'; @@ -33,14 +33,17 @@ const BRAID_RAIL = 'B'; const LOCAL_RAIL = 'L'; const PROJECTION_PREFIX = 'projection:'; const CANONICAL_PREFIX = 'canonical@t'; +const TICK_PREFIX = 'tick:t'; +const TICKS_PREFIX = 'ticks:t'; +const TICK_SPAN_SEPARATOR = '->t'; const LOCAL_NAME = 'local'; const VISIBLE_BRAID_NAME = 'visible braid'; -const LOCAL_DELTA = '+local/-0'; +const LOCAL_SPAN = 'local'; const OPTIMISTIC_NOTE = 'optimistic'; const ACTIVE_NOTE = 'active'; const CONFLICT_NOTE = 'conflict'; const BLOCKED_NOTE = 'blocked'; -const PHASE_HEADER = 's phase r name basis head delta evidence note'; +const PHASE_HEADER = 's phase r name basis head span evidence note'; const SELECTED_MARKER = '>'; const UNSELECTED_MARKER = ' '; @@ -51,7 +54,7 @@ interface WorldlinePhaseRow { readonly name: string; readonly basis: string; readonly head: string; - readonly delta: string; + readonly span: string; readonly evidence: string; readonly note: string; } @@ -109,7 +112,7 @@ function graphNodePhaseRow( name: node.name, basis: node.basis, head: node.headTick == null ? NO_HEAD : String(node.headTick), - delta: worldlineDeltaLabel(node), + span: worldlineSpanLabel(node), evidence: node.headTick == null ? NO_EVIDENCE : `${CANONICAL_PREFIX}${node.headTick}`, note: graphNodeNote(node), }; @@ -129,7 +132,7 @@ function localOptimisticRow(model: WorkspaceModel): WorldlinePhaseRow | undefine name: LOCAL_NAME, basis: canonicalRef(model), head: NO_HEAD, - delta: LOCAL_DELTA, + span: LOCAL_SPAN, evidence: localOptimisticEvidence(model, latest), note: localOptimisticNote(phase), }; @@ -146,7 +149,7 @@ function visibleBraidRow( name: VISIBLE_BRAID_NAME, basis: `${MAIN_WORLDLINE_NAME}+${LOCAL_NAME}`, head: NO_HEAD, - delta: LOCAL_DELTA, + span: LOCAL_SPAN, evidence: canonicalRef(model), note: ACTIVE_NOTE, }; @@ -235,8 +238,17 @@ function canonicalRef(model: WorkspaceModel): string { return `${CANONICAL_PREFIX}${model.worldline.canonicalHeadTick}`; } -function worldlineDeltaLabel(node: WorkspaceWorldlineGraphNode): string { - return `+${node.ahead}/-${node.behind}`; +function worldlineSpanLabel(node: WorkspaceWorldlineGraphNode): string { + if (node.basisTick == null && node.headTick == null) { + return NO_EVIDENCE; + } + if (node.basisTick == null) { + return `${TICK_PREFIX}${node.headTick}`; + } + if (node.headTick == null || node.basisTick === node.headTick) { + return `${TICK_PREFIX}${node.basisTick}`; + } + return `${TICKS_PREFIX}${node.basisTick}${TICK_SPAN_SEPARATOR}${node.headTick}`; } function renderPhaseRow(row: WorldlinePhaseRow): string { @@ -247,7 +259,7 @@ function renderPhaseRow(row: WorldlinePhaseRow): string { cell(row.name, NAME_CELL_WIDTH), cell(row.basis, BASIS_CELL_WIDTH), cell(row.head, HEAD_CELL_WIDTH), - cell(row.delta, DELTA_CELL_WIDTH), + cell(row.span, SPAN_CELL_WIDTH), cell(row.evidence, EVIDENCE_CELL_WIDTH), row.note, ].filter((part) => part.length > EMPTY_LENGTH).join(' '); From ffe0a764051eb301cf42042e05595ff553b9d000 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 3 Jul 2026 23:54:23 -0700 Subject: [PATCH 2/3] Fix light theme gutter contrast --- spec/source-viewer.spec.mjs | 40 +++++++++++++++++ spec/theme-switch.spec.mjs | 55 ++++++++++++++++++++++++ spec/workspace-footer.spec.mjs | 18 ++++++++ src/ui/jedit-theme-palettes.ts | 2 +- src/ui/source-viewer.ts | 47 +++++++++++--------- src/ui/workspace-footer-surface-utils.ts | 4 ++ 6 files changed, 145 insertions(+), 21 deletions(-) diff --git a/spec/source-viewer.spec.mjs b/spec/source-viewer.spec.mjs index d2a5fce4..0f0ce720 100644 --- a/spec/source-viewer.spec.mjs +++ b/spec/source-viewer.spec.mjs @@ -5,12 +5,18 @@ import { pathToFileURL } from "node:url"; import { REPO_ROOT, ensureDistBuilt } from "./dist-helpers.mjs"; const SOURCE_VIEWER_PATH = path.join(REPO_ROOT, "dist", "ui", "source-viewer.js"); +const THEMES_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-themes.js"); async function loadSourceViewerModule() { await ensureDistBuilt(); return import(pathToFileURL(SOURCE_VIEWER_PATH).href); } +async function loadThemesModule() { + await ensureDistBuilt(); + return import(pathToFileURL(THEMES_PATH).href); +} + test("source viewer paints a stable line-number gutter before source text", async () => { const { createSurface } = await import("@flyingrobots/bijou"); const sourceViewer = await loadSourceViewerModule(); @@ -74,6 +80,40 @@ test("source viewer can paint cursor-relative line numbers", async () => { assert.equal(rowText(surface, 4).startsWith("+2│ line-5"), true); }); +test("source viewer keeps light-theme gutter cells on the workspace surface", async () => { + const { createSurface } = await import("@flyingrobots/bijou"); + const [sourceViewer, themes] = await Promise.all([ + loadSourceViewerModule(), + loadThemesModule(), + ]); + const theme = themes.resolveInitialJeditTheme("morning"); + const surface = createSurface(16, 1, { char: ".", empty: false }); + + sourceViewer.renderSourceViewer( + surface, + { + lines: ["alpha"], + cursorRow: 0, + cursorCol: 0, + scrollRow: 0, + scrollCol: 0, + mode: "normal", + }, + undefined, + { + viewport: { width: 16, height: 1 }, + leftPad: 0, + topPad: 0, + theme, + }, + ); + + assert.deepEqual(surface.get(0, 0).fgRGB, theme.surface.workspace.fgRGB); + assert.deepEqual(surface.get(0, 0).bgRGB, theme.surface.workspace.bgRGB); + assert.deepEqual(surface.get(1, 0).bgRGB, theme.surface.workspace.bgRGB); + assert.deepEqual(surface.get(2, 0).bgRGB, theme.surface.workspace.bgRGB); +}); + function sourceViewerTheme() { const workspace = token("#f0f6fc", "#0d1117"); const gutter = token("#8b949e", "#0d1117"); diff --git a/spec/theme-switch.spec.mjs b/spec/theme-switch.spec.mjs index 2fe6f717..1776de03 100644 --- a/spec/theme-switch.spec.mjs +++ b/spec/theme-switch.spec.mjs @@ -35,6 +35,14 @@ const CANONICAL_VARIABLE_NAMES = [ const LUMINANCE_RED_WEIGHT = 0.2126; const LUMINANCE_GREEN_WEIGHT = 0.7152; const LUMINANCE_BLUE_WEIGHT = 0.0722; +const MIN_SURFACE_TEXT_CONTRAST = 4.5; +const CONTRAST_LUMINANCE_OFFSET = 0.05; +const SRGB_CHANNEL_MAX = 255; +const SRGB_LINEAR_BREAKPOINT = 0.03928; +const SRGB_LINEAR_DIVISOR = 12.92; +const SRGB_LINEAR_OFFSET = 0.055; +const SRGB_LINEAR_SCALE = 1.055; +const SRGB_LINEAR_EXPONENT = 2.4; async function loadThemesModule() { await ensureDistBuilt(); @@ -132,6 +140,20 @@ test("authored light and dark variants override generated companions", async () assert.equal(solarizedRoundTrip.name, "solarized-dark"); }); +test("jedit surface text clears contrast for built-in and companion themes", async () => { + const { themes } = await loadThemesModule(); + + for (const theme of themesWithGeneratedCompanions(themes)) { + for (const [surfaceName, token] of Object.entries(theme.surface)) { + const ratio = colorContrastRatio(token.fgRGB, token.bgRGB); + assert.ok( + ratio >= MIN_SURFACE_TEXT_CONTRAST, + `${theme.name} ${surfaceName} contrast ratio ${ratio.toFixed(2)}`, + ); + } + } +}); + test("built-in jedit theme tokens map back to named variables and effect metadata", async () => { const { themes, style } = await loadThemesModule(); @@ -193,6 +215,39 @@ function colorLuminance(color) { ); } +function themesWithGeneratedCompanions(themes) { + const all = new Map(); + for (const theme of themes.availableJeditThemes()) { + all.set(theme.name, theme); + const companion = themes.oppositeJeditTheme(theme); + all.set(companion.name, companion); + } + return all.values(); +} + +function colorContrastRatio(first, second) { + const firstLuminance = relativeColorLuminance(first); + const secondLuminance = relativeColorLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET); +} + +function relativeColorLuminance(color) { + return ( + linearChannel(color[0]) * LUMINANCE_RED_WEIGHT + + linearChannel(color[1]) * LUMINANCE_GREEN_WEIGHT + + linearChannel(color[2]) * LUMINANCE_BLUE_WEIGHT + ); +} + +function linearChannel(channel) { + const scaled = channel / SRGB_CHANNEL_MAX; + return scaled <= SRGB_LINEAR_BREAKPOINT + ? scaled / SRGB_LINEAR_DIVISOR + : ((scaled + SRGB_LINEAR_OFFSET) / SRGB_LINEAR_SCALE) ** SRGB_LINEAR_EXPONENT; +} + function assertCompleteBasePalette(theme) { const variableNames = [...theme.variables.keys()]; for (const variableName of CANONICAL_VARIABLE_NAMES) { diff --git a/spec/workspace-footer.spec.mjs b/spec/workspace-footer.spec.mjs index 03161857..6b85f38b 100644 --- a/spec/workspace-footer.spec.mjs +++ b/spec/workspace-footer.spec.mjs @@ -159,6 +159,24 @@ test('workspace footer renders command-line hints on the painted secondary row', assert.equal(rowText(surface, 1).trim(), '[tab accept · enter run · esc cancel]'); }); +test('workspace footer applies theme foreground and background to painted text', async () => { + const footer = await loadFooterModule(); + const token = { + fg: '#22272e', + fgRGB: [34, 39, 46], + bg: '#dedad0', + bgRGB: [222, 218, 208], + foregroundVariables: [], + backgroundVariables: [], + }; + const surface = footer.renderWorkspaceFooter(idleNormalState(), 24, token); + const cell = surface.get(0, 0); + + assert.equal(cell.char, 'N'); + assert.deepEqual(cell.fgRGB, token.fgRGB); + assert.deepEqual(cell.bgRGB, token.bgRGB); +}); + test('workspace footer pins editor posture to the lower-right corner when it fits', async () => { const footer = await loadFooterModule(); const posture = 'basis:reading | head:basis | worldline:main | export:host | admit:main | tick:t0'; diff --git a/src/ui/jedit-theme-palettes.ts b/src/ui/jedit-theme-palettes.ts index ac37c85f..639738e8 100644 --- a/src/ui/jedit-theme-palettes.ts +++ b/src/ui/jedit-theme-palettes.ts @@ -103,7 +103,7 @@ const SOLARIZED_DARK_PALETTE: ThemePalette = { }; const SOLARIZED_LIGHT_PALETTE: ThemePalette = { - ink: [101, 123, 131], + ink: [79, 98, 104], muted: [147, 161, 161], accent: [211, 54, 130], info: [38, 139, 210], diff --git a/src/ui/source-viewer.ts b/src/ui/source-viewer.ts index cef662b3..7a7650ee 100644 --- a/src/ui/source-viewer.ts +++ b/src/ui/source-viewer.ts @@ -1,6 +1,6 @@ import type { Cell, Surface } from '@flyingrobots/bijou'; import type { SourceHighlightReading } from '../ports/source-highlighter.js'; -import { JEDIT_SOURCE_TOKEN, type JeditStyleToken, type JeditTheme } from './jedit-theme.js'; +import type { JeditStyleToken, JeditTheme } from './jedit-theme.js'; import { SOURCE_LINE_NUMBER_MODE, type SourceLineNumberMode, @@ -20,6 +20,7 @@ const DEFAULT_LINE_NUMBER_MODE = SOURCE_LINE_NUMBER_MODE.Absolute; const CURRENT_LINE_RELATIVE_NUMBER = 0; export type SourceViewerMode = typeof NORMAL_MODE | typeof INSERT_MODE; +type SourceViewerCellStyle = Pick; export interface SourceViewerEditor { readonly lines: readonly string[]; @@ -94,7 +95,7 @@ function paintSourceViewerCursor( surface.set(cursorX, cursorY, { ...cell, char: cell.char.length > 0 ? cell.char : cursorFallbackChar(editor.mode), - ...cellStyle(token), + ...cellStyle(token, options.theme.surface.workspace), empty: false, }); } @@ -153,10 +154,12 @@ function paintSourceViewerGutter( context: SourceViewerGutterPaintContext, ): void { const { reading, options, gutter, cursorRow, mode } = context; - const numberToken = - options.theme.source.get(JEDIT_SOURCE_TOKEN.Comment) ?? - options.theme.chrome.titleLogoShadow; + const numberToken = options.theme.surface.workspace; const ruleToken = options.theme.chrome.activeEdge; + const baseToken = options.theme.surface.workspace; + const numberStyle = cellStyle(numberToken, baseToken); + const ruleStyle = cellStyle(ruleToken, baseToken); + const gapStyle = cellStyle(baseToken, baseToken); const ruleX = options.leftPad + gutter.numberWidth + GUTTER_CONTENT_GAP - 1; for (let row = 0; row < options.viewport.height; row += 1) { const sourceLine = reading.lines[row]; @@ -167,9 +170,9 @@ function paintSourceViewerGutter( gutter.numberWidth, ' ', ); - paintGutterText(surface, label, options.leftPad, y, numberToken); - paintGutterCell(surface, ruleX, y, GUTTER_RULE, ruleToken); - paintGutterRuleGap(surface, ruleX, y, options.theme.surface.workspace); + paintGutterText(surface, label, options.leftPad, y, numberStyle); + paintGutterCell(surface, ruleX, y, GUTTER_RULE, ruleStyle); + paintGutterRuleGap(surface, ruleX, y, gapStyle); } } @@ -213,10 +216,10 @@ function paintGutterRuleGap( surface: Surface, ruleX: number, y: number, - token: JeditStyleToken, + style: SourceViewerCellStyle, ): void { for (let gap = 0; gap < GUTTER_RULE_GAP; gap += 1) { - paintGutterCell(surface, ruleX + gap + 1, y, ' ', token); + paintGutterCell(surface, ruleX + gap + 1, y, ' ', style); } } @@ -225,10 +228,10 @@ function paintGutterText( text: string, x: number, y: number, - token: JeditStyleToken, + style: SourceViewerCellStyle, ): void { for (let index = 0; index < text.length; index += 1) { - paintGutterCell(surface, x + index, y, text[index] ?? ' ', token); + paintGutterCell(surface, x + index, y, text[index] ?? ' ', style); } } @@ -237,7 +240,7 @@ function paintGutterCell( x: number, y: number, char: string, - token: JeditStyleToken, + style: SourceViewerCellStyle, ): void { if (x < 0 || y < 0 || x >= surface.width || y >= surface.height) { return; @@ -246,7 +249,7 @@ function paintGutterCell( surface.set(x, y, { ...cell, char, - ...cellStyle(token), + ...style, empty: false, }); } @@ -270,12 +273,16 @@ function cursorFallbackChar(mode: SourceViewerMode): string { return mode === NORMAL_MODE ? ' ' : '│'; } -function cellStyle(token: JeditStyleToken): Pick { +function cellStyle(token: JeditStyleToken, baseToken: JeditStyleToken): SourceViewerCellStyle { return { - fg: token.fg, - bg: token.bg, - fgRGB: token.fgRGB, - bgRGB: token.bgRGB, - modifiers: token.modifiers == null ? undefined : [...token.modifiers], + fg: token.fg ?? baseToken.fg, + bg: token.bg ?? baseToken.bg, + fgRGB: token.fgRGB ?? baseToken.fgRGB, + bgRGB: token.bgRGB ?? baseToken.bgRGB, + modifiers: token.modifiers == null ? copiedModifiers(baseToken) : [...token.modifiers], }; } + +function copiedModifiers(token: JeditStyleToken): string[] | undefined { + return token.modifiers == null ? undefined : [...token.modifiers]; +} diff --git a/src/ui/workspace-footer-surface-utils.ts b/src/ui/workspace-footer-surface-utils.ts index 7317e76b..3831cb59 100644 --- a/src/ui/workspace-footer-surface-utils.ts +++ b/src/ui/workspace-footer-surface-utils.ts @@ -8,6 +8,8 @@ export { fitLine } from './fit-line.js'; export function fillSurface(surface: Surface, token: JeditStyleToken): void { surface.fill({ char: SURFACE_FILL_CHAR, + fg: token.fg, + fgRGB: token.fgRGB, bg: token.bg, bgRGB: token.bgRGB, empty: false, @@ -21,6 +23,8 @@ export function applyBackground(surface: Surface, token: JeditStyleToken): void surface.set(x, y, { ...cell, char: cell.char.length > 0 ? cell.char : SURFACE_FILL_CHAR, + fg: token.fg, + fgRGB: token.fgRGB, bg: token.bg, bgRGB: token.bgRGB, empty: false, From 223579c18a8f156290e02edf6e069f4c2a6cfcc6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 4 Jul 2026 00:23:23 -0700 Subject: [PATCH 3/3] Fix causal footer review findings --- spec/source-viewer.spec.mjs | 14 ++- spec/theme-switch.spec.mjs | 41 +++++++ spec/workspace-app-echo-cutover.spec.mjs | 6 + spec/workspace-worldline-ux.spec.mjs | 7 +- src/app/workspace/workspace-footer-posture.ts | 4 +- src/app/workspace/worldline-graph.ts | 32 +----- src/app/workspace/worldline-phase-view.ts | 19 +--- src/app/workspace/worldline-tick-span.ts | 26 +++++ src/ui/jedit-theme-palettes.ts | 6 +- src/ui/jedit-themes.ts | 105 +++++++++++++++++- src/ui/source-viewer.ts | 6 +- 11 files changed, 206 insertions(+), 60 deletions(-) create mode 100644 src/app/workspace/worldline-tick-span.ts diff --git a/spec/source-viewer.spec.mjs b/spec/source-viewer.spec.mjs index 0f0ce720..38f27c7a 100644 --- a/spec/source-viewer.spec.mjs +++ b/spec/source-viewer.spec.mjs @@ -6,6 +6,7 @@ import { REPO_ROOT, ensureDistBuilt } from "./dist-helpers.mjs"; const SOURCE_VIEWER_PATH = path.join(REPO_ROOT, "dist", "ui", "source-viewer.js"); const THEMES_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-themes.js"); +const STYLE_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-theme.js"); async function loadSourceViewerModule() { await ensureDistBuilt(); @@ -14,7 +15,10 @@ async function loadSourceViewerModule() { async function loadThemesModule() { await ensureDistBuilt(); - return import(pathToFileURL(THEMES_PATH).href); + return { + style: await import(pathToFileURL(STYLE_PATH).href), + themes: await import(pathToFileURL(THEMES_PATH).href), + }; } test("source viewer paints a stable line-number gutter before source text", async () => { @@ -82,11 +86,11 @@ test("source viewer can paint cursor-relative line numbers", async () => { test("source viewer keeps light-theme gutter cells on the workspace surface", async () => { const { createSurface } = await import("@flyingrobots/bijou"); - const [sourceViewer, themes] = await Promise.all([ + const [sourceViewer, themeModules] = await Promise.all([ loadSourceViewerModule(), loadThemesModule(), ]); - const theme = themes.resolveInitialJeditTheme("morning"); + const theme = themeModules.themes.resolveInitialJeditTheme("morning"); const surface = createSurface(16, 1, { char: ".", empty: false }); sourceViewer.renderSourceViewer( @@ -108,7 +112,9 @@ test("source viewer keeps light-theme gutter cells on the workspace surface", as }, ); - assert.deepEqual(surface.get(0, 0).fgRGB, theme.surface.workspace.fgRGB); + const comment = theme.source.get(themeModules.style.JEDIT_SOURCE_TOKEN.Comment); + + assert.deepEqual(surface.get(0, 0).fgRGB, comment?.fgRGB); assert.deepEqual(surface.get(0, 0).bgRGB, theme.surface.workspace.bgRGB); assert.deepEqual(surface.get(1, 0).bgRGB, theme.surface.workspace.bgRGB); assert.deepEqual(surface.get(2, 0).bgRGB, theme.surface.workspace.bgRGB); diff --git a/spec/theme-switch.spec.mjs b/spec/theme-switch.spec.mjs index 1776de03..6b0868ee 100644 --- a/spec/theme-switch.spec.mjs +++ b/spec/theme-switch.spec.mjs @@ -36,6 +36,7 @@ const LUMINANCE_RED_WEIGHT = 0.2126; const LUMINANCE_GREEN_WEIGHT = 0.7152; const LUMINANCE_BLUE_WEIGHT = 0.0722; const MIN_SURFACE_TEXT_CONTRAST = 4.5; +const MIN_ACCENT_TEXT_CONTRAST = 3.0; const CONTRAST_LUMINANCE_OFFSET = 0.05; const SRGB_CHANNEL_MAX = 255; const SRGB_LINEAR_BREAKPOINT = 0.03928; @@ -154,6 +155,16 @@ test("jedit surface text clears contrast for built-in and companion themes", asy } }); +test("jedit rendered accent text clears contrast for built-in and companion themes", async () => { + const { themes } = await loadThemesModule(); + + for (const theme of themesWithGeneratedCompanions(themes)) { + assertTokenGroupContrast(theme.name, "source", theme.source, theme.surface.workspace.bgRGB); + assertTokenGroupContrast(theme.name, "markdown", theme.markdown, theme.surface.workspace.bgRGB); + assertObjectTokenContrast(theme.name, "chrome", theme.chrome, theme.surface.workspace.bgRGB); + } +}); + test("built-in jedit theme tokens map back to named variables and effect metadata", async () => { const { themes, style } = await loadThemesModule(); @@ -233,6 +244,36 @@ function colorContrastRatio(first, second) { return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET); } +function assertTokenGroupContrast(themeName, groupName, tokens, fallbackBackground) { + for (const [tokenName, token] of tokens) { + assertRenderedTokenContrast( + themeName, + `${groupName}.${tokenName.description ?? String(tokenName)}`, + token, + fallbackBackground, + ); + } +} + +function assertObjectTokenContrast(themeName, groupName, tokens, fallbackBackground) { + for (const [tokenName, token] of Object.entries(tokens)) { + assertRenderedTokenContrast( + themeName, + `${groupName}.${tokenName}`, + token, + fallbackBackground, + ); + } +} + +function assertRenderedTokenContrast(themeName, tokenName, token, fallbackBackground) { + const ratio = colorContrastRatio(token.fgRGB, token.bgRGB ?? fallbackBackground); + assert.ok( + ratio >= MIN_ACCENT_TEXT_CONTRAST, + `${themeName} ${tokenName} contrast ratio ${ratio.toFixed(2)}`, + ); +} + function relativeColorLuminance(color) { return ( linearChannel(color[0]) * LUMINANCE_RED_WEIGHT + diff --git a/spec/workspace-app-echo-cutover.spec.mjs b/spec/workspace-app-echo-cutover.spec.mjs index e525cc84..59daa30c 100644 --- a/spec/workspace-app-echo-cutover.spec.mjs +++ b/spec/workspace-app-echo-cutover.spec.mjs @@ -9,6 +9,7 @@ import { openedHarness, twoFileHarness, } from './workspace-echo-test-utils.mjs'; +import { importDist } from './workspace-helpers.mjs'; test('real workspace app path opens files through production text authority', async () => { const harness = await createWorkspaceEchoAppHarness({ @@ -367,6 +368,7 @@ test('real workspace app path inserts canonical spacebar token in insert mode', }); test('real workspace app path saves by exporting and checkpointing production text', async () => { + const footerPosture = await importDist('app', 'workspace', 'workspace-footer-posture.js'); const harness = await openedHarness({ exportText: 'saved from Echo' }); await harness.key('i'); @@ -379,6 +381,10 @@ test('real workspace app path saves by exporting and checkpointing production te assert.equal(harness.calls.checkpoint.length, 1); assert.equal(harness.model.textAuthority.lastExportReadingId, 'reading:export'); assert.equal(harness.model.textAuthority.lastCheckpointId, 'checkpoint:save'); + assert.match( + footerPosture.workspaceFooterTextPosture(harness.model), + /basis:checkpoint \| head:checkpoint \| worldline:main \| export:host/, + ); }); test('real workspace app path keeps obstruction honest without retrying', async () => { diff --git a/spec/workspace-worldline-ux.spec.mjs b/spec/workspace-worldline-ux.spec.mjs index 4f082a89..e55d32c3 100644 --- a/spec/workspace-worldline-ux.spec.mjs +++ b/spec/workspace-worldline-ux.spec.mjs @@ -11,7 +11,8 @@ import { createI18nMock } from "./i18n-mock.mjs"; import { createWorkspaceEchoAppHarness, productionTextObstruction } from "./workspace-echo-app-harness.mjs"; test("ttd commands move the observer without mutating canonical worldline posture", async () => { - const [keyBindings, titleScreen, editorMode, worldline] = await Promise.all([ + const [footerPosture, keyBindings, titleScreen, editorMode, worldline] = await Promise.all([ + importDist("app", "workspace", "workspace-footer-posture.js"), importDist("app", "workspace", "key-bindings.js"), importDist("ui", "title-screen.js"), importDist("app", "workspace", "editor", "mode.js"), @@ -44,6 +45,10 @@ test("ttd commands move the observer without mutating canonical worldline postur assert.equal(observing.worldline.posture.kind, "historical"); assert.equal(observing.worldline.posture.observedTick, 2); assert.equal(worldline.workspaceWorldlinePostureLabel(observing.worldline.posture), "observe:t2"); + assert.match( + footerPosture.workspaceFooterTextPosture(observing), + /worldline:observe:t2 \| export:host \| admit:main \| tick:t2/, + ); assert.equal(head.worldline.posture.kind, "canonical"); assert.equal(worldline.workspaceWorldlinePostureLabel(head.worldline.posture), "main"); }); diff --git a/src/app/workspace/workspace-footer-posture.ts b/src/app/workspace/workspace-footer-posture.ts index 6a086472..25b24d28 100644 --- a/src/app/workspace/workspace-footer-posture.ts +++ b/src/app/workspace/workspace-footer-posture.ts @@ -90,9 +90,9 @@ function openedTextHead(authority: WorkspaceTextAuthorityOpened): string { if (authority.pendingIntentStatus != null) { return `${HEAD_PREFIX}${authority.pendingIntentStatus}`; } - if (authority.lastReceiptId != null) return HEAD_RECEIPT; - if (authority.dirty) return HEAD_LOCAL; if (authority.lastCheckpointId != null) return HEAD_CHECKPOINT; if (authority.lastExportReadingId != null) return HEAD_EXPORT; + if (authority.lastReceiptId != null) return HEAD_RECEIPT; + if (authority.dirty) return HEAD_LOCAL; return HEAD_BASIS; } diff --git a/src/app/workspace/worldline-graph.ts b/src/app/workspace/worldline-graph.ts index 1b212024..e8030394 100644 --- a/src/app/workspace/worldline-graph.ts +++ b/src/app/workspace/worldline-graph.ts @@ -4,15 +4,14 @@ import { fitLine } from '../../ui/fit-line.js'; import { clampIndex } from './viewport.js'; import { isWorkspaceDownKey, isWorkspaceUpKey, WorkspaceKeys } from './workspace-key.js'; import { - MAIN_WORLDLINE_NAME, WorkspaceWorldlineNodeKinds, - WorkspaceWorldlinePostureKinds, WorkspaceWorldlineMaterializationKinds, workspaceWorldlinePostureLabel, type WorkspaceWorldlineGraphNode, type WorkspaceWorldlineMaterializationKind, type WorkspaceWorldlineState, } from './worldline-types.js'; +import { workspaceWorldlineTickSpanLabel } from './worldline-tick-span.js'; const GRAPH_PAGE_STEP = 10; const TITLE_KEY = 'worldline.title'; @@ -35,9 +34,6 @@ const EXPORT_HOST = 'export:host'; const EXPORT_PENDING = 'export:pending'; const EXPORT_NONE = 'export:none'; const ADMIT_PREFIX = 'admit:'; -const TICK_PREFIX = 'tick:t'; -const TICKS_PREFIX = 'ticks:t'; -const TICK_SPAN_SEPARATOR = '->t'; export interface WorkspaceWorldlineContextState { readonly worldline: WorkspaceWorldlineState; @@ -47,12 +43,11 @@ export interface WorkspaceWorldlineContextState { export function workspaceWorldlineContextLabel( state: WorkspaceWorldlineContextState, ): string { - const node = currentWorldlineGraphNode(state.worldline); return [ `${WORLDLINE_PREFIX}${workspaceWorldlinePostureLabel(state.worldline.posture)}`, workspaceWorldlineExportLabel(state.materialization), `${ADMIT_PREFIX}${state.worldline.posture.admissionTarget}`, - node == null ? undefined : worldlineTickSpanLabel(node), + workspaceWorldlineTickSpanLabel(state.worldline.posture), ].filter((part): part is string => part != null && part.length > EMPTY_LENGTH).join(' | '); } @@ -142,16 +137,6 @@ function graphScrollStart(selectedIndex: number, total: number, visible: number) return Math.min(Math.max(FIRST_ROW, selectedIndex - half), total - visible); } -function currentWorldlineGraphNode( - worldline: WorkspaceWorldlineState, -): WorkspaceWorldlineGraphNode | undefined { - if (worldline.posture.kind === WorkspaceWorldlinePostureKinds.Canonical || - worldline.posture.kind === WorkspaceWorldlinePostureKinds.Historical) { - return worldline.graph.find((node) => node.name === MAIN_WORLDLINE_NAME); - } - return worldline.graph.find((node) => node.name === worldline.posture.name); -} - function worldlineDeltaLabel(node: WorkspaceWorldlineGraphNode): string { return `+${node.ahead}/-${node.behind}`; } @@ -167,19 +152,6 @@ function workspaceWorldlineExportLabel( : EXPORT_NONE; } -function worldlineTickSpanLabel(node: WorkspaceWorldlineGraphNode): string | undefined { - if (node.basisTick == null && node.headTick == null) { - return undefined; - } - if (node.basisTick == null) { - return `${TICK_PREFIX}${node.headTick}`; - } - if (node.headTick == null || node.basisTick === node.headTick) { - return `${TICK_PREFIX}${node.basisTick}`; - } - return `${TICKS_PREFIX}${node.basisTick}${TICK_SPAN_SEPARATOR}${node.headTick}`; -} - function braidStatusLabel(node: WorkspaceWorldlineGraphNode): string { if (node.kind !== WorkspaceWorldlineNodeKinds.Braid) { return ''; diff --git a/src/app/workspace/worldline-phase-view.ts b/src/app/workspace/worldline-phase-view.ts index ffbff1d8..32313685 100644 --- a/src/app/workspace/worldline-phase-view.ts +++ b/src/app/workspace/worldline-phase-view.ts @@ -11,6 +11,7 @@ import { type WorkspaceWorldlineGraphNode, type WorkspaceWorldlinePhaseKind, } from './worldline-types.js'; +import { workspaceWorldlineTickSpanLabel } from './worldline-tick-span.js'; const TITLE_KEY = 'worldline.title'; const EMPTY_LENGTH = 0; @@ -33,9 +34,6 @@ const BRAID_RAIL = 'B'; const LOCAL_RAIL = 'L'; const PROJECTION_PREFIX = 'projection:'; const CANONICAL_PREFIX = 'canonical@t'; -const TICK_PREFIX = 'tick:t'; -const TICKS_PREFIX = 'ticks:t'; -const TICK_SPAN_SEPARATOR = '->t'; const LOCAL_NAME = 'local'; const VISIBLE_BRAID_NAME = 'visible braid'; const LOCAL_SPAN = 'local'; @@ -112,7 +110,7 @@ function graphNodePhaseRow( name: node.name, basis: node.basis, head: node.headTick == null ? NO_HEAD : String(node.headTick), - span: worldlineSpanLabel(node), + span: workspaceWorldlineTickSpanLabel(node) ?? NO_EVIDENCE, evidence: node.headTick == null ? NO_EVIDENCE : `${CANONICAL_PREFIX}${node.headTick}`, note: graphNodeNote(node), }; @@ -238,19 +236,6 @@ function canonicalRef(model: WorkspaceModel): string { return `${CANONICAL_PREFIX}${model.worldline.canonicalHeadTick}`; } -function worldlineSpanLabel(node: WorkspaceWorldlineGraphNode): string { - if (node.basisTick == null && node.headTick == null) { - return NO_EVIDENCE; - } - if (node.basisTick == null) { - return `${TICK_PREFIX}${node.headTick}`; - } - if (node.headTick == null || node.basisTick === node.headTick) { - return `${TICK_PREFIX}${node.basisTick}`; - } - return `${TICKS_PREFIX}${node.basisTick}${TICK_SPAN_SEPARATOR}${node.headTick}`; -} - function renderPhaseRow(row: WorldlinePhaseRow): string { return [ cell(row.selected ? SELECTED_MARKER : UNSELECTED_MARKER, SELECTED_CELL_WIDTH), diff --git a/src/app/workspace/worldline-tick-span.ts b/src/app/workspace/worldline-tick-span.ts new file mode 100644 index 00000000..a0c362af --- /dev/null +++ b/src/app/workspace/worldline-tick-span.ts @@ -0,0 +1,26 @@ +const TICK_PREFIX = 'tick:t'; +const TICKS_PREFIX = 'ticks:t'; +const TICK_SPAN_SEPARATOR = '->t'; + +export interface WorkspaceWorldlineTickSpan { + readonly basisTick?: number; + readonly observedTick?: number; + readonly headTick?: number; +} + +export function workspaceWorldlineTickSpanLabel( + span: WorkspaceWorldlineTickSpan, +): string | undefined { + const basisTick = span.observedTick ?? span.basisTick; + const headTick = span.observedTick ?? span.headTick; + if (basisTick == null && headTick == null) { + return undefined; + } + if (basisTick == null) { + return `${TICK_PREFIX}${headTick}`; + } + if (headTick == null || basisTick === headTick) { + return `${TICK_PREFIX}${basisTick}`; + } + return `${TICKS_PREFIX}${basisTick}${TICK_SPAN_SEPARATOR}${headTick}`; +} diff --git a/src/ui/jedit-theme-palettes.ts b/src/ui/jedit-theme-palettes.ts index 639738e8..113fab5d 100644 --- a/src/ui/jedit-theme-palettes.ts +++ b/src/ui/jedit-theme-palettes.ts @@ -104,11 +104,11 @@ const SOLARIZED_DARK_PALETTE: ThemePalette = { const SOLARIZED_LIGHT_PALETTE: ThemePalette = { ink: [79, 98, 104], - muted: [147, 161, 161], + muted: [88, 110, 117], accent: [211, 54, 130], info: [38, 139, 210], - warning: [181, 137, 0], - success: [133, 153, 0], + warning: [165, 112, 0], + success: [112, 137, 0], surface: [253, 246, 227], surfaceRaised: [238, 232, 213], surfaceMuted: [238, 232, 213], diff --git a/src/ui/jedit-themes.ts b/src/ui/jedit-themes.ts index 583dd080..91445ef1 100644 --- a/src/ui/jedit-themes.ts +++ b/src/ui/jedit-themes.ts @@ -35,6 +35,20 @@ const ACTIVE_EDGE_CHAR = "░"; const THEME_MODE_LABEL_DARK = "Dark"; const THEME_MODE_LABEL_LIGHT = "Light"; const COLOR_CHANNEL_MAX = 255; +const COLOR_CHANNEL_MIN = 0; +const CONTRAST_BLEND_STEPS = 20; +const CONTRAST_LUMINANCE_OFFSET = 0.05; +const CONTRAST_TARGET_LIGHTNESS = 0.5; +const LUMINANCE_BLUE_WEIGHT = 0.0722; +const LUMINANCE_GREEN_WEIGHT = 0.7152; +const LUMINANCE_RED_WEIGHT = 0.2126; +const MIN_ACCENT_CONTRAST_RATIO = 3; +const MIN_SURFACE_TEXT_CONTRAST_RATIO = 4.5; +const SRGB_LINEAR_BREAKPOINT = 0.03928; +const SRGB_LINEAR_DIVISOR = 12.92; +const SRGB_LINEAR_EXPONENT = 2.4; +const SRGB_LINEAR_OFFSET = 0.055; +const SRGB_LINEAR_SCALE = 1.055; interface ThemeVariables { readonly ink: ThemeColorVariable; @@ -223,7 +237,7 @@ function oppositeThemeMode(mode: JeditThemeMode): JeditThemeMode { } function oppositePalette(palette: ThemePalette): ThemePalette { - return { + return contrastAdjustedPalette({ ink: invertColor(palette.ink), muted: invertColor(palette.muted), accent: invertColor(palette.accent), @@ -233,9 +247,98 @@ function oppositePalette(palette: ThemePalette): ThemePalette { surface: invertColor(palette.surface), surfaceRaised: invertColor(palette.surfaceRaised), surfaceMuted: invertColor(palette.surfaceMuted), + }); +} + +function contrastAdjustedPalette(palette: ThemePalette): ThemePalette { + return { + ink: contrastAdjustedColor(palette.ink, surfaceBackgrounds(palette), MIN_SURFACE_TEXT_CONTRAST_RATIO), + muted: contrastAdjustedColor(palette.muted, [palette.surface], MIN_ACCENT_CONTRAST_RATIO), + accent: contrastAdjustedColor(palette.accent, [palette.surface], MIN_ACCENT_CONTRAST_RATIO), + info: contrastAdjustedColor(palette.info, [palette.surface, palette.surfaceRaised], MIN_ACCENT_CONTRAST_RATIO), + warning: contrastAdjustedColor(palette.warning, [palette.surface, palette.surfaceRaised], MIN_ACCENT_CONTRAST_RATIO), + success: contrastAdjustedColor(palette.success, [palette.surface], MIN_ACCENT_CONTRAST_RATIO), + surface: palette.surface, + surfaceRaised: palette.surfaceRaised, + surfaceMuted: palette.surfaceMuted, }; } +function surfaceBackgrounds(palette: ThemePalette): readonly RgbTuple[] { + return [palette.surface, palette.surfaceRaised, palette.surfaceMuted]; +} + +function contrastAdjustedColor( + color: RgbTuple, + backgrounds: readonly RgbTuple[], + minContrastRatio: number, +): RgbTuple { + const target = averageLuminance(backgrounds) > CONTRAST_TARGET_LIGHTNESS + ? colorTarget(COLOR_CHANNEL_MIN) + : colorTarget(COLOR_CHANNEL_MAX); + for (let step = 0; step <= CONTRAST_BLEND_STEPS; step += 1) { + const candidate = blendColor(color, target, step / CONTRAST_BLEND_STEPS); + if (passesContrast(candidate, backgrounds, minContrastRatio)) { + return candidate; + } + } + return target; +} + +function averageLuminance(colors: readonly RgbTuple[]): number { + const total = colors.reduce((sum, color) => sum + relativeColorLuminance(color), 0); + return total / colors.length; +} + +function colorTarget(channel: number): RgbTuple { + return [channel, channel, channel]; +} + +function blendColor(from: RgbTuple, to: RgbTuple, amount: number): RgbTuple { + return [ + blendChannel(from[0], to[0], amount), + blendChannel(from[1], to[1], amount), + blendChannel(from[2], to[2], amount), + ]; +} + +function blendChannel(from: number, to: number, amount: number): number { + return Math.round(from + ((to - from) * amount)); +} + +function passesContrast( + color: RgbTuple, + backgrounds: readonly RgbTuple[], + minContrastRatio: number, +): boolean { + return backgrounds.every( + (background) => colorContrastRatio(color, background) >= minContrastRatio, + ); +} + +function colorContrastRatio(first: RgbTuple, second: RgbTuple): number { + const firstLuminance = relativeColorLuminance(first); + const secondLuminance = relativeColorLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET); +} + +function relativeColorLuminance(color: RgbTuple): number { + return ( + linearChannel(color[0]) * LUMINANCE_RED_WEIGHT + + linearChannel(color[1]) * LUMINANCE_GREEN_WEIGHT + + linearChannel(color[2]) * LUMINANCE_BLUE_WEIGHT + ); +} + +function linearChannel(channel: number): number { + const scaled = channel / COLOR_CHANNEL_MAX; + return scaled <= SRGB_LINEAR_BREAKPOINT + ? scaled / SRGB_LINEAR_DIVISOR + : ((scaled + SRGB_LINEAR_OFFSET) / SRGB_LINEAR_SCALE) ** SRGB_LINEAR_EXPONENT; +} + function paletteFromTheme(theme: JeditTheme): ThemePalette { return { ink: variableRgb(theme, VARIABLE_INK, [226, 231, 236]), diff --git a/src/ui/source-viewer.ts b/src/ui/source-viewer.ts index 7a7650ee..511779c8 100644 --- a/src/ui/source-viewer.ts +++ b/src/ui/source-viewer.ts @@ -1,6 +1,6 @@ import type { Cell, Surface } from '@flyingrobots/bijou'; import type { SourceHighlightReading } from '../ports/source-highlighter.js'; -import type { JeditStyleToken, JeditTheme } from './jedit-theme.js'; +import { JEDIT_SOURCE_TOKEN, type JeditStyleToken, type JeditTheme } from './jedit-theme.js'; import { SOURCE_LINE_NUMBER_MODE, type SourceLineNumberMode, @@ -154,7 +154,9 @@ function paintSourceViewerGutter( context: SourceViewerGutterPaintContext, ): void { const { reading, options, gutter, cursorRow, mode } = context; - const numberToken = options.theme.surface.workspace; + const numberToken = + options.theme.source.get(JEDIT_SOURCE_TOKEN.Comment) ?? + options.theme.chrome.titleLogoShadow; const ruleToken = options.theme.chrome.activeEdge; const baseToken = options.theme.surface.workspace; const numberStyle = cellStyle(numberToken, baseToken);