diff --git a/BEARING.md b/BEARING.md index 08a48055..ec202e17 100644 --- a/BEARING.md +++ b/BEARING.md @@ -14,14 +14,16 @@ timeline ## Current State -`v6.5.4` shipped on `2026-07-26`. Application asset, bundle, page, cache, +`v6.5.5` shipped on `2026-07-26`. Application asset, bundle, page, cache, expiry, witness, and repository-diagnostics APIs sit above mutable root sets and the low-level CAS pipeline. Direct bundle-reference reads and bounded immutable metadata/page reuse, bounded Git object sessions, page batches, and -deterministic resource closure are published. The versioned v6.5.5 candidate -makes internal commits independent of ambient Git identity. npm plus GitHub -Releases are the active publication surfaces. JSR validation is healthy, but -JSR publication remains outside the release workflow. +deterministic resource closure are published. Internal commits are independent +of ambient Git identity. The active v6.5.6 cycle updates the coherent Bijou +family to 7.2.0 and moves the interactive cockpit onto Bijou's hosted framed-app +boundary. npm plus GitHub Releases are the active publication surfaces. JSR +validation is healthy, but JSR publication remains outside the release +workflow. What exists now: @@ -82,9 +84,13 @@ What exists now: `workspace.pages.putBatch()` so one bounded ordered page group is written and retained under one exact workspace generation instead of rewriting a growing root set for every page. -- **Self-contained internal commit identity.** The v6.5.5 candidate supplies a +- **Self-contained internal commit identity.** v6.5.5 supplies a stable git-cas author and committer for root-set, publication, and vault commits without mutating repository or global Git configuration. +- **Bijou 7 framed-cockpit candidate.** The active v6.5.6 cycle updates the + complete Bijou family and makes the frame the sole owner of terminal + lifecycle, outer chrome, help, command/search palettes, settings, + notifications, performance telemetry, and quit confirmation. - **Migration script.** `scripts/migrate-encryption.js` upgrades legacy v1/v2 manifests to the current scheme identifiers. @@ -140,16 +146,16 @@ These were the active tensions from the previous bearing. All resolved. ## Next Horizon -With v6.5.4 shipped and the versioned v6.5.5 release candidate being prepared, -active work is tracked in GitHub Issues and Milestones. Repo docs hold design -and evidence records, not the active queue. +With v6.5.5 shipped and the v6.5.6 framed-cockpit cycle being prepared, active +work is tracked in GitHub Issues and Milestones. Repo docs hold design and +evidence records, not the active queue. -The latest landed design is -[0055-internal-commit-identity](./docs/design/0055-internal-commit-identity/internal-commit-identity.md). -Its release evidence is attached to -[#102](https://github.com/git-stunts/git-cas/issues/102) and the -[`v6.5.5` milestone](https://github.com/git-stunts/git-cas/milestone/15). Tag -and publication evidence remain pending; no later design is selected here. +The active design is +[0056-bijou-7-framed-cockpit](./docs/design/0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md). +Its goalpost and hosted-shell slice are +[#105](https://github.com/git-stunts/git-cas/issues/105) and +[#106](https://github.com/git-stunts/git-cas/issues/106) in the +[`v6.5.6` milestone](https://github.com/git-stunts/git-cas/milestone/16). The broader horizon remains: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8725362c..26b6e9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Bijou 7 framed cockpit** - updates the coherent Bijou dependency family to + 7.2.0 and moves terminal lifecycle, shell chrome, help, command/search + palettes, settings, notifications, performance telemetry, and quit + confirmation to the hosted `createFramedApp()` boundary. + +### Fixed + +- **Shared progress cursor ownership** - store and restore progress now use + Bijou's reference-counted cursor guard and render the initial zero state + without counting it as a processed chunk. +- **Audited cockpit contrast** - foreground/background choices selected with + Design Book are registered as semantic theme tokens and verified at WCAG AA + contrast by Bijou's theme doctor. + ## [6.5.5] — 2026-07-26 ### Fixed diff --git a/README.md b/README.md index 9af316ce..ab1dab89 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ Navigate your stored assets through the reader-first interactive dashboard. git-cas vault dashboard ``` +The framed cockpit keeps Explorer, Atlas, and Operations in one shared session. +Use `/` to search assets, `Ctrl+P` for the command palette, `?` for keyboard +help, `F2` for session settings, and `q` for a confirmed exit. Noninteractive +invocations retain the plain tab-separated listing. + ### 3. Library Ingress Integrate managed blob storage directly into your TypeScript or JavaScript application. diff --git a/ROADMAP.md b/ROADMAP.md index 075b68d7..0ef49c71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,7 @@ GitHub wins and this file should be corrected. | [`v6.5.3`](https://github.com/git-stunts/git-cas/milestone/13) | Coherent Git object sessions across immutable writes | [#94](https://github.com/git-stunts/git-cas/issues/94) | | [`v6.5.4`](https://github.com/git-stunts/git-cas/milestone/14) | Batched staging-workspace page retention | [#99](https://github.com/git-stunts/git-cas/issues/99) | | [`v6.5.5`](https://github.com/git-stunts/git-cas/milestone/15) | Identity-independent internal Git commits | [#102](https://github.com/git-stunts/git-cas/issues/102) | +| [`v6.5.6`](https://github.com/git-stunts/git-cas/milestone/16) | Bijou 7 framed cockpit | [#105](https://github.com/git-stunts/git-cas/issues/105), [#106](https://github.com/git-stunts/git-cas/issues/106) | | [`v6.6.0`](https://github.com/git-stunts/git-cas/milestone/9) | Operator TUI and agent automation follow-through | [#39](https://github.com/git-stunts/git-cas/issues/39), [#40](https://github.com/git-stunts/git-cas/issues/40) | | [`v6.7.0`](https://github.com/git-stunts/git-cas/milestone/10) | Browser and edge read-path exploration | [#41](https://github.com/git-stunts/git-cas/issues/41) | | [`v7.0.0`](https://github.com/git-stunts/git-cas/milestone/6) | Protocol break only if audit requires it | [#42](https://github.com/git-stunts/git-cas/issues/42), only when justified | diff --git a/bin/ui/context.js b/bin/ui/context.js index f0019fbc..f1f6c89f 100644 --- a/bin/ui/context.js +++ b/bin/ui/context.js @@ -3,7 +3,7 @@ */ import { createBijou } from '@flyingrobots/bijou'; -import { nodeRuntime, nodeIO, chalkStyle } from '@flyingrobots/bijou-node'; +import { chalkStyle, createNodeContext, nodeIO, nodeRuntime } from '@flyingrobots/bijou-node'; import { GIT_CAS_THEME } from './theme.js'; /** @type {import('@flyingrobots/bijou').BijouContext | null} */ @@ -19,13 +19,12 @@ export function getCliContext() { if (ctx) { return ctx; } - const runtime = nodeRuntime(); - const noColor = runtime.env('NO_COLOR') !== undefined; - ctx = createBijou({ - runtime, - io: stderrIO(), - style: chalkStyle(noColor), + ctx = createNodeContext({ theme: GIT_CAS_THEME, + nodeIO: { + stdout: process.stderr, + stderr: process.stderr, + }, }); return ctx; } @@ -71,52 +70,20 @@ export function detectCliTuiMode(runtime) { export function createCliTuiContext(options = {}) { const runtime = options.runtime || nodeRuntime(); const noColor = runtime.env('NO_COLOR') !== undefined; - const base = createBijou({ - runtime, - io: options.io || nodeIO(), - style: options.style || chalkStyle(noColor), - theme: GIT_CAS_THEME, - }); + const base = + options.runtime || options.style + ? createBijou({ + runtime, + io: options.io || nodeIO(), + style: options.style || chalkStyle({ noColor }), + theme: GIT_CAS_THEME, + }) + : createNodeContext({ + theme: GIT_CAS_THEME, + ...(options.io ? { io: options.io } : {}), + }); return { ...base, mode: detectCliTuiMode(runtime), }; } - -/** - * @returns {import('@flyingrobots/bijou').IOPort} - */ -function stderrIO() { - return { - /** @param {string} data */ - write(data) { - process.stderr.write(data); - }, - question() { - throw new Error('question() not supported in CLI context'); - }, - rawInput() { - throw new Error('rawInput() not supported in CLI context'); - }, - /** @param {(cols: number, rows: number) => void} callback */ - onResize(callback) { - const handler = () => { - callback(process.stderr.columns ?? 80, process.stderr.rows ?? 24); - }; - process.stderr.on('resize', handler); - return { dispose() { process.stderr.removeListener('resize', handler); } }; - }, - /** - * @param {() => void} callback - * @param {number} ms - */ - setInterval(callback, ms) { - const id = globalThis.setInterval(callback, ms); - return { dispose() { globalThis.clearInterval(id); } }; - }, - readFile() { throw new Error('readFile() not supported'); }, - readDir() { throw new Error('readDir() not supported'); }, - /** @param {string[]} segments */ - joinPath(...segments) { return segments.join('/'); }, - }; -} diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index ae5723ee..a16c4401 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -5,18 +5,14 @@ import { boxSurface, createSurface, parseAnsiToSurface } from '@flyingrobots/bijou'; import { canvas, - commandPaletteSurface, - drawer, hstackSurface, - modal, navigableTableSurface, placeSurface, - renderNotificationStack, statusBarSurface, vstackSurface, } from '@flyingrobots/bijou-tui'; import { renderRepoTreemapMap, renderRepoTreemapSidebar } from './repo-treemap.js'; -import { shellRule, themeText } from './theme.js'; +import { GIT_CAS_THEME, shellRule, themeText } from './theme.js'; import { renderChunkTable, renderMerkleExplorer } from './blocks/merkle-explorer.js'; import { renderHealthDashboard } from './blocks/health-dashboard.js'; import { renderOperationFeed } from './blocks/operation-feed.js'; @@ -28,12 +24,17 @@ import { shortShaStatus } from './components/short-sha.js'; /** @typedef {import('./dashboard.js').DashDeps} DashDeps */ /** @typedef {import('./dashboard.js').DashSource} DashSource */ -const BG = '#25313a'; -const HELP_WIDTH = 58; +function uiColor(ctx, tokenName) { + return ctx.theme.theme.ui[tokenName]?.hex ?? GIT_CAS_THEME.ui[tokenName].hex; +} function sourceLabel(source) { - if (source.type === 'vault') { return 'vault'; } - if (source.type === 'ref') { return `ref ${source.ref}`; } + if (source.type === 'vault') { + return 'vault'; + } + if (source.type === 'ref') { + return `ref ${source.ref}`; + } return `oid ${source.treeOid}`; } @@ -41,13 +42,21 @@ function textSurface(content, width, height) { return parseAnsiToSurface(content, Math.max(1, width), Math.max(1, height)); } -function blank(width, height) { - return createSurface(Math.max(1, width), Math.max(1, height), { char: ' ', bg: BG }); +function blank(ctx, width, height) { + return createSurface(Math.max(1, width), Math.max(1, height), { + char: ' ', + bg: uiColor(ctx, 'canvas'), + }); } function verticalRail(ctx, height) { - return textSurface(Array.from({ length: Math.max(1, height) }, () => - themeText(ctx, '│', { tone: 'accent', bold: true })).join('\n'), 1, height); + return textSurface( + Array.from({ length: Math.max(1, height) }, () => + themeText(ctx, '│', { tone: 'accent', bold: true }) + ).join('\n'), + 1, + height + ); } function panel({ title, body, width, height, ctx, active = false }) { @@ -55,9 +64,10 @@ function panel({ title, body, width, height, ctx, active = false }) { const safeHeight = Math.max(3, height); const innerWidth = Math.max(1, safeWidth - 2); const innerHeight = Math.max(1, safeHeight - 2); - const bodySurface = typeof body === 'string' - ? textSurface(body, innerWidth, innerHeight) - : placeSurface(body, { width: innerWidth, height: innerHeight }); + const bodySurface = + typeof body === 'string' + ? textSurface(body, innerWidth, innerHeight) + : placeSurface(body, { width: innerWidth, height: innerHeight }); const boxed = boxSurface(bodySurface, { ctx, title, @@ -66,7 +76,9 @@ function panel({ title, body, width, height, ctx, active = false }) { bgToken: ctx.theme.theme.surface.primary, }); const placed = placeSurface(boxed, { width: safeWidth, height: safeHeight }); - if (active) { placed.blit(verticalRail(ctx, safeHeight), 0, 0); } + if (active) { + placed.blit(verticalRail(ctx, safeHeight), 0, 0); + } return placed; } @@ -87,10 +99,18 @@ function metricLine(model, ctx) { } function formatSize(bytes) { - if (!Number.isFinite(bytes)) { return '-'; } - if (bytes < 1024) { return `${bytes}B`; } - if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; } - if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; } + if (!Number.isFinite(bytes)) { + return '-'; + } + if (bytes < 1024) { + return `${bytes}B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}K`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)}M`; + } return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; } @@ -100,56 +120,41 @@ function workspaceTabs(model, ctx) { ['atlas', '2 Atlas'], ['operations', '3 Operations'], ]; - return tabs.map(([id, label]) => { - const active = model.workspace === id; - return themeText(ctx, active ? `▌ ${label}` : ` ${label}`, { tone: active ? 'brand' : 'secondary', bold: active }); - }).join(' '); -} - -function renderHeaderSurface(model, deps, width) { - const left = themeText(deps.ctx, ' git-cas cockpit', { tone: 'brand', bold: true }); - const center = workspaceTabs(model, deps.ctx); - const right = model.gitBranch ? themeText(deps.ctx, model.gitBranch, { tone: 'info' }) : themeText(deps.ctx, model.status, { tone: 'secondary' }); - const first = statusBarSurface({ left, center, right, width }); + return tabs + .map(([id, label]) => { + const active = model.workspace === id; + return themeText(ctx, active ? `▌ ${label}` : ` ${label}`, { + tone: active ? 'brand' : 'secondary', + bold: active, + }); + }) + .join(' '); +} + +function renderWorkspaceSurface(model, deps, width) { + const left = workspaceTabs(model, deps.ctx); + const right = model.gitBranch + ? themeText(deps.ctx, model.gitBranch, { tone: 'info' }) + : themeText(deps.ctx, model.status, { tone: 'secondary' }); + const first = statusBarSurface({ left: ` ${left}`, right, width }); const economics = statusBarSurface({ left: ` ${metricLine(model, deps.ctx)}`, right: themeText(deps.ctx, sourceLabel(model.source), { tone: 'primary' }), width, }); - const filter = model.filtering - ? `${themeText(deps.ctx, 'filter', { tone: 'accent' })} /${model.filterText}█` - : `${themeText(deps.ctx, 'filter', { tone: 'accent' })} ${model.filterText ? `/${model.filterText}` : 'none'}`; + const search = `${themeText(deps.ctx, 'search', { tone: 'accent' })} ${model.filterText || 'all assets'}`; + const detailStatus = selectedShaFooter(model); const third = statusBarSurface({ - left: ` ${filter}`, - right: model.error ? themeText(deps.ctx, model.error, { tone: 'danger' }) : '', + left: ` ${search}`, + right: model.error + ? themeText(deps.ctx, model.error, { tone: 'danger' }) + : themeText(deps.ctx, detailStatus ?? '', { tone: 'info' }), width, }); const rule = textSurface(shellRule(deps.ctx, width), width, 1); return vstackSurface(first, economics, third, rule); } -function renderFooterSurface(model, ctx, width) { - const left = model.quitConfirm - ? themeText(ctx, 'Quit? y/enter confirm, n/escape cancel', { tone: 'warning' }) - : themeText(ctx, '? help / filter ctrl+p palette F2 settings q quit', { tone: 'subdued' }); - const rightByWorkspace = { - explorer: model.focusPane === 'detail' - ? 'tab focus j/k chunk u/d page enter ledger' - : 'tab focus j/k rows enter detail m merkle', - atlas: 'j/k focus +/- drill t scope i ignored', - operations: 'n store s stats x doctor', - }; - const detailStatus = selectedShaFooter(model); - const footer = statusBarSurface({ - left: ` ${left}`, - right: detailStatus - ? themeText(ctx, detailStatus, { tone: 'info' }) - : themeText(ctx, rightByWorkspace[model.workspace], { tone: 'secondary' }), - width, - }); - return vstackSurface(textSurface(shellRule(ctx, width), width, 1), footer); -} - export function tableSchema(width) { if (width >= 110) { const slugWidth = Math.max(18, Math.min(36, width - 82)); @@ -188,42 +193,53 @@ export function tableSchema(width) { }; } -export function renderDashboard(model, deps) { - if (model.phase === 'title' || model.phase === 'password') { - return renderTitleScreen(model, deps); +export function renderDashboard(model, deps, options = {}) { + const renderModel = { + ...model, + columns: options.width ?? model.columns, + rows: options.height ?? model.rows, + }; + if (renderModel.phase === 'title' || renderModel.phase === 'password') { + return renderTitleScreen(renderModel, deps); } - const width = Math.max(1, model.columns); - const height = Math.max(1, model.rows); - const screen = createSurface(width, height, { char: ' ', bg: BG }); - const header = renderHeaderSurface(model, deps, width); - const footer = renderFooterSurface(model, deps.ctx, width); - const bodyHeight = Math.max(1, height - header.height - footer.height); - const body = renderBody(model, deps, { width: Math.max(1, width - 1), height: bodyHeight }); - - screen.blit(vstackSurface(header, hstackSurface(0, verticalRail(deps.ctx, bodyHeight), body), footer), 0, 0); - renderOverlays(model, deps, { screen, width, height }); + const width = Math.max(1, renderModel.columns); + const height = Math.max(1, renderModel.rows); + const screen = createSurface(width, height, { + char: ' ', + bg: uiColor(deps.ctx, 'canvas'), + }); + const header = renderWorkspaceSurface(renderModel, deps, width); + const bodyHeight = Math.max(1, height - header.height); + const body = renderBody(renderModel, deps, { width: Math.max(1, width - 1), height: bodyHeight }); + + screen.blit( + vstackSurface(header, hstackSurface(0, verticalRail(deps.ctx, bodyHeight), body)), + 0, + 0 + ); + renderPageOverlay(renderModel, deps, { screen, width, height }); return screen; } function renderTitleScreen(model, deps) { const { columns: width, rows: height } = model; - const screen = createSurface(width, height, { char: ' ', bg: BG }); + const screen = createSurface(width, height, { + char: ' ', + bg: uiColor(deps.ctx, 'canvas'), + }); const { ctx } = deps; const innerW = Math.min(56, width); - screen.blit(canvas(width, height, organicFlowShader, { - time: (model.titleTimeMs ?? 0) / 1000, - resolution: 'braille', - uniforms: { entryCount: model.vaultEntryCount, width, height }, - }), 0, 0); - - if (model.showPerfHud) { - const fpsText = ` ${model.fps} FPS `; - const hud = parseAnsiToSurface(fpsText, fpsText.length, 1); - hud.fill({ char: ' ', bg: '#000000', fg: '#ffffff' }); - screen.blit(hud, 0, 0); - } + screen.blit( + canvas(width, height, organicFlowShader, { + time: (model.titleTimeMs ?? 0) / 1000, + resolution: 'braille', + uniforms: { entryCount: model.vaultEntryCount, width, height }, + }), + 0, + 0 + ); const surfaces = titlePanelSurfaces(model, ctx, innerW); @@ -234,7 +250,11 @@ function renderTitleScreen(model, deps) { borderToken: ctx.theme.theme.border.primary, bgToken: ctx.theme.theme.surface.overlay, }); - screen.blit(titlePanel, Math.max(0, Math.floor((width - titlePanel.width) / 2)), Math.max(0, Math.floor((height - titlePanel.height) / 3))); + screen.blit( + titlePanel, + Math.max(0, Math.floor((width - titlePanel.width) / 2)), + Math.max(0, Math.floor((height - titlePanel.height) / 3)) + ); return screen; } @@ -254,7 +274,14 @@ function titlePhaseSurfaces(model, ctx, width) { const tone = model.promptEnter ? 'success' : 'subdued'; const surfaces = [textSurface(themeText(ctx, status, { tone }), width, 1)]; if (model.promptEnter) { - surfaces.push(createSurface(1, 1), textSurface(themeText(ctx, 'enter to continue | escape to quit', { tone: 'subdued' }), width, 1)); + surfaces.push( + createSurface(1, 1), + textSurface( + themeText(ctx, 'enter to continue | escape to quit', { tone: 'subdued' }), + width, + 1 + ) + ); } return surfaces; } @@ -262,20 +289,43 @@ function titlePhaseSurfaces(model, ctx, width) { function passwordPhaseSurfaces(model, ctx, width) { const mask = '•'.repeat(model.passphrase.length); const surfaces = [ - textSurface(themeText(ctx, 'Vault is encrypted. Enter passphrase to unlock.', { tone: 'warning' }), width, 1), + textSurface( + themeText(ctx, 'Vault is encrypted. Enter passphrase to unlock.', { tone: 'warning' }), + width, + 1 + ), createSurface(1, 1), - hstackSurface(1, createSurface(2, 1), textSurface(themeText(ctx, 'Passphrase:', { tone: 'accent' }), 11, 1), textSurface(`${mask}█`, Math.max(1, width - 14), 1)), + hstackSurface( + 1, + createSurface(2, 1), + textSurface(themeText(ctx, 'Passphrase:', { tone: 'accent' }), 11, 1), + textSurface(`${mask}█`, Math.max(1, width - 14), 1) + ), ]; if (model.authError) { - surfaces.push(createSurface(1, 1), hstackSurface(1, createSurface(2, 1), textSurface(themeText(ctx, model.authError, { tone: 'danger' }), Math.max(1, width - 3), 1))); + surfaces.push( + createSurface(1, 1), + hstackSurface( + 1, + createSurface(2, 1), + textSurface(themeText(ctx, model.authError, { tone: 'danger' }), Math.max(1, width - 3), 1) + ) + ); } - surfaces.push(createSurface(1, 1), textSurface(themeText(ctx, 'enter to unlock | escape to quit', { tone: 'subdued' }), width, 1)); + surfaces.push( + createSurface(1, 1), + textSurface(themeText(ctx, 'enter to unlock | escape to quit', { tone: 'subdued' }), width, 1) + ); return surfaces; } function renderBody(model, deps, options) { - if (model.workspace === 'atlas') { return renderAtlasWorkspace(model, deps, options); } - if (model.workspace === 'operations') { return renderOperationsWorkspace(model, deps, options); } + if (model.workspace === 'atlas') { + return renderAtlasWorkspace(model, deps, options); + } + if (model.workspace === 'operations') { + return renderOperationsWorkspace(model, deps, options); + } return renderExplorerWorkspace(model, deps, options); } @@ -285,8 +335,8 @@ function renderExplorerWorkspace(model, deps, options) { const detailHeight = Math.max(4, options.height - ledgerHeight - 1); return vstackSurface( renderLedgerPanel(model, deps.ctx, { width: options.width, height: ledgerHeight }), - blank(options.width, 1), - renderInspectorPanel(model, deps.ctx, { width: options.width, height: detailHeight }), + blank(deps.ctx, options.width, 1), + renderInspectorPanel(model, deps.ctx, { width: options.width, height: detailHeight }) ); } const gap = 2; @@ -295,26 +345,40 @@ function renderExplorerWorkspace(model, deps, options) { return hstackSurface( gap, renderLedgerPanel(model, deps.ctx, { width: ledgerWidth, height: options.height }), - renderInspectorPanel(model, deps.ctx, { width: inspectorWidth, height: options.height }), + renderInspectorPanel(model, deps.ctx, { width: inspectorWidth, height: options.height }) ); } function renderLedgerPanel(model, ctx, box) { const { width, height } = box; const schema = tableSchema(Math.max(20, width - 2)); - const projectedRows = model.table.rows.map((row) => schema.indexes.map((index) => row[index] ?? '')); - const tableSurface = navigableTableSurface({ - columns: schema.columns, - rows: projectedRows, - focusRow: model.table.focusRow, - scrollY: model.table.scrollY, - height: Math.max(1, height - 5), - }, { ctx }); - return panel({ title: 'Asset Ledger', body: tableSurface, width, height, ctx, active: model.focusPane === 'ledger' }); + const projectedRows = model.table.rows.map((row) => + schema.indexes.map((index) => row[index] ?? '') + ); + const tableSurface = navigableTableSurface( + { + columns: schema.columns, + rows: projectedRows, + focusRow: model.table.focusRow, + scrollY: model.table.scrollY, + height: Math.max(1, height - 5), + }, + { ctx } + ); + return panel({ + title: 'Asset Ledger', + body: tableSurface, + width, + height, + ctx, + active: model.focusPane === 'ledger', + }); } function selectedEntry(model) { - return model.filtered[Math.min(model.table.focusRow, Math.max(0, model.filtered.length - 1))] ?? null; + return ( + model.filtered[Math.min(model.table.focusRow, Math.max(0, model.filtered.length - 1))] ?? null + ); } function manifestData(manifest) { @@ -333,9 +397,13 @@ function selectedChunk(model) { } function selectedShaFooter(model) { - if (model.workspace !== 'explorer' || model.focusPane !== 'detail') { return null; } + if (model.workspace !== 'explorer' || model.focusPane !== 'detail') { + return null; + } const chunk = selectedChunk(model); - if (!chunk) { return null; } + if (!chunk) { + return null; + } const digest = shortShaStatus('digest', chunk.digest); const blob = shortShaStatus('blob', chunk.blob); return [`#${chunk.index}`, digest, blob].filter(Boolean).join(' '); @@ -346,7 +414,14 @@ function renderInspectorPanel(model, ctx, box) { const entry = selectedEntry(model); const manifest = selectedManifest(model); if (!entry) { - return panel({ title: 'Inspector', body: 'No asset selected.', width, height, ctx, active: model.focusPane === 'detail' }); + return panel({ + title: 'Inspector', + body: 'No asset selected.', + width, + height, + ctx, + active: model.focusPane === 'detail', + }); } if (model.explorerMode === 'merkle') { return panel({ @@ -362,9 +437,23 @@ function renderInspectorPanel(model, ctx, box) { const body = manifest ? renderManifestDetail({ model, manifest, ctx, box: { width, height } }) : `Loading manifest for ${entry.slug}...\n${entry.treeOid}`; - return panel({ title: 'Manifest Ledger', body, width, height, ctx, active: model.focusPane === 'detail' }); + return panel({ + title: 'Manifest Ledger', + body, + width, + height, + ctx, + active: model.focusPane === 'detail', + }); } - return panel({ title: 'Inspector', body: renderInspectorBody(entry, manifest, ctx), width, height, ctx, active: model.focusPane === 'detail' }); + return panel({ + title: 'Inspector', + body: renderInspectorBody(entry, manifest, ctx), + width, + height, + ctx, + active: model.focusPane === 'detail', + }); } function renderInspectorBody(entry, manifest, ctx) { @@ -383,7 +472,9 @@ function renderInspectorBody(entry, manifest, ctx) { `${themeText(ctx, 'compress', { tone: 'accent' })} ${manifest.compression?.algorithm ?? 'none'}`, `${themeText(ctx, 'format', { tone: 'accent' })} ${manifest.formatVersion ?? manifest.version ?? '-'}`, '', - themeText(ctx, 'enter detail | tab detail focus | ctrl+p digest search', { tone: 'subdued' }), + themeText(ctx, 'enter detail | tab detail focus | ctrl+p digest search', { + tone: 'subdued', + }) ); return lines.join('\n'); } @@ -404,18 +495,26 @@ function renderManifestDetail({ model, manifest, ctx, box }) { '', themeText(ctx, `Chunk Ledger (${chunks.length})`, { tone: 'info', bold: true }), ]; - if (chunks.length === 0) { return lines.concat('No chunks.').join('\n'); } - return lines.concat(renderChunkTable(manifest, ctx, { - selectedIndex: Math.min(model.chunkFocus ?? 0, chunks.length - 1), - pageSize: pageSizeForDetail(box.height), - width: Math.max(24, box.width - 4), - })).join('\n'); + if (chunks.length === 0) { + return lines.concat('No chunks.').join('\n'); + } + return lines + .concat( + renderChunkTable(manifest, ctx, { + selectedIndex: Math.min(model.chunkFocus ?? 0, chunks.length - 1), + pageSize: pageSizeForDetail(box.height), + width: Math.max(24, box.width - 4), + }) + ) + .join('\n'); } function renderMerkleBody({ model, manifest, ctx, box }) { - if (!manifest) { return 'No manifest loaded.'; } + if (!manifest) { + return 'No manifest loaded.'; + } const tabs = ['table', 'tree', 'dag'] - .map((item) => item === model.merkleMode ? `[${item}]` : ` ${item} `) + .map((item) => (item === model.merkleMode ? `[${item}]` : ` ${item} `)) .join(' '); if (model.merkleMode !== 'table') { return `${themeText(ctx, tabs, { tone: 'accent' })}\n\n${renderMerkleExplorer(manifest, model.merkleMode, ctx)}`; @@ -427,15 +526,42 @@ function renderMerkleBody({ model, manifest, ctx, box }) { })}`; } +function renderAtlasBriefing(sidebar, ctx) { + return [ + themeText(ctx, 'Overview', { tone: 'accent', bold: true }), + sidebar.overview, + '', + themeText(ctx, 'Focused Region', { tone: 'accent', bold: true }), + sidebar.focused, + '', + themeText(ctx, 'Legend', { tone: 'accent', bold: true }), + sidebar.legend, + '', + themeText(ctx, 'Largest Regions', { tone: 'accent', bold: true }), + sidebar.regions, + '', + sidebar.notes, + ] + .filter((section) => section !== null && section !== undefined) + .join('\n'); +} + function renderAtlasWorkspace(model, deps, options) { const gap = options.width >= 76 ? 2 : 1; const sidebarWidth = Math.min(44, Math.max(28, Math.floor(options.width * 0.34))); const mapWidth = Math.max(20, options.width - sidebarWidth - gap); if (!model.treemapReport) { - const body = model.treemapStatus === 'error' - ? `Atlas unavailable\n\n${model.treemapError}` - : 'Loading repository atlas...'; - return panel({ title: 'Repository Atlas', body, width: options.width, height: options.height, ctx: deps.ctx }); + const body = + model.treemapStatus === 'error' + ? `Atlas unavailable\n\n${model.treemapError}` + : 'Loading repository atlas...'; + return panel({ + title: 'Repository Atlas', + body, + width: options.width, + height: options.height, + ctx: deps.ctx, + }); } const selected = model.treemapReport.tiles?.[model.treemapFocus]?.id ?? null; const map = renderRepoTreemapMap(model.treemapReport, { @@ -450,25 +576,23 @@ function renderAtlasWorkspace(model, deps, options) { height: Math.max(4, options.height - 2), selectedTileId: selected, }); - const briefing = [ - themeText(deps.ctx, 'Overview', { tone: 'accent', bold: true }), - sidebar.overview, - '', - themeText(deps.ctx, 'Focused Region', { tone: 'accent', bold: true }), - sidebar.focused, - '', - themeText(deps.ctx, 'Legend', { tone: 'accent', bold: true }), - sidebar.legend, - '', - themeText(deps.ctx, 'Largest Regions', { tone: 'accent', bold: true }), - sidebar.regions, - '', - sidebar.notes, - ].filter(Boolean).join('\n'); + const briefing = renderAtlasBriefing(sidebar, deps.ctx); return hstackSurface( gap, - panel({ title: 'Repository Atlas', body: map, width: mapWidth, height: options.height, ctx: deps.ctx }), - panel({ title: 'Atlas Briefing', body: briefing, width: sidebarWidth, height: options.height, ctx: deps.ctx }), + panel({ + title: 'Repository Atlas', + body: map, + width: mapWidth, + height: options.height, + ctx: deps.ctx, + }), + panel({ + title: 'Atlas Briefing', + body: briefing, + width: sidebarWidth, + height: options.height, + ctx: deps.ctx, + }) ); } @@ -477,7 +601,10 @@ function renderOperationsWorkspace(model, deps, options) { const topHeight = Math.max(8, Math.floor(options.height / 2)); return vstackSurface( renderEconomicsPanel(model, deps.ctx, { width: options.width, height: topHeight }), - renderOpsDetailPanel(model, deps.ctx, { width: options.width, height: Math.max(4, options.height - topHeight) }), + renderOpsDetailPanel(model, deps.ctx, { + width: options.width, + height: Math.max(4, options.height - topHeight), + }) ); } const gap = 2; @@ -486,22 +613,24 @@ function renderOperationsWorkspace(model, deps, options) { return hstackSurface( gap, renderEconomicsPanel(model, deps.ctx, { width: leftWidth, height: options.height }), - renderOpsDetailPanel(model, deps.ctx, { width: rightWidth, height: options.height }), + renderOpsDetailPanel(model, deps.ctx, { width: rightWidth, height: options.height }) ); } function renderEconomicsPanel(model, ctx, box) { const { width, height } = box; - const body = model.statsStatus === 'ready' && model.statsReport - ? renderStats(model.statsReport, ctx) - : `${themeText(ctx, 'Vault Economics', { tone: 'brand', bold: true })}\n\nStats are ${model.statsStatus}. Press s to refresh.`; + const body = + model.statsStatus === 'ready' && model.statsReport + ? renderStats(model.statsReport, ctx) + : `${themeText(ctx, 'Vault Economics', { tone: 'brand', bold: true })}\n\nStats are ${model.statsStatus}. Press s to refresh.`; return panel({ title: 'Vault Economics', body, width, height, ctx }); } function renderStats(stats, ctx) { - const chunking = Object.entries(stats.chunkingStrategies ?? {}) - .map(([strategy, count]) => `${strategy}:${count}`) - .join(', ') || '-'; + const chunking = + Object.entries(stats.chunkingStrategies ?? {}) + .map(([strategy, count]) => `${strategy}:${count}`) + .join(', ') || '-'; return [ `${themeText(ctx, 'entries', { tone: 'accent' })} ${stats.entries}`, `${themeText(ctx, 'logical size', { tone: 'accent' })} ${formatSize(stats.totalLogicalSize)}`, @@ -517,9 +646,12 @@ function renderStats(stats, ctx) { function renderOpsDetailPanel(model, ctx, box) { const { width, height } = box; - const doctor = model.doctorStatus === 'ready' && model.doctorReport - ? (typeof model.doctorReport === 'string' ? model.doctorReport : renderHealthDashboard(model.doctorReport, ctx)) - : `Doctor status: ${model.doctorStatus}\nPress x to run a sweep.`; + const doctor = + model.doctorStatus === 'ready' && model.doctorReport + ? typeof model.doctorReport === 'string' + ? model.doctorReport + : renderHealthDashboard(model.doctorReport, ctx) + : `Doctor status: ${model.doctorStatus}\nPress x to run a sweep.`; const feed = renderOperationFeed(model.operationFeed, ctx); return panel({ title: 'Operations Deck', @@ -530,115 +662,21 @@ function renderOpsDetailPanel(model, ctx, box) { }); } -function renderOverlays(model, deps, options) { - if (model.settingsOpen) { - blitOverlay(options.screen, renderSettingsDrawer(model, deps, options)); - } - if (model.palette) { - const width = Math.min(84, Math.max(24, options.width - 6)); - const palette = panel({ - title: 'Command Palette', - body: commandPaletteSurface(model.palette, { width: width - 2, ctx: deps.ctx, showScrollbar: true }), - width, - height: Math.min(14, options.height - 4), - ctx: deps.ctx, - }); - options.screen.blit(palette, centerX(options.width, palette.width), topThirdY(options.height, palette.height)); - } - if (model.showHelp) { - const help = renderHelpSurface(model, deps.ctx, { width: Math.min(HELP_WIDTH, options.width - 4), height: Math.min(20, options.height - 4) }); - options.screen.blit(help, centerX(options.width, help.width), topThirdY(options.height, help.height)); - } +function renderPageOverlay(model, deps, options) { if (model.storeWizard) { - const wizard = renderWizardSurface(model.storeWizard, { width: options.width, height: options.height, ctx: deps.ctx }); - options.screen.blit(wizard, centerX(options.width, wizard.width), topThirdY(options.height, wizard.height)); - } - if (model.quitConfirm) { - blitOverlay(options.screen, modal({ - title: 'Quit git-cas?', - body: 'Leave the cockpit session now?', - hint: 'Y / Enter confirm | N / Esc cancel', - width: Math.min(52, options.width - 4), - screenWidth: options.width, - screenHeight: options.height, - borderToken: deps.ctx.theme.theme.border.warning, - bgToken: deps.ctx.theme.theme.surface.overlay, + const wizard = renderWizardSurface(model.storeWizard, { + width: options.width, + height: options.height, ctx: deps.ctx, - })); - } - for (const overlay of renderNotificationStack(model.notifications, { - screenWidth: options.width, - screenHeight: options.height, - ctx: deps.ctx, - margin: 1, - gap: 1, - })) { - blitOverlay(options.screen, overlay); + }); + options.screen.blit( + wizard, + centerX(options.width, wizard.width), + topThirdY(options.height, wizard.height) + ); } } -function vaultStatus(model) { - if (model.metadata?.encryption && model.vaultEncryptionKey) { return 'encrypted / unlocked'; } - if (model.metadata?.encryption) { return 'encrypted / locked'; } - return 'plaintext vault'; -} - -function renderSettingsDrawer(model, deps, options) { - const { ctx } = deps; - const body = [ - themeText(ctx, 'Cockpit Settings', { tone: 'brand', bold: true }), - '', - `${themeText(ctx, 'theme', { tone: 'accent' })} bright cockpit`, - `${themeText(ctx, 'workspace', { tone: 'accent' })} ${model.workspace}`, - `${themeText(ctx, 'focus', { tone: 'accent' })} ${model.focusPane ?? '-'}`, - `${themeText(ctx, 'source', { tone: 'accent' })} ${sourceLabel(model.source)}`, - `${themeText(ctx, 'vault', { tone: 'accent' })} ${vaultStatus(model)}`, - `${themeText(ctx, 'short SHA', { tone: 'accent' })} selected chunk expands in footer`, - '', - themeText(ctx, 'F2 closes this drawer.', { tone: 'subdued' }), - ].join('\n'); - return drawer({ - content: body, - anchor: 'right', - width: Math.min(46, Math.max(28, options.width - 4)), - screenWidth: options.width, - screenHeight: options.height, - title: 'Settings', - borderToken: ctx.theme.theme.border.primary, - bgToken: ctx.theme.theme.surface.overlay, - ctx, - }); -} - -function renderHelpSurface(model, ctx, box) { - const lines = [ - themeText(ctx, 'Frame', { tone: 'accent', bold: true }), - '1/e Explorer 2/a Atlas 3/o Operations', - '? Help F2 Settings q Quit', - 'Esc Close overlay', - '', - themeText(ctx, 'Explorer', { tone: 'accent', bold: true }), - 'Tab focus / Filter Ctrl+P Search digest', - 'j/k rows/chunks u/d page Enter detail', - 'm Merkle lens i Inspector mode', - '', - themeText(ctx, 'Atlas', { tone: 'accent', bold: true }), - 'j/k focus + Drill in - Drill out', - 't Scope i Path mode r Reload', - '', - themeText(ctx, 'Operations', { tone: 'accent', bold: true }), - 'n Store wizard s Stats x Doctor', - '', - `Active workspace: ${model.workspace}`, - ]; - return panel({ title: 'Help', body: lines.join('\n'), width: box.width, height: box.height, ctx }); -} - -function blitOverlay(screen, overlay) { - const surface = overlay.surface ?? textSurface(overlay.content, Math.max(1, overlay.content.split('\n').reduce((max, line) => Math.max(max, line.length), 0)), overlay.content.split('\n').length); - screen.blit(surface, overlay.col, overlay.row); -} - function centerX(containerWidth, overlayWidth) { return Math.max(0, Math.floor((containerWidth - overlayWidth) / 2)); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 12a21213..652e66f3 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -2,26 +2,16 @@ * TEA app shell for the V6 git-cas cockpit. */ -import { startApp as bijouStartApp } from '@flyingrobots/bijou-node'; import { - cpFilter, - cpFocusNext, - cpFocusPrev, - cpPageDown, - cpPageUp, - cpSelectedItem, - createCommandPaletteState, + createFramedApp, + createKeyMap, createNavigableTableState, - createNotificationState, navTableFocusNext, navTableFocusPrev, navTablePageDown, navTablePageUp, - notificationsNeedTick, - pushNotification, - quit, + notify, tick as bijouTick, - tickNotifications, } from '@flyingrobots/bijou-tui'; import { renderDashboard, tableSchema } from './dashboard-view.js'; import { createCliTuiContext } from './context.js'; @@ -44,9 +34,7 @@ import { shortenSha } from './components/short-sha.js'; /** @typedef {import('../src/domain/value-objects/Manifest.js').default} Manifest */ /** @typedef {import('../src/domain/services/VaultService.js').VaultEntry} VaultEntry */ /** @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext */ -/** @typedef {import('@flyingrobots/bijou-tui').CommandPaletteState} CommandPaletteState */ /** @typedef {import('@flyingrobots/bijou-tui').NavigableTableState} NavigableTableState */ -/** @typedef {import('@flyingrobots/bijou-tui').NotificationState} NotificationState */ /** @typedef {import('./store-wizard.js').StoreWizardState} StoreWizardState */ /** @typedef {import('./blocks/operation-feed.js').OperationFeedState} OperationFeedState */ @@ -62,9 +50,6 @@ import { shortenSha } from './components/short-sha.js'; * @typedef {Object} DashModel * @property {AppPhase} phase * @property {number} titleTimeMs - * @property {number} lastTickTime - * @property {number} fps - * @property {boolean} showPerfHud * @property {number} vaultEntryCount * @property {string} passphrase * @property {string | null} authError @@ -80,16 +65,12 @@ import { shortenSha } from './components/short-sha.js'; * @property {VaultEntry[]} entries * @property {VaultEntry[]} filtered * @property {string} filterText - * @property {boolean} filtering * @property {any} metadata * @property {Map} manifestCache * @property {string | null} loadingSlug * @property {string | null} error * @property {NavigableTableState} table - * @property {CommandPaletteState | null} palette - * @property {boolean} showHelp * @property {boolean} promptEnter - * @property {boolean} quitConfirm * @property {any} refsStatus * @property {any | null} refsInventory * @property {string | null} refsError @@ -106,12 +87,10 @@ import { shortenSha } from './components/short-sha.js'; * @property {any} treemapStatus * @property {any | null} treemapReport * @property {string | null} treemapError - * @property {NotificationState} notifications * @property {OperationFeedState} operationFeed * @property {StoreWizardState | null} storeWizard * @property {string | null} gitBranch * @property {Buffer | null} vaultEncryptionKey - * @property {boolean} settingsOpen */ /** @@ -124,8 +103,6 @@ import { shortenSha } from './components/short-sha.js'; */ const TITLE_TICK_MS = 33; -const NOTIFICATION_TICK_MS = 40; - const FULL_COLUMNS = [ { header: 'Slug' }, { header: 'Tree OID' }, @@ -275,9 +252,6 @@ function createShellState(columns, rows, source) { return { phase: 'title', titleTimeMs: 0, - lastTickTime: 0, - fps: 0, - showPerfHud: false, vaultEntryCount: 0, passphrase: '', authError: null, @@ -291,10 +265,8 @@ function createShellState(columns, rows, source) { focusPane: 'ledger', chunkFocus: 0, promptEnter: false, - quitConfirm: false, gitBranch: null, vaultEncryptionKey: null, - settingsOpen: false, }; } @@ -303,7 +275,6 @@ function createExplorerState(rows) { entries: [], filtered: [], filterText: '', - filtering: false, metadata: null, manifestCache: new Map(), loadingSlug: null, @@ -313,8 +284,6 @@ function createExplorerState(rows) { rows: [], height: tableHeight(rows), }), - palette: null, - showHelp: false, }; } @@ -336,7 +305,6 @@ function createServiceState() { treemapStatus: 'idle', treemapReport: null, treemapError: null, - notifications: createNotificationState(), operationFeed: createFeedState(), storeWizard: null, }; @@ -516,45 +484,21 @@ function enterDashboard(model, deps) { ]; } -function pushToast(model, spec) { - return { - ...model, - notifications: pushNotification( - model.notifications, - { - variant: 'TOAST', - placement: 'LOWER_RIGHT', - width: 42, - ...spec, - }, - Date.now() - ), - }; -} - -function notificationTick(model, deps) { - return notificationsNeedTick(model.notifications) - ? [deps.tick(NOTIFICATION_TICK_MS, { type: 'notification-tick' })] - : []; +function toastCommand(spec) { + return notify({ + ...spec, + durationMs: 4_000, + }); } function handleTickMsg(msg, model, deps) { - if (msg.type === 'notification-tick') { - const next = { ...model, notifications: tickNotifications(model.notifications, Date.now()) }; - return [next, notificationTick(next, deps)]; - } if (msg.type !== 'title-tick' || (model.phase !== 'title' && model.phase !== 'password')) { return [model, []]; } - const now = Date.now(); - const elapsed = now - (model.lastTickTime || now); - const fps = elapsed > 0 ? Math.round(1000 / elapsed) : 0; return [ { ...model, titleTimeMs: (model.titleTimeMs || 0) + TITLE_TICK_MS, - lastTickTime: now, - fps: model.lastTickTime ? fps : 0, }, [deps.tick(TITLE_TICK_MS, { type: 'title-tick' })], ]; @@ -627,7 +571,7 @@ function handleLoadError(msg, model) { } else if (msg.source === 'treemap') { next = { ...next, treemapStatus: 'error', treemapError: error }; } - return [pushToast(next, { title: 'Dashboard load failed', message: error, tone: 'ERROR' }), []]; + return [next, [toastCommand({ title: 'Dashboard load failed', message: error, tone: 'ERROR' })]]; } function handleAuthOkMsg(_msg, model, deps) { @@ -676,39 +620,101 @@ function handleLoadedBranchMsg(msg, model) { function handleStoreCompleteMsg(msg, model, deps) { const manifestCache = new Map(model.manifestCache); manifestCache.set(msg.slug, msg.manifest); - const next = pushToast( + const next = { + ...syncExplorer(model, { manifestCache, storeWizard: null }), + operationFeed: completeLatestOperation(model.operationFeed, msg.slug, null), + }; + return [ + next, + [ + loadEntriesCmd(casForModel(deps.cas, model), model.source), + toastCommand({ + title: 'Stored asset', + message: `${msg.slug} -> ${shortenSha(msg.treeOid)}`, + tone: 'SUCCESS', + }), + ], + ]; +} + +function handleStoreErrorMsg(msg, model) { + const next = { + ...model, + storeWizard: model.storeWizard + ? { ...model.storeWizard, step: 'error', error: msg.error } + : null, + operationFeed: completeLatestOperation( + model.operationFeed, + model.storeWizard?.slug ?? 'store', + msg.error + ), + }; + return [next, [toastCommand({ title: 'Store failed', message: msg.error, tone: 'ERROR' })]]; +} + +function handleSelectAssetMsg(msg, model, deps) { + const selected = model.entries.find((entry) => entry.slug === msg.slug); + const next = syncExplorer(model, { + workspace: 'explorer', + explorerMode: 'manifest', + filterText: selected ? selected.slug : model.filterText, + }); + const manifestCmd = maybeLoadSelectedManifest(next, deps); + return [next, manifestCmd ? [manifestCmd] : []]; +} + +function handleOpenStoreWizardMsg(_msg, model) { + return [ { - ...syncExplorer(model, { manifestCache, storeWizard: null }), - operationFeed: completeLatestOperation(model.operationFeed, msg.slug, null), + ...model, + workspace: 'operations', + storeWizard: createWizardState(), }, - { title: 'Stored asset', message: `${msg.slug} -> ${shortenSha(msg.treeOid)}`, tone: 'SUCCESS' } - ); + [], + ]; +} + +function handleRefreshStatisticsMsg(_msg, model, deps) { return [ - next, - [loadEntriesCmd(casForModel(deps.cas, model), model.source), ...notificationTick(next, deps)], + { + ...model, + workspace: 'operations', + statsStatus: 'loading', + statsError: null, + }, + [loadStatsCmd(deps.cas, model.entries, model.source)], ]; } -function handleStoreErrorMsg(msg, model, deps) { - const next = pushToast( +function handleRunDoctorMsg(_msg, model, deps) { + return [ { ...model, - storeWizard: model.storeWizard - ? { ...model.storeWizard, step: 'error', error: msg.error } - : null, - operationFeed: completeLatestOperation( - model.operationFeed, - model.storeWizard?.slug ?? 'store', - msg.error - ), + workspace: 'operations', + doctorStatus: 'loading', + doctorError: null, }, - { title: 'Store failed', message: msg.error, tone: 'ERROR' } - ); - return [next, notificationTick(next, deps)]; + [ + loadDoctorCmd(deps.cas, { + source: model.source, + entries: model.entries, + encryptionKey: model.vaultEncryptionKey, + }), + ], + ]; +} + +function handleClearAssetSearchMsg(_msg, model) { + return [ + syncExplorer(model, { + workspace: 'explorer', + filterText: '', + }), + [], + ]; } const APP_MSG_HANDLERS = { - 'notification-tick': handleTickMsg, 'title-tick': handleTickMsg, 'vault-auth-check': handleVaultAuthCheck, 'vault-auth-ok': handleAuthOkMsg, @@ -723,6 +729,11 @@ const APP_MSG_HANDLERS = { 'store-complete': handleStoreCompleteMsg, 'store-error': handleStoreErrorMsg, 'load-error': handleLoadError, + 'select-asset': handleSelectAssetMsg, + 'open-store-wizard': handleOpenStoreWizardMsg, + 'refresh-statistics': handleRefreshStatisticsMsg, + 'run-doctor': handleRunDoctorMsg, + 'clear-asset-search': handleClearAssetSearchMsg, }; function handleAppMsg(msg, model, deps) { @@ -731,12 +742,6 @@ function handleAppMsg(msg, model, deps) { } function handleTitleKey(msg, model, deps) { - if (msg.key === '`') { - return [{ ...model, showPerfHud: !model.showPerfHud }, []]; - } - if (msg.key === 'q' || msg.key === 'escape') { - return [model, [quit()]]; - } if (msg.key === 'enter' && model.promptEnter) { return enterDashboard(model, deps); } @@ -744,12 +749,6 @@ function handleTitleKey(msg, model, deps) { } function handlePasswordKey(msg, model, deps) { - if (msg.key === '`') { - return [{ ...model, showPerfHud: !model.showPerfHud }, []]; - } - if (msg.key === 'escape') { - return [model, [quit()]]; - } if (msg.key === 'enter') { return [{ ...model, authError: null }, [verifyPassphraseCmd(deps.cas, model.passphrase)]]; } @@ -780,103 +779,6 @@ function buildPaletteItems(model) { }); } -function openPalette(model) { - return { - ...model, - palette: createCommandPaletteState(buildPaletteItems(model), 9), - showHelp: false, - }; -} - -function selectPaletteItem(model, deps) { - const item = cpSelectedItem(model.palette); - if (!item) { - return [{ ...model, palette: null }, []]; - } - const selected = model.entries.find((entry) => entry.slug === item.id); - const next = syncExplorer(model, { - workspace: 'explorer', - explorerMode: 'manifest', - palette: null, - filterText: selected ? selected.slug : model.filterText, - filtering: false, - }); - const manifestCmd = maybeLoadSelectedManifest(next, deps); - return [next, manifestCmd ? [manifestCmd] : []]; -} - -const PALETTE_ACTIONS = { - close: (_msg, model) => [{ ...model, palette: null }, []], - select: (_msg, model, deps) => selectPaletteItem(model, deps), - next: (_msg, model) => [{ ...model, palette: cpFocusNext(model.palette) }, []], - previous: (_msg, model) => [{ ...model, palette: cpFocusPrev(model.palette) }, []], - pageDown: (_msg, model) => [{ ...model, palette: cpPageDown(model.palette) }, []], - pageUp: (_msg, model) => [{ ...model, palette: cpPageUp(model.palette) }, []], -}; - -const PALETTE_KEY_ACTIONS = { - escape: 'close', - enter: 'select', - down: 'next', - up: 'previous', - pagedown: 'pageDown', - pageup: 'pageUp', -}; - -function paletteAction(msg) { - if (msg.ctrl && msg.key === 'n') { - return 'next'; - } - if (msg.ctrl && msg.key === 'p') { - return 'previous'; - } - if (msg.ctrl && msg.key === 'd') { - return 'pageDown'; - } - if (msg.ctrl && msg.key === 'u') { - return 'pageUp'; - } - return PALETTE_KEY_ACTIONS[msg.key] ?? null; -} - -function paletteQueryForKey(msg, palette) { - if (msg.key === 'backspace') { - return palette.query.slice(0, -1); - } - if (msg.key.length === 1 && !msg.ctrl && !msg.alt) { - return `${palette.query}${msg.key}`; - } - return null; -} - -function handlePaletteKey(msg, model, deps) { - if (!model.palette) { - return [model, []]; - } - const action = paletteAction(msg); - if (action) { - return PALETTE_ACTIONS[action](msg, model, deps); - } - const query = paletteQueryForKey(msg, model.palette); - return query === null ? [model, []] : [{ ...model, palette: cpFilter(model.palette, query) }, []]; -} - -function handleFilterKey(msg, model) { - if (msg.key === 'escape') { - return [syncExplorer(model, { filtering: false }), []]; - } - if (msg.key === 'enter') { - return [syncExplorer(model, { filtering: false }), []]; - } - if (msg.key === 'backspace') { - return [syncExplorer(model, { filterText: model.filterText.slice(0, -1) }), []]; - } - if (msg.key.length === 1 && !msg.ctrl && !msg.alt) { - return [syncExplorer(model, { filterText: `${model.filterText}${msg.key}` }), []]; - } - return [model, []]; -} - const LEDGER_NAVIGATORS = { j: navTableFocusNext, down: navTableFocusNext, @@ -952,7 +854,7 @@ function handleDetailNavigation(msg, model) { } function switchWorkspace(model, workspace, deps) { - const next = { ...model, workspace, showHelp: false }; + const next = { ...model, workspace }; if (workspace === 'atlas' && next.treemapStatus === 'idle') { const cas = casForModel(deps.cas, next); return [ @@ -1178,90 +1080,18 @@ function handleWizardKey(msg, model, deps) { function handleOperationsKey(msg, model, deps) { if (msg.key === 'n') { - return [{ ...model, storeWizard: createWizardState() }, []]; + return handleOpenStoreWizardMsg(msg, model); } if (msg.key === 's') { - return [ - { ...model, statsStatus: 'loading', statsError: null }, - [loadStatsCmd(deps.cas, model.entries, model.source)], - ]; + return handleRefreshStatisticsMsg(msg, model, deps); } if (msg.key === 'x') { - return [ - { - ...model, - doctorStatus: 'loading', - doctorError: null, - }, - [ - loadDoctorCmd(deps.cas, { - source: model.source, - entries: model.entries, - encryptionKey: model.vaultEncryptionKey, - }), - ], - ]; - } - return null; -} - -function handleQuitConfirmKey(msg, model) { - if (msg.key === 'y' || msg.key === 'enter') { - return [model, [quit()]]; - } - if (msg.key === 'n' || msg.key === 'escape' || msg.key === 'q') { - return [{ ...model, quitConfirm: false }, []]; - } - return [model, []]; -} - -function clearDashboardOverlays(model) { - return { - ...model, - showHelp: false, - storeWizard: null, - palette: null, - filtering: false, - quitConfirm: false, - settingsOpen: false, - }; -} - -function handleOverlayShortcut(msg, model) { - if (msg.key === 'escape') { - return [clearDashboardOverlays(model), []]; - } - if (msg.key === 'f2') { - return [{ ...model, settingsOpen: !model.settingsOpen, showHelp: false, palette: null }, []]; - } - if (msg.key === '?' || (msg.shift && msg.key === '/')) { - return [{ ...model, showHelp: !model.showHelp, palette: null }, []]; - } - if (msg.key === 'q') { - return [{ ...model, quitConfirm: true }, []]; - } - return null; -} - -function handleSearchShortcut(msg, model) { - if (msg.key === '/' && !msg.ctrl) { - return [syncExplorer(model, { workspace: 'explorer', filtering: true, palette: null }), []]; - } - if ((msg.ctrl && msg.key === 'p') || msg.key === ':') { - return [openPalette(model), []]; + return handleRunDoctorMsg(msg, model, deps); } return null; } function handleGlobalDashboardKey(msg, model, deps) { - const overlay = handleOverlayShortcut(msg, model); - if (overlay) { - return overlay; - } - const search = handleSearchShortcut(msg, model); - if (search) { - return search; - } return handleWorkspaceKey(msg, model, deps); } @@ -1277,19 +1107,10 @@ function handleWorkspaceSpecificKey(msg, model, deps) { } function handleDashboardKey(msg, model, deps) { - if (model.quitConfirm) { - return handleQuitConfirmKey(msg, model); - } - if (model.palette) { - return handlePaletteKey(msg, model, deps); - } const wizard = handleWizardKey(msg, model, deps); if (wizard) { return wizard; } - if (model.filtering) { - return handleFilterKey(msg, model); - } const global = handleGlobalDashboardKey(msg, model, deps); return global ?? handleWorkspaceSpecificKey(msg, model, deps) ?? [model, []]; } @@ -1310,14 +1131,262 @@ function handleUpdate(msg, model, deps) { return handleAppMsg(msg, model, deps); } +function keyMessage(key, options = {}) { + return { + type: 'key', + key, + ctrl: options.ctrl ?? false, + alt: options.alt ?? false, + shift: options.shift ?? false, + }; +} + +function createDashboardKeyMap() { + return createKeyMap() + .group('Workspaces', (group) => + group + .bind('1', 'Open Explorer workspace', keyMessage('1')) + .bind('2', 'Open Atlas workspace', keyMessage('2')) + .bind('3', 'Open Operations workspace', keyMessage('3')) + .bind('e', 'Open Explorer workspace', keyMessage('e')) + .bind('a', 'Open Atlas workspace', keyMessage('a')) + .bind('o', 'Open Operations workspace', keyMessage('o')) + ) + .group('Navigate cockpit', (group) => + group + .bind('tab', 'Change cockpit focus', keyMessage('tab')) + .bind('j', 'Move down', keyMessage('j')) + .bind('k', 'Move up', keyMessage('k')) + .bind('down', 'Move down', keyMessage('down')) + .bind('up', 'Move up', keyMessage('up')) + .bind('d', 'Page down', keyMessage('d')) + .bind('u', 'Page up', keyMessage('u')) + .bind('pagedown', 'Page down', keyMessage('pagedown')) + .bind('pageup', 'Page up', keyMessage('pageup')) + .bind('g', 'Move to first item', keyMessage('g')) + .bind('enter', 'Open or activate selection', keyMessage('enter')) + ) + .group('Explorer', (group) => + group + .bind('m', 'Cycle Merkle view', keyMessage('m')) + .bind('i', 'Toggle inspector view', keyMessage('i')) + ) + .group('Atlas', (group) => + group + .bind('t', 'Toggle atlas scope', keyMessage('t')) + .bind('r', 'Reload atlas', keyMessage('r')) + .bind('+', 'Drill into atlas tile', keyMessage('+')) + .bind('-', 'Drill out of atlas tile', keyMessage('-')) + ) + .group('Operations', (group) => + group + .bind('n', 'Open store wizard', keyMessage('n')) + .bind('s', 'Refresh vault statistics', keyMessage('s')) + .bind('x', 'Run repository doctor', keyMessage('x')) + ); +} + +function createDashboardModalKeyMap(model) { + if (model.phase !== 'password' && !model.storeWizard) { + return undefined; + } + // Bijou modal key maps intentionally swallow unmatched keys. Keep a compact + // help surface, but make input handling total so text and shell-reserved + // characters still reach the active password or wizard reducer. + const helpMap = createKeyMap().group('Active input', (group) => + group + .bind('enter', 'Confirm active input', keyMessage('enter')) + .bind('backspace', 'Delete previous character', keyMessage('backspace')) + .bind('escape', 'Cancel active input', keyMessage('escape')) + ); + return Object.assign(Object.create(helpMap), { + handle(msg) { + return helpMap.handle(msg) ?? msg; + }, + }); +} + +function cockpitWorkspaceCommands() { + return [ + { + id: 'workspace-explorer', + label: 'Open Explorer', + category: 'workspace', + shortcut: '1', + action: keyMessage('1'), + }, + { + id: 'workspace-atlas', + label: 'Open Repository Atlas', + category: 'workspace', + shortcut: '2', + action: keyMessage('2'), + }, + { + id: 'workspace-operations', + label: 'Open Operations', + category: 'workspace', + shortcut: '3', + action: keyMessage('3'), + }, + ]; +} + +function cockpitOperationCommands() { + return [ + { + id: 'store-asset', + label: 'Store a new asset', + category: 'operation', + shortcut: 'n', + action: { type: 'open-store-wizard' }, + }, + { + id: 'refresh-statistics', + label: 'Refresh vault statistics', + category: 'operation', + shortcut: 's', + action: { type: 'refresh-statistics' }, + }, + { + id: 'run-doctor', + label: 'Run repository doctor', + category: 'operation', + shortcut: 'x', + action: { type: 'run-doctor' }, + }, + ]; +} + +function buildCockpitCommands(model) { + return [ + ...cockpitWorkspaceCommands(), + ...cockpitOperationCommands(), + ...(model.filterText + ? [ + { + id: 'clear-asset-search', + label: 'Show all assets', + category: 'search', + action: { type: 'clear-asset-search' }, + }, + ] + : []), + ...(model.workspace === 'atlas' + ? [ + { + id: 'reload-atlas', + label: 'Reload Repository Atlas', + category: 'atlas', + shortcut: 'r', + action: keyMessage('r'), + }, + ] + : []), + ]; +} + +function buildCockpitSearchItems(model) { + return buildPaletteItems(model).map((item) => ({ + ...item, + action: { type: 'select-asset', slug: item.id }, + })); +} + +function cockpitVaultStatus(model) { + if (model.metadata?.encryption && model.vaultEncryptionKey) { + return 'encrypted / unlocked'; + } + if (model.metadata?.encryption) { + return 'encrypted / locked'; + } + return 'plaintext vault'; +} + +function cockpitSourceLabel(source) { + if (source.type === 'vault') { + return 'vault'; + } + if (source.type === 'ref') { + return `ref ${source.ref}`; + } + return `oid ${source.treeOid}`; +} + +function buildCockpitSettings(model, ctx) { + return { + title: 'Cockpit Settings', + borderToken: ctx.theme.theme.border.primary, + bgToken: ctx.theme.theme.surface.overlay, + sections: [ + { + id: 'session', + title: 'Session', + rows: [ + { id: 'workspace', label: 'Workspace', valueLabel: model.workspace, kind: 'info' }, + { id: 'focus', label: 'Focus', valueLabel: model.focusPane ?? '-', kind: 'info' }, + { + id: 'source', + label: 'Source', + valueLabel: cockpitSourceLabel(model.source), + kind: 'info', + }, + { id: 'vault', label: 'Vault', valueLabel: cockpitVaultStatus(model), kind: 'info' }, + ], + }, + { + id: 'display', + title: 'Display', + rows: [ + { + id: 'short-sha', + label: 'Selected digest', + description: 'The selected chunk expands in the cockpit detail status.', + kind: 'info', + }, + ], + }, + ], + }; +} + export function createDashboardApp(deps) { + return createFramedApp({ + title: 'git-cas', + pages: [createDashboardPage(deps)], + defaultPageId: 'cockpit', + initialColumns: deps.ctx.runtime.columns ?? 80, + initialRows: deps.ctx.runtime.rows ?? 24, + enableCommandPalette: true, + keyPriority: 'page-first', + ctx: deps.ctx, + observeKey(msg, route) { + return route === 'unhandled' ? msg : undefined; + }, + settings: ({ pageModel }) => buildCockpitSettings(pageModel, deps.ctx), + runtimeNotifications: true, + }); +} + +export function createDashboardPage(deps) { return { + id: 'cockpit', + title: 'Cockpit', init: () => [ createInitModel(deps.ctx, deps.source), [checkVaultAuthCmd(deps.cas), deps.tick(TITLE_TICK_MS, { type: 'title-tick' })], ], update: (msg, model) => handleUpdate(msg, model, deps), - view: (model) => renderDashboard(model, deps), + keyMap: createDashboardKeyMap(), + modalKeyMap: (model) => createDashboardModalKeyMap(model), + commandItems: (model) => buildCockpitCommands(model), + searchItems: (model) => buildCockpitSearchItems(model), + searchTitle: 'Search assets', + layout: (model) => ({ + kind: 'pane', + paneId: 'cockpit', + render: (width, height) => renderDashboard(model, deps, { width, height }), + }), }; } @@ -1341,8 +1410,8 @@ export async function launchDashboard(cas, options = {}) { } const dashTick = options.tick || bijouTick; const deps = { cas, ctx, cwdLabel: options.cwd ?? process.cwd(), source, tick: dashTick }; - const runApp = options.runApp || bijouStartApp; - return runApp(createDashboardApp(deps), { ctx }); + const app = createDashboardApp(deps); + return options.runApp ? options.runApp(app, { ctx }) : app.run({ ctx }); } export { buildTableRows, selectedEntry, selectedManifest, tableSchema }; diff --git a/bin/ui/progress.js b/bin/ui/progress.js index 27e41412..d251c9a8 100644 --- a/bin/ui/progress.js +++ b/bin/ui/progress.js @@ -4,7 +4,7 @@ */ import { statSync } from 'node:fs'; -import { CLEAR_LINE_RETURN, progressBar } from '@flyingrobots/bijou'; +import { CLEAR_LINE_RETURN, cursorGuard, progressBar } from '@flyingrobots/bijou'; import { getCliContext } from './context.js'; /** @@ -43,7 +43,13 @@ function formatBytes(bytes) { * @param {import('@flyingrobots/bijou').BijouContext} [options.ctx] - Context override. * @returns {ProgressTracker} */ -export function createStoreProgress({ filePath, chunkSize, quiet, fileSize: providedSize, ctx: providedCtx }) { +export function createStoreProgress({ + filePath, + chunkSize, + quiet, + fileSize: providedSize, + ctx: providedCtx, +}) { if (quiet) { return { attach() {}, detach() {} }; } @@ -92,6 +98,7 @@ export function createRestoreProgress({ totalChunks, quiet, ctx: providedCtx }) * @property {number | null} startTime * @property {Observer | null} service * @property {((evt: { size: number }) => void) | null} handler + * @property {import('@flyingrobots/bijou').CursorHideHandle | null} cursorHandle */ /** @@ -102,18 +109,77 @@ export function createRestoreProgress({ totalChunks, quiet, ctx: providedCtx }) * @param {{ ctx: import('@flyingrobots/bijou').BijouContext, totalChunks: number, label: string, width: number }} deps */ function handleChunkEvent({ size }, state, deps) { - if (!state.startTime) { state.startTime = Date.now(); } + if (!state.startTime) { + state.startTime = Date.now(); + } state.chunksProcessed++; state.bytesProcessed += size; + renderProgress(state, deps); +} + +/** + * Render the current tracker state without mutating its counters. + * + * @param {TrackerState} state + * @param {{ ctx: import('@flyingrobots/bijou').BijouContext, totalChunks: number, label: string, width: number }} deps + */ +function renderProgress(state, deps) { const pct = (state.chunksProcessed / deps.totalChunks) * 100; - const elapsed = (Date.now() - state.startTime) / 1000; + const elapsed = state.startTime ? (Date.now() - state.startTime) / 1000 : 0; const throughput = elapsed > 0 ? state.bytesProcessed / elapsed : 0; if (deps.ctx.mode === 'interactive') { const status = ` ${deps.label} ${state.chunksProcessed}/${deps.totalChunks} ${formatBytes(throughput)}/s `; const barStr = progressBar(pct, { width: deps.width, showPercent: false, ctx: deps.ctx }); deps.ctx.io.writeError(`${CLEAR_LINE_RETURN}${status}${barStr}`); - } else if (state.chunksProcessed === 1 || state.chunksProcessed === deps.totalChunks || state.chunksProcessed % 10 === 0) { - deps.ctx.io.writeError(`${deps.label} ${state.chunksProcessed}/${deps.totalChunks} ${Math.round(pct)}%\n`); + } else if ( + state.chunksProcessed === 1 || + state.chunksProcessed === deps.totalChunks || + state.chunksProcessed % 10 === 0 + ) { + deps.ctx.io.writeError( + `${deps.label} ${state.chunksProcessed}/${deps.totalChunks} ${Math.round(pct)}%\n` + ); + } +} + +function releaseTracker(state) { + state.cursorHandle?.dispose(); + state.cursorHandle = null; + state.service = null; + state.handler = null; +} + +function attachProgress(svc, state, deps) { + state.service = svc; + state.handler = (/** @type {{ size: number }} */ evt) => handleChunkEvent(evt, state, deps); + try { + if (deps.ctx.mode === 'interactive') { + state.cursorHandle = cursorGuard(deps.ctx.io).hide(); + renderProgress(state, deps); + } + state.service.on(deps.event, state.handler); + } catch (error) { + releaseTracker(state); + throw error; + } +} + +function detachProgress(state, deps) { + try { + if (state.service && state.handler) { + state.service.removeListener(deps.event, state.handler); + } + const elapsed = state.startTime ? (Date.now() - state.startTime) / 1000 : 0; + const throughput = elapsed > 0 ? state.bytesProcessed / elapsed : 0; + if (deps.ctx.mode === 'interactive') { + deps.ctx.io.writeError( + `${CLEAR_LINE_RETURN} ${deps.label} ${state.chunksProcessed}/${deps.totalChunks} done ${formatBytes(throughput)}/s\n` + ); + } else { + deps.ctx.io.writeError(`${deps.label} ${state.chunksProcessed}/${deps.totalChunks} done\n`); + } + } finally { + releaseTracker(state); } } @@ -126,29 +192,23 @@ function handleChunkEvent({ size }, state, deps) { function createProgressTracker({ ctx, totalChunks, event, label }) { const width = Math.min(40, (ctx.runtime.columns || 80) - 30); /** @type {TrackerState} */ - const state = { chunksProcessed: 0, bytesProcessed: 0, startTime: null, service: null, handler: null }; - const deps = { ctx, totalChunks, label, width }; + const state = { + chunksProcessed: 0, + bytesProcessed: 0, + startTime: null, + service: null, + handler: null, + cursorHandle: null, + }; + const deps = { ctx, totalChunks, event, label, width }; return { /** @param {Observer} svc */ attach(svc) { - state.service = svc; - state.handler = (/** @type {{ size: number }} */ evt) => handleChunkEvent(evt, state, deps); - if (ctx.mode === 'interactive') { - ctx.io.writeError('\x1b[?25l'); // hide cursor - handleChunkEvent({ size: 0 }, state, deps); - } - state.service.on(event, state.handler); + attachProgress(svc, state, deps); }, detach() { - if (state.service && state.handler) { state.service.removeListener(event, state.handler); } - const elapsed = state.startTime ? (Date.now() - state.startTime) / 1000 : 0; - const throughput = elapsed > 0 ? state.bytesProcessed / elapsed : 0; - if (ctx.mode === 'interactive') { - ctx.io.writeError(`${CLEAR_LINE_RETURN} ${label} ${state.chunksProcessed}/${totalChunks} done ${formatBytes(throughput)}/s\n\x1b[?25h`); // show cursor - } else { - ctx.io.writeError(`${label} ${state.chunksProcessed}/${totalChunks} done\n`); - } + detachProgress(state, deps); }, }; } diff --git a/bin/ui/theme.js b/bin/ui/theme.js index 68d23fb8..843f36d6 100644 --- a/bin/ui/theme.js +++ b/bin/ui/theme.js @@ -2,7 +2,7 @@ * Shared visual language for git-cas terminal surfaces. */ -import { parseAnsiToSurface } from '@flyingrobots/bijou'; +import { defineThemeSafePairs, parseAnsiToSurface } from '@flyingrobots/bijou'; export const GIT_CAS_PALETTE = { ghost: [251, 252, 252], @@ -31,16 +31,18 @@ function token(rgb, modifiers = [], bg) { }; } -const { - ghost, pearl, slate, cyan, orange, midnight, ink, ruby, sky, violet, deepSlate, -} = GIT_CAS_PALETTE; +const { ghost, slate, cyan, orange, midnight, ink, ruby, sky, violet, deepSlate } = GIT_CAS_PALETTE; + +const secondaryBackground = [43, 58, 68]; +const elevatedBackground = [51, 68, 78]; +const mutedBackground = [31, 41, 49]; /** - * Bijou v5 theme for the git-cas cockpit. + * Bijou v7 theme for the git-cas cockpit. * * Keep this object aligned with Bijou's public Theme shape. App-specific - * colors belong in local helpers, not in stray token groups that components - * will never read. + * surface tokens are registered under `ui` so Bijou's theme doctor can verify + * every foreground/background relationship used by the cockpit. */ export const GIT_CAS_THEME = { name: 'git-cas-cockpit', @@ -51,7 +53,7 @@ export const GIT_CAS_THEME = { info: token(sky), pending: token(slate), active: token(cyan, ['bold']), - muted: token(deepSlate), + muted: token(slate), }, semantic: { success: token(cyan, ['bold']), @@ -91,22 +93,47 @@ export const GIT_CAS_THEME = { logo: token(cyan, ['bold']), tableHeader: token(ghost, ['bold']), trackEmpty: token(deepSlate), + canvas: token(midnight), + secondaryCanvas: token(secondaryBackground), + elevatedCanvas: token(elevatedBackground), + overlayCanvas: token(ink), + mutedCanvas: token(mutedBackground), }, surface: { primary: token(ghost, [], midnight), - secondary: token(pearl, [], [43, 58, 68]), - elevated: token(ghost, [], [51, 68, 78]), + secondary: token(ghost, [], secondaryBackground), + elevated: token(ghost, [], elevatedBackground), overlay: token(ghost, [], ink), - muted: token(slate, [], [31, 41, 49]), + muted: token(ghost, [], mutedBackground), }, }; +/** + * High-contrast foreground/background contracts selected with Design Book and + * enforced by Bijou's theme doctor. Decorative track and border colors are not + * text pairs and are intentionally excluded. + */ +export const GIT_CAS_THEME_SAFE_PAIRS = defineThemeSafePairs() + .readable('surface.primary', 'ui.canvas', { label: 'primary surface text' }) + .readable('surface.secondary', 'ui.secondaryCanvas', { label: 'secondary surface text' }) + .readable('surface.elevated', 'ui.elevatedCanvas', { label: 'elevated surface text' }) + .readable('surface.overlay', 'ui.overlayCanvas', { label: 'overlay surface text' }) + .readable('surface.muted', 'ui.mutedCanvas', { label: 'muted surface text' }) + .readable('semantic.primary', 'ui.canvas', { label: 'primary cockpit text' }) + .readable('semantic.muted', 'ui.canvas', { label: 'muted cockpit text' }) + .status('semantic.success', 'ui.canvas', { label: 'success cockpit text' }) + .status('semantic.error', 'ui.canvas', { label: 'error cockpit text' }) + .status('semantic.warning', 'ui.canvas', { label: 'warning cockpit text' }) + .status('semantic.info', 'ui.canvas', { label: 'informational cockpit text' }) + .status('semantic.accent', 'ui.canvas', { label: 'accent cockpit text' }) + .build(); + const TEXT_TONES = { brand: { fg: cyan, bold: true }, accent: { fg: orange, bold: true }, primary: { fg: ghost }, secondary: { fg: slate }, - subdued: { fg: deepSlate }, + subdued: { fg: slate }, info: { fg: sky, bold: true }, violet: { fg: violet, bold: true }, success: { fg: cyan, bold: true }, diff --git a/docs/design/0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md b/docs/design/0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md new file mode 100644 index 00000000..0542ec0c --- /dev/null +++ b/docs/design/0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md @@ -0,0 +1,374 @@ +--- +title: 'TUI-0056 - Bijou 7 Framed Cockpit' +cycle: '0056' +task_id: 'bijou-7-framed-cockpit' +legend: 'TUI' +release_home: 'v6.5.6' +issue: 'https://github.com/git-stunts/git-cas/issues/105' +goalpost_issue: 'https://github.com/git-stunts/git-cas/issues/105' +tracker_source: 'github' +status: 'active' +base_commit: '9ea91a738f2cbadf2a20b5ac7c2c6d54ba9f409e' +owners: + - '@git-stunts' +sponsors: + human: 'James' + agent: 'Codex' +blocking_issues: [] +supersedes: [] +superseded_by: null +created: '2026-07-27' +updated: '2026-07-27' +--- + +# TUI-0056 - Bijou 7 Framed Cockpit + +## Linked Issue + +- Goalpost: https://github.com/git-stunts/git-cas/issues/105 +- Hosted-shell slice: https://github.com/git-stunts/git-cas/issues/106 + +## Linked Tracker + +- Milestone: `v6.5.6` +- Goalpost issue: https://github.com/git-stunts/git-cas/issues/105 +- Slice issue: https://github.com/git-stunts/git-cas/issues/106 + +## Design Type + +- [ ] Runtime/API +- [ ] Storage/substrate +- [x] Migration/release +- [x] CLI/operator +- [x] Docs/public guidance +- [x] TUI/visual surface +- [x] Test/tooling + +## Decision Summary + +Upgrade the complete Bijou dependency family to 7.2.0 and host the interactive +cockpit through `createFramedApp()`. The frame owns terminal lifecycle, outer +chrome, help, command and search palettes, settings, notifications, performance +telemetry, and quit confirmation. The git-cas page continues to own storage +domain state, Explorer/Atlas/Operations workspace navigation, authentication, +filtering, and the store wizard. + +## Sponsored Human + +An operator wants git-cas to use the current Bijou application model so the +cockpit receives current shell behavior and accessibility fixes without +maintaining an older parallel shell. + +## Sponsored Agent + +An agent needs one explicit hosted entrypoint and inspectable page contracts so +it can test shell ownership separately from git-cas domain behavior without +inferring state from raw ANSI output. + +## Hill + +By the end of this cycle, the interactive launcher runs a self-hosted +`FramedApp`, all duplicated outer-shell state is gone, the installed dependency +graph contains one coherent Bijou 7.2 family, and tests prove both the framed +human surface and the unchanged static lower mode. + +## Current Truth + +`package.json` declares four Bijou 5 caretaker ranges. `bin/ui/context.js` +manually assembles a Node context and stderr adapter. +`bin/ui/dashboard.js` calls `startApp()` directly and stores app-owned command +palette, help, settings, notification, performance-HUD, and quit-confirmation +state. `bin/ui/dashboard-view.js` renders matching outer chrome and overlays. +`bin/ui/progress.js` manually owns cursor hide/show sequences. + +Bijou 7.2 provides `createNodeContext()` stream overrides, reference-counted +cursor ownership, theme safe-pair checks, and a self-running +`createFramedApp()` whose shell already owns those application concerns. + +## Problem + +Keeping a second shell inside the application forfeits current Bijou behavior, +creates two potential owners for the same keybindings and overlays, and makes +future upgrades more expensive. Merely changing package versions would leave +the architectural duplication intact. + +## Scope + +This cycle includes: + +- coherent Bijou 7.2 dependency ranges and lockfile; +- Node-host context construction through current adapters; +- reference-counted cursor ownership for store/restore progress; +- a `FramePage` contract around the existing cockpit domain model; +- `FramedApp.run()` as the interactive hosted entrypoint; +- frame-owned help, command/search palettes, settings, notifications, + performance HUD, quit confirmation, outer header, and footer; +- explicit application key bindings, commands, asset search items, and + settings facts; +- Design Book selection and Bijou enforcement of high-contrast text/surface + pairs; +- static output and structured-stdout preservation; +- release documentation and witness evidence. + +## Non-Goals + +This cycle does not include: + +- storage, manifest, object, ref, or public package API changes; +- turning Explorer, Atlas, and Operations into independent frame pages with + duplicated domain models; +- light-theme design without a separately selected and audited palette; +- rewriting the store wizard as a generic Bijou form; +- changing machine-readable CLI output. + +## Runtime / API Contract + +- `createDashboardPage(deps)` exposes the testable git-cas page contract. +- `createDashboardApp(deps)` returns a `FramedApp`. +- `launchDashboard()` retains the static fallback for noninteractive contexts. +- Interactive launch calls the framed app's hosted `run({ ctx })` entrypoint. +- Page commands emit frame-managed notifications through `notify()`. +- Frame search items select assets; command items expose application actions. +- Shell-reserved keys own help, search/palette, settings, performance HUD, and + quit. Page-first conflict resolution preserves application navigation where + keys overlap with frame scrolling. + +## User Experience / Product Shape + +The operator sees one Bijou frame around the cockpit: + +- the frame header names the application and active page; +- the frame footer renders current shell and page key help; +- `?` opens frame help; +- `/` opens asset search; +- `Ctrl+P` or `:` opens application commands; +- `F2` or `Ctrl+,` opens frame-rendered settings; +- `Shift+N` opens notification history; +- backtick opens the frame performance HUD; +- `q` opens the frame quit confirmation. + +Explorer, Atlas, Operations, vault unlock, filtering, and the store wizard +retain their existing application semantics. + +## Data / State Model + +| State | Source of truth | Derived state | Invalid states | Reset behavior | Serialization | Determinism assumptions | +| -------------------- | -------------------------------- | ------------------------------- | -------------------------- | ----------------------- | ---------------- | ------------------------------------ | +| Cockpit domain model | active frame page model | rendered workspace and commands | page model missing | frame initializes page | in-memory only | reducer inputs determine state | +| Shell state | `FrameModel` | overlays and chrome | duplicate app owner | frame lifecycle | in-memory only | frame reducer inputs determine state | +| Notifications | frame runtime notification state | toast stack/history | app timer plus frame timer | frame expires/dismisses | in-memory only | timestamps are runtime values | +| Theme contrast | theme tokens and safe pairs | doctor report | ratio below 4.5 | select another pair | source constants | ratios are deterministic | + +## Architecture / Anti-SLUDGE Posture + +| Concern | Decision | +| ------------------------------- | ------------------------------------------------------------------ | +| Domain changes | Preserve the existing `DashModel` and storage commands | +| Port changes | Use Bijou's Node host and frame page contracts | +| Adapter changes | Replace the app-owned stderr adapter with stream overrides | +| Boundary validation | Keep structured stdout separate from human stderr | +| Runtime-backed nouns introduced | `FramePage` and `FramedApp` only | +| Expected failure representation | Existing app errors plus frame runtime notifications | +| Banned shortcuts avoided | No compatibility shell, raw cursor literals, or duplicate overlays | + +## Cost / Residency Posture + +| Surface | Current cost | Target cost | Limit/budget | Failure mode | +| ------------------ | ------------------------------ | -------------------------- | ----------------------------------- | -------------------------------- | +| Shell state | duplicate app/frame candidates | one frame-owned state tree | one page plus bounded notifications | frame runtime error notification | +| Progress output | one render per chunk | same, shared cursor guard | existing bar width | static fallback | +| Theme verification | manual review | deterministic doctor test | WCAG ratio 4.5 | test failure | + +## Determinism / Replay / Causality + +Pure page and frame reducers remain replayable from messages. Runtime clocks +still drive title animation and notification expiry. Tests use injected +contexts and commands; no Git state or object identity changes. + +## Git Substrate Impact + +| Substrate area | Impact | +| ----------------------- | ----------------------------------------------------------- | +| refs | none | +| commits | none | +| trees/blobs | none | +| object ids | none | +| tag/release behavior | patch release `v6.5.6` | +| migration compatibility | downstream git-warp receives Bijou 7 without nested Bijou 5 | + +## Compatibility / Migration Posture + +| Concern | Decision | +| -------------------------- | -------------------------------------------------- | +| Public API compatibility | unchanged | +| Package export changes | none | +| Storage/read compatibility | unchanged | +| Legacy behavior retained | static fallback and cockpit domain actions | +| Deprecation behavior | remove app-owned shell implementation, not aliases | +| Migration path | dependency-only upgrade for consumers | +| Release note impact | dependency and interactive-shell patch | + +## Error Contract + +| Failure | Error/result | Caller recovery | Test | +| ------------------------- | ---------------------------------- | ------------------------- | --------------- | +| noninteractive terminal | static listing | consume text/redirect | launcher test | +| app command failure | page state plus frame notification | inspect message and retry | dashboard tests | +| theme contrast regression | doctor report/test failure | select compliant token | theme test | +| hosted runtime failure | Bijou runtime notification/error | inspect lower-mode error | frame test | + +## Security / Trust / Redaction Posture + +The frame receives only already-rendered operator facts. Passphrases and derived +vault keys remain in the page model and are not added to command items, +settings, notifications, help, or search text. Structured stdout remains +reserved for machine output. + +## Lower Modes + +Noninteractive launch continues to emit the tab-separated static listing. +Tests can inspect the page model, frame model, command/search items, safe-pair +doctor report, and captured output without a terminal screenshot. + +## Accessibility Posture + +Frame-owned help, settings, search, notifications, and quit confirmation inherit +Bijou's accessible/static/pipe lowering. Application text remains in a linear +reading order. High-contrast foreground/background pairs must meet at least +4.5:1 and are enforced by tests. + +## User-Facing Text / Directionality + +Shell text comes from Bijou. Application command, search, and settings labels +use logical action language and no left/right navigation assumptions. The +static fallback remains plain text. + +## Agent Inspectability / Explainability Posture + +An agent can inspect page key bindings, command items, search items, settings +sections, frame state flags, notification commands, and the theme doctor report. +No shell behavior needs to be inferred from private terminal escape sequences. + +## Linked Invariants + +- Human progress and diagnostics never contaminate structured stdout. +- The frame is the sole owner of outer-shell state and lifecycle. +- Cockpit domain operations remain in the page reducer. +- Foreground/background text pairs are selected with Design Book and remain at + or above 4.5:1 contrast. +- Noninteractive use does not require a TTY. + +## Design Alternatives Considered + +### Update versions only + +Rejected because it retains the duplicate shell and misses the principal Bijou +7 application benefit. + +### Make every workspace a frame page + +Rejected for this cycle because each frame page owns a separate model. The +cockpit's vault/auth/cache/service state is intentionally shared across its +workspaces; duplicating or mutably sharing it would weaken the reducer model. + +### Host one cockpit page + +Selected. The frame owns application shell concerns while the coherent cockpit +domain model continues to own its internal workspaces. + +## Decision + +Host one git-cas cockpit page in `createFramedApp()` and delete every duplicated +outer-shell concern from the page model and renderer. + +## Proof Surface + +- installed dependency graph from `npm ls`; +- context and progress unit tests; +- page reducer and renderer tests; +- framed-app routing/state tests; +- theme doctor and explicit contrast tests; +- noninteractive launcher test; +- full unit/integration/lint/release gates. + +## Implementation Slices + +- Upgrade dependency family and lockfile. +- Adopt Node host, cursor guard, and safe-pair theme contracts. +- Introduce the cockpit `FramePage` and hosted `FramedApp`. +- Move search, commands, settings, notifications, telemetry, and quit to frame. +- Remove duplicated renderer/model state. +- Validate and publish `v6.5.6`. + +## Tests To Write First + +- [x] Bijou caretaker-range regression. +- [x] cursor ownership and initial-count regression. +- [x] declared and manually styled contrast pairs. +- [x] framed app initializes the cockpit page. +- [x] frame owns settings, search, telemetry, notifications, and quit state. +- [x] page search selects an asset and loads its manifest. +- [x] noninteractive launch retains static output. + +## Acceptance Criteria + +- [x] one installed Bijou 7.2 family and no Bijou 5 packages; +- [x] production interactive launch calls `FramedApp.run()`; +- [x] no app-owned outer-shell model fields, timers, or renderers remain; +- [x] application actions appear in frame help/commands/search; +- [x] static mode and structured stdout boundaries are unchanged; +- [x] all declared text/surface pairs pass at 4.5:1 or higher; +- [ ] full validation and package/release checks pass. + +## Validation Plan + +Run targeted TUI tests during conversion, then `npm test`, `npx eslint .`, +dependency-tree inspection, package dry-run, release preparation, and the +repository's documented pre-release gates. Capture exact commands and counts in +the cycle witness. + +## Playback / Witness + +The witness must answer: + +- Does the interactive launcher return a hosted `FramedApp`? +- Which state and render paths disappeared from the application? +- Can an operator search assets and run commands through the frame? +- Does the frame own notifications and quit confirmation? +- Is static output unchanged? +- Does the dependency tree contain any Bijou 5 package? +- Do all text/surface contracts meet the contrast threshold? + +## Risks + +- Frame/page key conflicts could change navigation. +- Removing custom overlays could hide app-specific facts if settings and command + providers are incomplete. +- A generic test context may not carry app-only theme tokens. +- Notification commands must not expose secrets. + +Mitigations are explicit page-first application bindings, provider tests, +fallback theme lookup, and payload review. + +## Follow-On Debt + +- A separately designed light theme may be added only after Design Book + selection and contrast proof. +- Store wizard adoption of newer first-party form blocks remains a separate + product change. + +## Tracker Disposition + +GitHub issues #105 and #106 remain open until the implementation, witness, +merge, and patch release are complete. + +## Done Does Not Mean + +- the broader TUI modernization tracked by #39 is complete; +- a light cockpit theme has been designed or shipped; +- the store wizard has been rewritten around newer form blocks; +- every internal cockpit panel has been rewritten as a Bijou frame pane. + +## Retrospective + +Pending merge and release evidence. diff --git a/docs/design/0056-bijou-7-framed-cockpit/witness/validation.md b/docs/design/0056-bijou-7-framed-cockpit/witness/validation.md new file mode 100644 index 00000000..4acfe4a8 --- /dev/null +++ b/docs/design/0056-bijou-7-framed-cockpit/witness/validation.md @@ -0,0 +1,134 @@ +# Bijou 7 Framed Cockpit Witness + +Date: 2026-07-27 + +Branch: `deps/bijou-7.2` + +Base: `9ea91a738f2cbadf2a20b5ac7c2c6d54ba9f409e` + +## Dependency family + +`npm ls @flyingrobots/bijou @flyingrobots/bijou-node +@flyingrobots/bijou-tui @flyingrobots/bijou-tui-app --all` resolved one +coherent family: + +```text +@flyingrobots/bijou@7.2.0 +@flyingrobots/bijou-node@7.2.0 +@flyingrobots/bijou-tui@7.2.0 +@flyingrobots/bijou-tui-app@7.2.0 +``` + +There were no Bijou 5 packages in the installed tree. + +## Ownership proof + +The interactive launcher now returns a hosted `FramedApp` and invokes +`app.run({ ctx })`. The frame owns: + +- outer application header and footer; +- help; +- command and asset-search palettes; +- settings; +- transient notifications and notification history; +- performance telemetry; +- quit confirmation; +- terminal runtime lifecycle. + +The single `cockpit` page continues to own vault authentication, source and +asset state, Explorer/Atlas/Operations navigation, the store wizard, and +storage commands. + +Structural inspection against the base showed the duplicated application +shell disappearing: + +- `dashboard-view.js`: seven old shell/overlay helpers removed; +- `dashboard.js`: twelve old palette, filtering-overlay, help, notification, + and quit helpers removed; +- `createDashboardPage()` added as the explicit domain/page contract; +- `createDashboardApp()` now hosts that page through `createFramedApp()`. + +## Interaction proof + +Focused tests prove: + +- the frame initializes the `cockpit` page; +- `F2`, backtick, and `q` change frame state rather than `DashModel`; +- `/` opens frame search over page-provided asset/digest items; +- selecting a search item dispatches a page-scoped action and opens its + manifest view; +- password and Store Wizard modal input receive ordinary characters and + shell-reserved `q`, `/`, and `?` keys instead of dropping them or opening + frame chrome; +- page failures become frame-managed notifications; +- runtime dimensions seed both the frame and page; +- static launch retains tab-separated output. + +## Progress and cursor proof + +Store and restore progress use Bijou's reference-counted cursor guard. +Regression coverage proves: + +- the initial `0/N` render does not increment the processed count; +- normal detach restores the cursor; +- an exception during the first progress render also restores the cursor and + leaves no event listener attached. + +## Contrast proof + +Design Book selected the high-contrast foreground for the dark application +surfaces. Bijou's theme doctor and explicit text-tone tests enforce a minimum +4.5:1 ratio. + +Measured against the primary `#25313a` canvas: + +| Foreground | Contrast | +| ---------------- | -------: | +| ghost `#fbfcfc` | 12.94:1 | +| pearl `#e4ebf1` | 11.05:1 | +| slate `#bccbd8` | 8.03:1 | +| cyan `#23ddd2` | 7.82:1 | +| orange `#ffa366` | 6.77:1 | +| ruby `#ff6f88` | 4.99:1 | +| sky `#97c2ff` | 7.27:1 | +| violet `#ba9bff` | 5.84:1 | + +The former deep-slate subdued pairing measured 3.33:1 and was replaced by +slate. + +## Validation + +### Unit and lint + +```text +npm test +Test Files 225 passed (225) +Tests 2095 passed | 2 skipped (2097) + +npx eslint . +exit 0 +``` + +### Docker integration + +The first local `npm run test:integration` attempt was rejected before test +execution because integration suites require `GIT_STUNTS_DOCKER=1`. The +repository-prescribed command then passed: + +```text +npm run test:integration:node +Test Files 13 passed (13) +Tests 199 passed (199) +``` + +### Package dry run + +```text +npm pack --dry-run --json +entryCount 256 +packed size 786023 bytes +unpacked size 2211848 bytes +``` + +Full release-verifier and publication evidence are appended only after their +respective gates complete. diff --git a/docs/design/README.md b/docs/design/README.md index c8b6f874..2a27cfec 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ process in [docs/method/process.md](../method/process.md). ## Active METHOD Cycles +- [0056-bijou-7-framed-cockpit - bijou-7-framed-cockpit](./0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md) - [0054-batched-page-retention - batched-page-retention](./0054-batched-page-retention/batched-page-retention.md) - [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md) - [0049-scoped-staging-workspaces — scoped-staging-workspaces](./0049-scoped-staging-workspaces/scoped-staging-workspaces.md) diff --git a/package.json b/package.json index 9354422e..8727f763 100644 --- a/package.json +++ b/package.json @@ -102,10 +102,10 @@ "format": "prettier --write ." }, "dependencies": { - "@flyingrobots/bijou": "^5.0.0", - "@flyingrobots/bijou-node": "^5.0.0", - "@flyingrobots/bijou-tui": "^5.0.0", - "@flyingrobots/bijou-tui-app": "^5.0.0", + "@flyingrobots/bijou": "^7.2.0", + "@flyingrobots/bijou-node": "^7.2.0", + "@flyingrobots/bijou-tui": "^7.2.0", + "@flyingrobots/bijou-tui-app": "^7.2.0", "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^3.2.0", "@git-stunts/vault": "^1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44153ebc..82c8d623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@flyingrobots/bijou': - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^7.2.0 + version: 7.2.0 '@flyingrobots/bijou-node': - specifier: ^5.0.0 - version: 5.0.0(@flyingrobots/bijou@5.0.0) + specifier: ^7.2.0 + version: 7.2.0(@flyingrobots/bijou@7.2.0) '@flyingrobots/bijou-tui': - specifier: ^5.0.0 - version: 5.0.0(@flyingrobots/bijou@5.0.0) + specifier: ^7.2.0 + version: 7.2.0(@flyingrobots/bijou@7.2.0) '@flyingrobots/bijou-tui-app': - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^7.2.0 + version: 7.2.0 '@git-stunts/alfred': specifier: ^0.10.0 version: 0.10.0 @@ -269,28 +269,28 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@flyingrobots/bijou-i18n@5.0.0': - resolution: {integrity: sha512-S3HUHBBLh7fZlijcyuJvtrcJYa+rlhmfW5AAaMZmvIS1P9yd38t7P3JaPGsmKPJjCIVsVgiH3zrjWY15TKB6xA==} + '@flyingrobots/bijou-i18n@7.2.0': + resolution: {integrity: sha512-1c2DPftoSxBRfp7EDpNknRpoQsODbjyy3SMY1soRSU4C61lEX/FM3P1mtbO5HmYRkYuzBolgWZhLZ7e1BMP5KA==} engines: {node: '>=18'} - '@flyingrobots/bijou-node@5.0.0': - resolution: {integrity: sha512-Ano8ydJKF/M8MGPeQDMjOuLouKFEOnEaTwYOKAHHMWxDh03G0Yt9HqZPPu364puzJuyFkY1APxtsxUCbru+5IA==} + '@flyingrobots/bijou-node@7.2.0': + resolution: {integrity: sha512-8PCWqr3WsBclZAnTqtnNd5xi85VFiCpbVjVrJ+jbb3UhC6QShz0meREUjSaSdfdguol9tribuy4a7hZaiqsvRw==} engines: {node: '>=18'} peerDependencies: - '@flyingrobots/bijou': 5.0.0 + '@flyingrobots/bijou': 7.2.0 - '@flyingrobots/bijou-tui-app@5.0.0': - resolution: {integrity: sha512-NrmTalkI1uIEBosE4tzSF04pMbP+Rs32NNlCRY6njDawDYr8mDBLErziyomio/zhHVVKvnrLuo6g4aLsDPKqHw==} + '@flyingrobots/bijou-tui-app@7.2.0': + resolution: {integrity: sha512-WKE+4qURIdO6yh6yufzLuRApLOp6GiwFmh9aNoCJ1mJNHY4AOVAo8uYxAcTGXpO3NcQWYRmP5new+jCNaRnZ2Q==} engines: {node: '>=18'} - '@flyingrobots/bijou-tui@5.0.0': - resolution: {integrity: sha512-dJAWBIZ8osXIM5y6Mc2KsawHPYR/3JxQEO78yVzzdsQiAx9PZLTQvB8fY6oUQRf+/n3sU9l2Ep0UUsJIbhAmpA==} + '@flyingrobots/bijou-tui@7.2.0': + resolution: {integrity: sha512-cwr4Lh38toZCt+DDCAkaxcJn7DLST0fZDVfbTL8CRmtO9611SxvDqmw8Ij18reEmY63G72qVXlBsyfza7QhKdg==} engines: {node: '>=18'} peerDependencies: - '@flyingrobots/bijou': 5.0.0 + '@flyingrobots/bijou': 7.2.0 - '@flyingrobots/bijou@5.0.0': - resolution: {integrity: sha512-Vmcs1jZYIxwb2NOn+LCDMK8ZmIKz64eTQI+gEk11Odn32s4ipIrzawrfrrAWZ4UTsdD5c9xWwwJH6SYgo5klBg==} + '@flyingrobots/bijou@7.2.0': + resolution: {integrity: sha512-f7Ik7Wx/DBgaVoplt5QmrFiH84ri1p1DIfyYpxkcCAEwbhf3i+IU3bd3rohWaHWXtC7J5FINOmW8Cgbo9SVSuw==} engines: {node: '>=18'} '@git-stunts/alfred@0.10.0': @@ -1137,27 +1137,27 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@flyingrobots/bijou-i18n@5.0.0': {} + '@flyingrobots/bijou-i18n@7.2.0': {} - '@flyingrobots/bijou-node@5.0.0(@flyingrobots/bijou@5.0.0)': + '@flyingrobots/bijou-node@7.2.0(@flyingrobots/bijou@7.2.0)': dependencies: - '@flyingrobots/bijou': 5.0.0 - '@flyingrobots/bijou-tui': 5.0.0(@flyingrobots/bijou@5.0.0) + '@flyingrobots/bijou': 7.2.0 + '@flyingrobots/bijou-tui': 7.2.0(@flyingrobots/bijou@7.2.0) chalk: 5.6.2 gifenc: 1.0.3 oled-font-5x7: 1.0.3 - '@flyingrobots/bijou-tui-app@5.0.0': + '@flyingrobots/bijou-tui-app@7.2.0': dependencies: - '@flyingrobots/bijou': 5.0.0 - '@flyingrobots/bijou-tui': 5.0.0(@flyingrobots/bijou@5.0.0) + '@flyingrobots/bijou': 7.2.0 + '@flyingrobots/bijou-tui': 7.2.0(@flyingrobots/bijou@7.2.0) - '@flyingrobots/bijou-tui@5.0.0(@flyingrobots/bijou@5.0.0)': + '@flyingrobots/bijou-tui@7.2.0(@flyingrobots/bijou@7.2.0)': dependencies: - '@flyingrobots/bijou': 5.0.0 - '@flyingrobots/bijou-i18n': 5.0.0 + '@flyingrobots/bijou': 7.2.0 + '@flyingrobots/bijou-i18n': 7.2.0 - '@flyingrobots/bijou@5.0.0': {} + '@flyingrobots/bijou@7.2.0': {} '@git-stunts/alfred@0.10.0': {} diff --git a/test/unit/cli/dashboard.launch.test.js b/test/unit/cli/dashboard.launch.test.js index c67aa8de..ed71a3dd 100644 --- a/test/unit/cli/dashboard.launch.test.js +++ b/test/unit/cli/dashboard.launch.test.js @@ -4,12 +4,21 @@ import launchDashboard from '../../../bin/ui/dashboard.js'; describe('launchDashboard runtime wiring', () => { it('uses injected runtime dimensions for the first frame', async () => { - const cas = { listVault: vi.fn().mockResolvedValue([]), getVaultMetadata: vi.fn().mockResolvedValue({}) }; + const cas = { + listVault: vi.fn().mockResolvedValue([]), + getVaultMetadata: vi.fn().mockResolvedValue({}), + }; const runMock = vi.fn(); const tickMock = vi.fn(); const ctx = createTestContext({ mode: 'interactive', - runtime: { columns: 120, rows: 40, stdoutIsTTY: true, stdinIsTTY: true, env: () => undefined } + runtime: { + columns: 120, + rows: 40, + stdoutIsTTY: true, + stdinIsTTY: true, + env: () => undefined, + }, }); await launchDashboard(cas, { ctx, runApp: runMock, tick: tickMock }); @@ -26,3 +35,24 @@ describe('launchDashboard runtime wiring', () => { expect(model.rows).toBe(40); }); }); + +describe('launchDashboard lower modes', () => { + it('retains the tab-separated static fallback outside a TTY', async () => { + const cas = { + listVault: vi.fn().mockResolvedValue([{ slug: 'alpha', treeOid: 'abc123' }]), + getVaultMetadata: vi.fn().mockResolvedValue({}), + }; + const output = { write: vi.fn() }; + const ctx = createTestContext({ + mode: 'static', + runtime: { stdoutIsTTY: false, stdinIsTTY: false, env: () => undefined }, + }); + + await launchDashboard(cas, { ctx, output }); + + expect(output.write).toHaveBeenCalledOnce(); + expect(output.write.mock.calls[0][0]).toContain('alpha'); + expect(output.write.mock.calls[0][0]).toContain('abc123'); + expect(output.write.mock.calls[0][0]).toContain('alpha\tabc123'); + }); +}); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index ecc15bdc..a6ddeacf 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { surfaceToString } from '@flyingrobots/bijou'; -import { createNavigableTableState, createNotificationState } from '@flyingrobots/bijou-tui'; +import { createNavigableTableState, wrapPageMsg } from '@flyingrobots/bijou-tui'; import { makeCtx } from './_testContext.js'; import { createFeedState } from '../../../bin/ui/blocks/operation-feed.js'; @@ -9,7 +9,19 @@ vi.mock('../../../bin/ui/context.js', () => ({ createCliTuiContext: () => makeCtx(), })); -const { createDashboardApp } = await import('../../../bin/ui/dashboard.js'); +const { createDashboardApp: createFramedDashboardApp, createDashboardPage } = + await import('../../../bin/ui/dashboard.js'); + +function createPageHarness(deps) { + const page = createDashboardPage(deps); + return { + init: page.init, + update: page.update, + view(model) { + return page.layout(model).render(model.columns, model.rows); + }, + }; +} function makeDeps(overrides = {}) { const ctx = makeCtx(); @@ -40,9 +52,6 @@ function modelCoreDefaults() { return { phase: 'dashboard', titleTimeMs: 0, - lastTickTime: 0, - fps: 0, - showPerfHud: false, vaultEntryCount: 0, passphrase: '', authError: null, @@ -56,20 +65,15 @@ function modelCoreDefaults() { focusPane: 'ledger', chunkFocus: 0, vaultEncryptionKey: null, - settingsOpen: false, entries: [], filtered: [], filterText: '', - filtering: false, metadata: null, manifestCache: new Map(), loadingSlug: null, - quitConfirm: false, storeWizard: null, error: null, table: createNavigableTableState({ columns: [], rows: [], height: 10 }), - palette: null, - showHelp: false, }; } @@ -91,7 +95,6 @@ function modelServiceDefaults() { treemapStatus: 'idle', treemapReport: null, treemapError: null, - notifications: createNotificationState(), operationFeed: createFeedState(), gitBranch: null, promptEnter: false, @@ -113,22 +116,186 @@ function renderView(view, ctx) { return surfaceToString(view, ctx.style); } +async function dispatchFrameKey(app, model, key) { + const [routed, commands] = app.update( + { + type: 'key', + key, + ctrl: false, + alt: false, + shift: false, + }, + model + ); + let next = routed; + for (const command of commands) { + const message = await command(); + if (message !== undefined) { + [next] = app.update(message, next); + } + } + return next; +} + +describe('hosted dashboard frame', () => { + it('owns settings, performance telemetry, and quit confirmation', () => { + const deps = makeDeps(); + const app = createFramedDashboardApp(deps); + const [initial] = app.init(); + + expect(initial.activePageId).toBe('cockpit'); + expect(initial.settingsOpen).toBe(false); + expect(initial.perfHudOpen).toBe(false); + expect(initial.quitConfirmOpen).toBe(false); + + const [settings] = app.update( + { type: 'key', key: 'f2', ctrl: false, alt: false, shift: false }, + initial + ); + expect(settings.settingsOpen).toBe(true); + expect(renderView(app.view(settings), deps.ctx)).toContain('Cockpit Settings'); + + const [closedSettings] = app.update( + { type: 'key', key: 'escape', ctrl: false, alt: false, shift: false }, + settings + ); + const [performance] = app.update( + { type: 'key', key: '`', ctrl: false, alt: false, shift: false }, + closedSettings + ); + expect(performance.perfHudOpen).toBe(true); + + const [quitRequested] = app.update( + { type: 'key', key: 'q', ctrl: false, alt: false, shift: false }, + performance + ); + expect(quitRequested.quitConfirmOpen).toBe(true); + }); +}); + +describe('hosted dashboard active input', () => { + it('routes ordinary and shell-reserved characters to the password page', async () => { + const deps = makeDeps(); + const app = createFramedDashboardApp(deps); + const [initial] = app.init(); + const seeded = { + ...initial, + pageModels: { + ...initial.pageModels, + cockpit: makeModel({ phase: 'password', passphrase: '' }), + }, + }; + + let next = await dispatchFrameKey(app, seeded, 'a'); + next = await dispatchFrameKey(app, next, 'q'); + next = await dispatchFrameKey(app, next, '/'); + next = await dispatchFrameKey(app, next, '?'); + + expect(next.pageModels.cockpit.passphrase).toBe('aq/?'); + expect(next.quitConfirmOpen).toBe(false); + expect(next.helpOpen).toBe(false); + expect(next.commandPalette).toBeUndefined(); + }); +}); + +describe('hosted dashboard search', () => { + it('owns asset search and dispatches the selected page action', async () => { + const deps = makeDeps(); + const app = createFramedDashboardApp(deps); + const [initial] = app.init(); + const cockpit = makeModel({ + entries: [{ slug: 'alpha', treeOid: 'abc' }], + filtered: [{ slug: 'alpha', treeOid: 'abc' }], + manifestCache: new Map([ + [ + 'alpha', + { + chunks: [{ digest: 'sha256:deadbeef', blob: 'blob-1' }], + }, + ], + ]), + }); + const seeded = { + ...initial, + pageModels: { ...initial.pageModels, cockpit }, + }; + + const [search] = app.update( + { type: 'key', key: '/', ctrl: false, alt: false, shift: false }, + seeded + ); + expect(search.commandPaletteKind).toBe('search'); + + const [queried] = app.update( + { type: 'key', key: 'd', ctrl: false, alt: false, shift: false }, + search + ); + expect(queried.commandPalette.query).toBe('d'); + expect(queried.commandPalette.filteredItems[0].label).toBe('alpha'); + + const [closed, commands] = app.update( + { type: 'key', key: 'enter', ctrl: false, alt: false, shift: false }, + queried + ); + expect(closed.commandPalette).toBeUndefined(); + expect(commands).toHaveLength(1); + + const pageMessage = await commands[0](); + const [selected] = app.update(pageMessage, closed); + expect(selected.pageModels.cockpit.workspace).toBe('explorer'); + expect(selected.pageModels.cockpit.explorerMode).toBe('manifest'); + expect(selected.pageModels.cockpit.filterText).toBe('alpha'); + }); +}); + +describe('hosted dashboard notifications', () => { + it('routes page failures into frame-managed notifications', async () => { + const deps = makeDeps(); + const app = createFramedDashboardApp(deps); + const [initial] = app.init(); + const seeded = { + ...initial, + pageModels: { ...initial.pageModels, cockpit: makeModel() }, + }; + + const [failed, commands] = app.update( + wrapPageMsg('cockpit', { + type: 'load-error', + source: 'entries', + error: 'object read failed', + }), + seeded + ); + expect(failed.pageModels.cockpit.error).toBe('object read failed'); + expect(commands).toHaveLength(1); + + const notificationMessage = await commands[0](); + const [notified] = app.update(notificationMessage, failed); + expect(notified.runtimeNotifications.items).toHaveLength(1); + expect(notified.runtimeNotifications.items[0]).toMatchObject({ + title: 'Dashboard load failed', + message: 'object read failed', + tone: 'ERROR', + }); + }); +}); + describe('dashboard basic rendering', () => { it('renders themed dashboard headers', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const rendered = renderView(app.view(makeModel()), deps.ctx); - expect(rendered).toContain('git-cas cockpit'); - expect(rendered).toContain('Explorer'); - expect(rendered).toContain('Atlas'); - expect(rendered).toContain('Operations'); + expect(rendered).not.toContain('git-cas cockpit'); + expect(rendered).toContain('1 Explorer'); + expect(rendered).toContain('2 Atlas'); + expect(rendered).toContain('3 Operations'); expect(rendered).toContain('Asset Ledger'); expect(rendered.split('\n').slice(0, 4).join('\n')).not.toContain('palette ctrl+p'); }); it('renders entry list when entries exist', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const model = makeModel({ entries: [{ slug: 'alpha', treeOid: 'abc' }], filtered: [{ slug: 'alpha', treeOid: 'abc' }], @@ -146,45 +313,10 @@ describe('dashboard basic rendering', () => { }); }); -describe('dashboard command palette', () => { - it('opens command palette for digest search', () => { - const deps = makeDeps(); - const app = createDashboardApp(deps); - const model = makeModel({ - entries: [{ slug: 'alpha', treeOid: 'abc' }], - filtered: [{ slug: 'alpha', treeOid: 'abc' }], - manifestCache: new Map([ - [ - 'alpha', - { - chunks: [{ digest: 'sha256:deadbeef', blob: 'blob-1' }], - }, - ], - ]), - }); - const [next] = app.update({ type: 'key', key: 'p', ctrl: true }, model); - const [queried] = app.update({ type: 'key', key: 'd' }, next); - expect(queried.palette.query).toBe('d'); - expect(queried.palette.filteredItems[0].id).toBe('alpha'); - }); -}); - -describe('dashboard settings chrome', () => { - it('opens the settings drawer with F2', () => { - const deps = makeDeps(); - const app = createDashboardApp(deps); - const [next] = app.update({ type: 'key', key: 'f2' }, makeModel()); - expect(next.settingsOpen).toBe(true); - const rendered = renderView(app.view(next), deps.ctx); - expect(rendered).toContain('Settings'); - expect(rendered).toContain('Cockpit Settings'); - }); -}); - describe('dashboard detail chrome', () => { it('paginates detail chunks and expands the selected digest in the footer', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const chunks = Array.from({ length: 30 }, (_, index) => ({ index, size: 2048, @@ -222,7 +354,7 @@ describe('dashboard detail chrome', () => { }); const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('Chunk Ledger (30)'); - expect(rendered).toContain('Showing 22-30 of 30'); + expect(rendered).toContain('Showing 24-30 of 30'); expect(rendered).toContain('Page 2/2'); expect(rendered).toContain(`${chunks[25].digest.slice(0, 20)}...`); expect(rendered).toContain(chunks[25].digest); @@ -251,7 +383,7 @@ describe('dashboard vault lock detection', () => { listVault, }, }); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const [model, cmds] = app.init(); const msg = await cmds[0](); const [next] = app.update(msg, model); @@ -275,7 +407,7 @@ describe('dashboard vault passphrase handling', () => { verifyIntegrity: vi.fn().mockResolvedValue(false), }, }); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const [pending, cmds] = app.update( { type: 'key', key: 'enter' }, makeModel({ @@ -308,7 +440,7 @@ describe('dashboard vault key threading', () => { verifyIntegrity: vi.fn().mockResolvedValue(true), }, }); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const [pending, authCmds] = app.update( { type: 'key', key: 'enter' }, makeModel({ @@ -360,7 +492,7 @@ function treemapReport() { describe('dashboard atlas rendering', () => { it('renders the treemap panel with title', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const rendered = renderView( app.view( makeModel({ @@ -379,7 +511,7 @@ describe('dashboard atlas rendering', () => { it('switches to atlas and queues a treemap load', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const [next, cmds] = app.update({ type: 'key', key: '2' }, makeModel()); expect(next.workspace).toBe('atlas'); expect(next.treemapStatus).toBe('loading'); @@ -390,7 +522,7 @@ describe('dashboard atlas rendering', () => { describe('dashboard operations rendering', () => { it('renders the operations deck', () => { const deps = makeDeps(); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const rendered = renderView( app.view(makeModel({ workspace: 'operations', columns: 120 })), deps.ctx @@ -414,7 +546,7 @@ describe('dashboard operations commands', () => { readManifest: vi.fn(), }, }); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const [next, cmds] = app.update( { type: 'key', key: 'x' }, @@ -435,7 +567,7 @@ describe('dashboard store wizard command', () => { const createTree = vi.fn().mockResolvedValue('f'.repeat(40)); const addToVault = vi.fn().mockResolvedValue({ commitOid: 'c'.repeat(40) }); const deps = makeDeps({ cas: { storeFile, createTree, addToVault } }); - const app = createDashboardApp(deps); + const app = createPageHarness(deps); const model = makeModel({ workspace: 'operations', storeWizard: { diff --git a/test/unit/cli/progress.test.js b/test/unit/cli/progress.test.js index 7d8f3fab..58412394 100644 --- a/test/unit/cli/progress.test.js +++ b/test/unit/cli/progress.test.js @@ -8,7 +8,12 @@ const ctx = makeCtx('static'); describe('createStoreProgress', () => { it('returns no-op when quiet is true', () => { - const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: true, ctx }); + const p = createStoreProgress({ + filePath: 'test.bin', + chunkSize: 256 * 1024, + quiet: true, + ctx, + }); const emitter = new EventEmitterObserver(); p.attach(emitter); emitter.metric('chunk', { action: 'stored', index: 0, size: 256 * 1024 }); @@ -17,16 +22,30 @@ describe('createStoreProgress', () => { }); it('attaches and detaches from EventEmitter', () => { - const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false, fileSize: FILE_SIZE, ctx }); + const p = createStoreProgress({ + filePath: 'test.bin', + chunkSize: 256 * 1024, + quiet: false, + fileSize: FILE_SIZE, + ctx, + }); const emitter = new EventEmitterObserver(); p.attach(emitter); expect(emitter.listenerCount('chunk:stored')).toBe(1); p.detach(); expect(emitter.listenerCount('chunk:stored')).toBe(0); }); +}); +describe('store progress events', () => { it('tracks chunk events without throwing', () => { - const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false, fileSize: FILE_SIZE, ctx }); + const p = createStoreProgress({ + filePath: 'test.bin', + chunkSize: 256 * 1024, + quiet: false, + fileSize: FILE_SIZE, + ctx, + }); const emitter = new EventEmitterObserver(); p.attach(emitter); for (let i = 0; i < 5; i++) { @@ -35,6 +54,47 @@ describe('createStoreProgress', () => { p.detach(); expect(emitter.listenerCount('chunk:stored')).toBe(0); }); + + it('uses Bijou cursor ownership without counting the initial render as a chunk', () => { + const interactiveCtx = makeCtx('interactive'); + const p = createStoreProgress({ + filePath: 'test.bin', + chunkSize: 256 * 1024, + quiet: false, + fileSize: FILE_SIZE, + ctx: interactiveCtx, + }); + const emitter = new EventEmitterObserver(); + + p.attach(emitter); + emitter.metric('chunk', { action: 'stored', index: 0, size: 256 * 1024 }); + p.detach(); + + expect(interactiveCtx.io.written).toEqual(['\x1b[?25l', '\x1b[?25h']); + expect(interactiveCtx.io.writtenErr.join('')).toContain('Storing 0/5'); + expect(interactiveCtx.io.writtenErr.join('')).toContain('Storing 1/5 done'); + }); +}); + +describe('progress cursor recovery', () => { + it('restores the cursor when the initial progress render fails', () => { + const interactiveCtx = makeCtx('interactive'); + interactiveCtx.io.writeError = () => { + throw new Error('stderr unavailable'); + }; + const p = createStoreProgress({ + filePath: 'test.bin', + chunkSize: 256 * 1024, + quiet: false, + fileSize: FILE_SIZE, + ctx: interactiveCtx, + }); + const emitter = new EventEmitterObserver(); + + expect(() => p.attach(emitter)).toThrow('stderr unavailable'); + expect(emitter.listenerCount('chunk:stored')).toBe(0); + expect(interactiveCtx.io.written).toEqual(['\x1b[?25l', '\x1b[?25h']); + }); }); describe('createRestoreProgress', () => { diff --git a/test/unit/cli/theme.test.js b/test/unit/cli/theme.test.js new file mode 100644 index 00000000..2ef85930 --- /dev/null +++ b/test/unit/cli/theme.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { doctorTheme, themeContrastRatio } from '@flyingrobots/bijou'; + +import { GIT_CAS_PALETTE, GIT_CAS_THEME, GIT_CAS_THEME_SAFE_PAIRS } from '../../../bin/ui/theme.js'; + +function hex(rgb) { + return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`; +} + +describe('git-cas cockpit theme', () => { + it('keeps every declared text/surface relationship at WCAG AA contrast', () => { + const report = doctorTheme(GIT_CAS_THEME, { + contrastPairs: GIT_CAS_THEME_SAFE_PAIRS, + }); + + expect(report.issues.filter((issue) => issue.kind === 'low-contrast')).toEqual([]); + }); + + it('keeps manually styled text tones readable on the primary canvas', () => { + const canvas = GIT_CAS_THEME.ui.canvas.hex; + const textColors = [ + GIT_CAS_PALETTE.ghost, + GIT_CAS_PALETTE.slate, + GIT_CAS_PALETTE.cyan, + GIT_CAS_PALETTE.orange, + GIT_CAS_PALETTE.ruby, + GIT_CAS_PALETTE.sky, + GIT_CAS_PALETTE.violet, + ]; + + for (const foreground of textColors) { + expect(themeContrastRatio(hex(foreground), canvas)).toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/test/unit/package/dependency-pins.test.js b/test/unit/package/dependency-pins.test.js index 082339b2..27303ad5 100644 --- a/test/unit/package/dependency-pins.test.js +++ b/test/unit/package/dependency-pins.test.js @@ -31,7 +31,7 @@ describe('dependency pins', () => { .sort(([a], [b]) => a.localeCompare(b)); expect(bijouDeps.length).toBeGreaterThan(0); - expect(bijouDeps).toEqual(bijouDeps.map(([name]) => [name, '^5.0.0'])); + expect(bijouDeps).toEqual(bijouDeps.map(([name]) => [name, '^7.2.0'])); }); }); @@ -55,10 +55,14 @@ describe('release package scripts', () => { const outsideGit = mkdtempSync(path.join(tmpdir(), 'git-cas-stamp-')); try { - const output = execFileSync('node', [path.join(repoRoot, 'scripts/stamp-build.js'), '--quiet'], { - cwd: outsideGit, - encoding: 'utf8', - }); + const output = execFileSync( + 'node', + [path.join(repoRoot, 'scripts/stamp-build.js'), '--quiet'], + { + cwd: outsideGit, + encoding: 'utf8', + } + ); const buildInfo = readJson('build-info.json'); expect(output).toBe('');