diff --git a/AGENTS.md b/AGENTS.md index 1e221149..9b2fceec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,25 @@ node scripts/run-tests.mjs --filter "widget" 2. If changing runtime, layout, or renderer code, also run integration tests. 3. Run the full suite before committing. +### Mandatory Live PTY Validation for UI Regressions + +For rendering/layout/theme regressions, do not stop at unit snapshots. Run the +app in a real PTY and collect frame audit evidence yourself before asking a +human to reproduce. + +Canonical runbook: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +Minimum required checks for UI regression work: + +1. Run target app/template in PTY with deterministic viewport. +2. Exercise relevant routes/keys (for starship: `1..6`, `t`, `q`). +3. Capture `REZI_FRAME_AUDIT` logs and analyze with + `node scripts/frame-audit-report.mjs ... --latest-pid`. +4. Capture app-level debug snapshots (`REZI_STARSHIP_DEBUG=1`) when applicable. +5. Include concrete evidence in your report (hash changes, route summary, key stages). + ## Verification Protocol (Two-Agent Verification) When verifying documentation or code changes, split into two passes: diff --git a/CLAUDE.md b/CLAUDE.md index 5c8e22a5..433355bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,15 @@ result.toText(); // Render to plain text for snapshots Test runner: `node:test`. Run all tests with `node scripts/run-tests.mjs`. +For rendering regressions, add a live PTY verification pass and frame-audit +evidence (not just snapshot/unit tests). Use: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +This runbook covers deterministic viewport setup, worker-mode PTY execution, +route/theme key driving, and cross-layer log analysis (`REZI_FRAME_AUDIT`, +`REZI_STARSHIP_DEBUG`, `frame-audit-report.mjs`). + ## Skills (Repeatable Recipes) Project-level skills for both Claude Code and Codex: diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md new file mode 100644 index 00000000..05ed2a37 --- /dev/null +++ b/docs/dev/live-pty-debugging.md @@ -0,0 +1,176 @@ +# Live PTY UI Testing and Frame Audit Runbook + +This runbook documents how to validate Rezi UI behavior autonomously in a real +terminal (PTY), capture end-to-end frame telemetry, and pinpoint regressions +across core/node/native layers. + +Use this before asking a human for screenshots. + +## Why this exists + +Headless/unit tests catch many issues, but rendering regressions often involve: + +- terminal dimensions and capability negotiation +- worker transport boundaries (core -> node worker -> native) +- partial redraw/damage behavior across many frames + +The PTY + frame-audit workflow gives deterministic evidence for all of those. + +## Prerequisites + +From repo root: + +```bash +cd +npx tsc -b packages/core packages/node packages/create-rezi +``` + +## Canonical interactive run (Starship template) + +This enables: + +- app-level debug snapshots (`REZI_STARSHIP_DEBUG`) +- cross-layer frame audit (`REZI_FRAME_AUDIT`) +- worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) + +```bash +cd +: > /tmp/rezi-frame-audit.ndjson +: > /tmp/starship.log + +env -u NO_COLOR \ + REZI_STARSHIP_EXECUTION_MODE=worker \ + REZI_STARSHIP_DEBUG=1 \ + REZI_STARSHIP_DEBUG_LOG=/tmp/starship.log \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +Key controls in template: + +- `1..6`: route switch (bridge/engineering/crew/comms/cargo/settings) +- `t`: cycle theme +- `q`: quit + +## Deterministic viewport (important) + +Many regressions are viewport-threshold dependent. Always test with a known +size before comparing runs. + +For an interactive shell/PTY: + +```bash +stty rows 68 cols 300 +``` + +Then launch the app in that same PTY. + +## Autonomous PTY execution (agent workflow) + +When your agent runtime supports PTY stdin/stdout control: + +1. Start app in PTY mode (with env above). +2. Send key sequences (`2`, `3`, `t`, `q`) through stdin. +3. Wait between keys to allow frames to settle. +4. Quit and analyze logs. + +Do not rely only on static test snapshots for visual regressions. + +## Frame audit analysis + +Use the built-in analyzer: + +```bash +node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson --latest-pid +``` + +What to look for: + +- `backend_submitted`, `worker_payload`, `worker_accepted`, `worker_completed` + should stay aligned in worker mode. +- `hash_mismatch_backend_vs_worker` should be `0`. +- `top_opcodes` should reflect expected widget workload. +- `route_summary` should show submissions for every exercised route. +- `native_summary_records`/`native_header_records` confirm native debug pull + from worker path. + +If a log contains multiple app runs, always use `--latest-pid` (or `--pid=`) +to avoid mixed-session confusion. + +## Useful grep patterns + +```bash +rg "runtime.command|runtime.fatal|shell.layout|engineering.layout|engineering.render|crew.render" /tmp/starship.log +rg "\"stage\":\"table.layout\"|\"stage\":\"drawlist.built\"|\"stage\":\"frame.submitted\"|\"stage\":\"frame.completed\"" /tmp/rezi-frame-audit.ndjson +``` + +## Optional deep capture (drawlist bytes) + +Capture raw drawlist payload snapshots for diffing: + +```bash +env \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_DUMP_DIR=/tmp/rezi-drawlist-dumps \ + REZI_FRAME_AUDIT_DUMP_MAX=20 \ + REZI_FRAME_AUDIT_DUMP_ROUTE=crew \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +This writes paired `.bin` + `.json` files with hashes and metadata. + +## Native trace through frame-audit + +Native debug records are enabled by frame audit in worker mode. Controls: + +- `REZI_FRAME_AUDIT_NATIVE=1|0` (default on when frame audit is enabled) +- `REZI_FRAME_AUDIT_NATIVE_RING=` (ring size override) + +Look for stages such as: + +- `native.debug.header` +- `native.drawlist.summary` +- `native.frame.*` +- `native.perf.*` + +## Triage playbook for common regressions + +### 1) “Theme only updates animated region” + +Check: + +1. `runtime.command` contains `cycle-theme`. +2. `drawlist.built` hashes change after theme switch. +3. `frame.submitted`/`frame.completed` continue for that route. + +If hashes do not change, bug is likely in view/theme resolution. +If hashes change but screen does not, investigate native diff/damage path. + +### 2) “Table looks empty or only one row visible” + +Check `table.layout` record: + +- `bodyH` +- `visibleRows` +- `startIndex` / `endIndex` +- table rect height + +If `bodyH` is too small, inspect parent layout/flex and sibling widgets +(pagination or controls often steal height). + +### 3) “Worker mode renders differently from inline” + +Run both modes with identical viewport and compare audit summaries: + +- worker: `REZI_STARSHIP_EXECUTION_MODE=worker` +- inline: `REZI_STARSHIP_EXECUTION_MODE=inline` + +If only worker diverges, focus on backend transport and worker audit stages. + +## Guardrails + +- Keep all instrumentation opt-in via env vars. +- Never print continuous debug spam to stdout during normal app usage. +- Write logs to files (`/tmp/...`) and inspect post-run. +- Prefer deterministic viewport + scripted route/theme steps when verifying fixes. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3eab0163..f3fdd9b1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -66,6 +66,18 @@ npm run test:e2e npm run test:e2e:reduced ``` +## Live PTY Rendering Validation (for UI regressions) + +For terminal rendering/theme/layout regressions, run a live PTY session with +frame-audit instrumentation in addition to normal tests. + +Use the dedicated runbook: + +- [Live PTY UI Testing and Frame Audit Runbook](live-pty-debugging.md) + +That guide includes deterministic viewport setup, worker-mode run commands, +scripted key driving, and cross-layer telemetry analysis. + ## Test Categories ### Unit Tests diff --git a/mkdocs.yml b/mkdocs.yml index c66f6e8e..bcc9b91b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -250,6 +250,7 @@ nav: - Repo Layout: dev/repo-layout.md - Build: dev/build.md - Testing: dev/testing.md + - Live PTY Debugging: dev/live-pty-debugging.md - Code Standards: dev/code-standards.md - Ink Compat Debugging: dev/ink-compat-debugging.md - Perf Regressions: dev/perf-regressions.md diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 33ec6158..c5787984 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -6,6 +6,7 @@ import { truncateWithEllipsis, } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; +import { FRAME_AUDIT_ENABLED, emitFrameAudit } from "../../../perf/frameAudit.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { @@ -605,6 +606,32 @@ export function renderCollectionWidget( ? Math.min(rowCount, startIndex + visibleRows + overscan) : rowCount; + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit( + "tableWidget", + "table.layout", + Object.freeze({ + tableId: props.id, + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + innerW, + innerH, + bodyY, + bodyH, + rowCount, + headerHeight, + rowHeight: safeRowHeight, + virtualized, + startIndex, + endIndex, + visibleRows, + overscan, + }), + ); + } + if (tableStore) { tableStore.set(props.id, { viewportHeight: bodyH, startIndex, endIndex }); } diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index d1ce4396..1b8ab376 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -128,7 +128,10 @@ describe("pagination ids and vnode", () => { const zoneNode = children[0]; assert.equal(zoneNode?.kind, "focusZone"); if (zoneNode?.kind !== "focusZone") return; - const ids = zoneNode.children + const controlsRow = zoneNode.children[0]; + assert.equal(controlsRow?.kind, "row"); + if (controlsRow?.kind !== "row") return; + const ids = controlsRow.children .filter((child) => child.kind === "button") .map((child) => (child.kind === "button" ? child.props.id : "")); assert.equal(ids.includes(getPaginationControlId("pages", "first")), true); diff --git a/packages/core/src/widgets/pagination.ts b/packages/core/src/widgets/pagination.ts index ea202124..6bfa73d4 100644 --- a/packages/core/src/widgets/pagination.ts +++ b/packages/core/src/widgets/pagination.ts @@ -243,6 +243,16 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ }); } + const controlsRow: VNode = { + kind: "row", + props: { + gap: 0, + wrap: true, + items: "center", + }, + children: Object.freeze(controls), + }; + const zone: VNode = { kind: "focusZone", props: { @@ -252,7 +262,7 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ columns: 1, wrapAround: false, }, - children: Object.freeze(controls), + children: Object.freeze([controlsRow]), }; return Object.freeze([zone]); diff --git a/packages/create-rezi/templates/starship/src/screens/bridge.ts b/packages/create-rezi/templates/starship/src/screens/bridge.ts index a2666219..bb4546b1 100644 --- a/packages/create-rezi/templates/starship/src/screens/bridge.ts +++ b/packages/create-rezi/templates/starship/src/screens/bridge.ts @@ -554,7 +554,7 @@ export function renderBridgeScreen( title: "Bridge Overview", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ BridgeCommandDeck({ key: "bridge-command-deck", state, dispatch: deps.dispatch }), ]), }); diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index f5735e23..7cef074d 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -30,7 +30,7 @@ export function renderCargoScreen( title: "Cargo Hold", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CargoDeck({ key: "cargo-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index 1a115088..8830a08c 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -462,7 +462,7 @@ export function renderCommsScreen( title: "Communications", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CommsDeck({ key: "comms-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 44f71ff1..0013fd90 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -7,6 +7,7 @@ import { type RouteRenderContext, type VNode, } from "@rezi-ui/core"; +import { debugSnapshot } from "../helpers/debug.js"; import { resolveLayout } from "../helpers/layout.js"; import { crewCounts, departmentLabel, rankBadge, statusBadge } from "../helpers/formatters.js"; import { selectedCrew, visibleCrew } from "../helpers/state.js"; @@ -193,7 +194,7 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ...tableSkin(tokens), }); - const detailPanel = ui.column({ gap: SPACE.sm }, [ + const detailPanel = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ maybe(selected, (member) => surfacePanel( tokens, @@ -300,8 +301,18 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ), ]); - const manifestBlock = ui.column({ gap: SPACE.sm }, [ - surfacePanel(tokens, "Crew Manifest", [table]), + const manifestBlock = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 10, + overflow: "hidden", + }, + [surfacePanel(tokens, "Crew Manifest", [table])], + ), ui.pagination({ id: ctx.id("crew-pagination"), page, @@ -313,126 +324,185 @@ const CrewDeck = defineWidget((props, ctx): VNode => { const deckLayout = layout.wide ? showDetailPane - ? ui.masterDetail({ - id: ctx.id("crew-master-detail"), - masterWidth: layout.crewMasterWidth, - master: manifestBlock, - detail: detailPanel, - }) - : manifestBlock - : showDetailPane - ? ui.column({ gap: SPACE.sm }, [manifestBlock, detailPanel]) - : manifestBlock; - - const operationsPanel = surfacePanel( - tokens, - "Crew Operations", - [ - sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), - ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ - ui.box( + ? ui.row( { - border: "none", - p: 0, + id: ctx.id("crew-master-detail"), gap: SPACE.sm, - ...(layout.wide ? { flex: 2 } : {}), + width: "100%", + height: "100%", + items: "stretch", + wrap: false, }, [ - ui.row({ gap: SPACE.sm, wrap: true }, [ - ui.badge(`Total ${counts.total}`, { variant: "info" }), - ui.badge(`Active ${counts.active}`, { variant: "success" }), - ui.badge(`Away ${counts.away}`, { variant: "warning" }), - ui.badge(`Injured ${counts.injured}`, { variant: "error" }), - ]), - ui.form([ - ui.field({ - label: "Search Crew", - hint: "Filter by name, rank, or department", - children: ui.input({ - id: ctx.id("crew-search"), - value: props.state.crewSearchQuery, - placeholder: "Type to filter", - onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), - }), - }), - ]), - ui.actions([ - ui.button({ - id: ctx.id("crew-new-assignment"), - label: "New Assignment", - intent: "primary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ui.button({ - id: ctx.id("crew-edit-selected"), - label: "Edit Selected", - intent: "secondary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ]), + ui.box( + { + border: "none", + p: 0, + width: layout.crewMasterWidth, + height: "100%", + overflow: "hidden", + }, + [manifestBlock], + ), + ui.box( + { + border: "none", + p: 0, + flex: 1, + height: "100%", + overflow: "hidden", + }, + [detailPanel], + ), ], - ), - ...(layout.wide - ? [ - ui.box( - { - border: "none", - p: 0, - flex: 1, - }, - [ - surfacePanel( - tokens, - "Crew Snapshot", + ) + : manifestBlock + : showDetailPane + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [manifestBlock], + ), + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [detailPanel], + ), + ]) + : manifestBlock; + + const operationsPanelMaxHeight = Math.max(12, Math.min(22, Math.floor(layout.height * 0.34))); + debugSnapshot("crew.render", { + viewportCols: props.state.viewportCols, + viewportRows: props.state.viewportRows, + visibleCount: visible.length, + sortedCount: sorted.length, + page, + totalPages, + pageDataCount: pageData.length, + showDetailPane, + operationsPanelMaxHeight, + editingCrew: props.state.editingCrew, + }); + const operationsPanel = ui.box( + { + border: "none", + p: 0, + width: "100%", + height: operationsPanelMaxHeight, + overflow: "scroll", + }, + [ + surfacePanel( + tokens, + "Crew Operations", + [ + sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), + ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ + ui.box( + { + border: "none", + p: 0, + gap: SPACE.sm, + ...(layout.wide ? { flex: 2 } : {}), + }, + [ + ui.row({ gap: SPACE.sm, wrap: true }, [ + ui.badge(`Total ${counts.total}`, { variant: "info" }), + ui.badge(`Active ${counts.active}`, { variant: "success" }), + ui.badge(`Away ${counts.away}`, { variant: "warning" }), + ui.badge(`Injured ${counts.injured}`, { variant: "error" }), + ]), + ui.form([ + ui.field({ + label: "Search Crew", + hint: "Filter by name, rank, or department", + children: ui.input({ + id: ctx.id("crew-search"), + value: props.state.crewSearchQuery, + placeholder: "Type to filter", + onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), + }), + }), + ]), + ui.actions([ + ui.button({ + id: ctx.id("crew-new-assignment"), + label: "New Assignment", + intent: "primary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ui.button({ + id: ctx.id("crew-edit-selected"), + label: "Edit Selected", + intent: "secondary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ]), + ], + ), + ...(layout.wide + ? [ + ui.box( + { + border: "none", + p: 0, + flex: 1, + }, [ - selected - ? ui.column({ gap: SPACE.xs }, [ - ui.text(selected.name, { variant: "label" }), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(rankBadge(selected.rank).text, { - variant: rankBadge(selected.rank).variant, + surfacePanel( + tokens, + "Crew Snapshot", + [ + selected + ? ui.column({ gap: SPACE.xs }, [ + ui.text(selected.name, { variant: "label" }), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(rankBadge(selected.rank).text, { + variant: rankBadge(selected.rank).variant, + }), + ui.badge(statusBadge(selected.status).text, { + variant: statusBadge(selected.status).variant, + }), + ui.tag(departmentLabel(selected.department), { variant: "info" }), + ]), + ]) + : ui.text("No crew selected", { + variant: "caption", + style: { fg: tokens.text.muted, dim: true }, }), - ui.badge(statusBadge(selected.status).text, { - variant: statusBadge(selected.status).variant, + ui.divider(), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(`Visible ${sorted.length}`, { variant: "info" }), + ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), + ]), + staffingError + ? ui.callout("Critical staffing below minimum.", { + title: "Guardrail", + variant: "warning", + }) + : ui.callout("Critical staffing thresholds healthy.", { + title: "Guardrail", + variant: "success", }), - ui.tag(departmentLabel(selected.department), { variant: "info" }), - ]), - ]) - : ui.text("No crew selected", { - variant: "caption", - style: { fg: tokens.text.muted, dim: true }, - }), - ui.divider(), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(`Visible ${sorted.length}`, { variant: "info" }), - ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), - ]), - staffingError - ? ui.callout("Critical staffing below minimum.", { - title: "Guardrail", - variant: "warning", - }) - : ui.callout("Critical staffing thresholds healthy.", { - title: "Guardrail", - variant: "success", - }), + ], + { + tone: "inset", + p: SPACE.sm, + gap: SPACE.sm, + }, + ), ], - { - tone: "inset", - p: SPACE.sm, - gap: SPACE.sm, - }, ), - ], - ), - ] - : []), - ]), + ] + : []), + ]), + ], + { tone: "base" }, + ), ], - { tone: "base" }, ); - return ui.column({ gap: SPACE.md, width: "100%" }, [ + return ui.column({ gap: SPACE.md, width: "100%", height: "100%" }, [ operationsPanel, show( asyncCrew.loading, @@ -443,7 +513,20 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ui.skeleton(44, { variant: "text" }), ], { tone: "inset" }), ), - show(!asyncCrew.loading, deckLayout), + show( + !asyncCrew.loading, + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 12, + overflow: "hidden", + }, + [deckLayout], + ), + ), ]); }); @@ -452,7 +535,7 @@ export function renderCrewScreen(context: RouteRenderContext, dep title: "Crew Manifest", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CrewDeck({ key: "crew-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 6247de81..016b2158 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -371,16 +371,27 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = }), ]); - const leftPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - reactorPanel, - ...(showSecondaryPanels ? [treePanel] : []), - ]); - const rightPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - powerPanel, - ...(showSecondaryPanels ? [thermalPanel, diagnosticsPanel] : []), - ]); + const leftPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12 }, [reactorPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + treePanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [reactorPanel]); + const rightPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12, overflow: "hidden" }, [ + powerPanel, + ]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10 }, [thermalPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + diagnosticsPanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [powerPanel]); - const responsiveDeckHeight = Math.max( + const responsiveDeckMinHeight = Math.max( 16, contentRows - (showControlsSummary ? 12 : 10) - (showSecondaryPanels ? 0 : 2), ); @@ -395,7 +406,8 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = border: "none", p: 0, width: "100%", - height: responsiveDeckHeight, + flex: 1, + minHeight: responsiveDeckMinHeight, overflow: "scroll", }, [responsiveDeckBody], @@ -491,7 +503,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = includeResponsiveDeck: renderMode === "full", responsiveDeckMode: useWideRow ? "row" : "column", forceStackViaEnv, - responsiveDeckHeight, + responsiveDeckMinHeight, }); if (veryCompactHeight) { @@ -502,7 +514,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = return ui.column({ gap: SPACE.sm, width: "100%" }, [controlsPanel, reactorPanel]); } - return ui.column({ gap: SPACE.sm, width: "100%" }, [ + return ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ controlsRegion, responsiveDeck, ]); @@ -516,7 +528,7 @@ export function renderEngineeringScreen( title: "Engineering Deck", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ EngineeringDeck({ key: "engineering-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index 9b9aac93..e18b31da 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -209,7 +209,7 @@ function settingsRightRail(state: StarshipState, deps: RouteDeps): VNode { subtitle: activeTheme.label, actions: [ui.badge("Preview", { variant: "info" })], }), - body: ui.column({ gap: SPACE.xs }, [ + body: ui.column({ gap: SPACE.xs, width: "100%", height: "100%" }, [ ui.breadcrumb({ items: [{ label: "Bridge" }, { label: "Settings" }, { label: "Theme Preview" }], }), @@ -277,7 +277,7 @@ export function renderSettingsScreen( title: "Ship Settings", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ SettingsDeck({ key: "settings-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index 91d1ba95..df0e53c7 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,12 +1,12 @@ import { type BadgeVariant, - type Rgb, type Rgb24, type TextStyle, type ThemeDefinition, draculaTheme, extendTheme, nordTheme, + rgb, } from "@rezi-ui/core"; import type { AlertLevel, ThemeName } from "./types.js"; @@ -42,52 +42,52 @@ const DAY_SHIFT_THEME = extendTheme(nordTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 118, g: 208, b: 255 }, + focusRingColor: rgb(118, 208, 255), }, colors: { bg: { - base: { r: 29, g: 42, b: 58 }, - elevated: { r: 40, g: 57, b: 76 }, - overlay: { r: 52, g: 72, b: 94 }, - subtle: { r: 34, g: 49, b: 67 }, + base: rgb(29, 42, 58), + elevated: rgb(40, 57, 76), + overlay: rgb(52, 72, 94), + subtle: rgb(34, 49, 67), }, fg: { - primary: { r: 236, g: 243, b: 255 }, - secondary: { r: 190, g: 217, b: 242 }, - muted: { r: 108, g: 138, b: 168 }, - inverse: { r: 20, g: 30, b: 42 }, + primary: rgb(236, 243, 255), + secondary: rgb(190, 217, 242), + muted: rgb(108, 138, 168), + inverse: rgb(20, 30, 42), }, border: { - subtle: { r: 74, g: 98, b: 126 }, - default: { r: 104, g: 136, b: 168 }, - strong: { r: 137, g: 171, b: 206 }, + subtle: rgb(74, 98, 126), + default: rgb(104, 136, 168), + strong: rgb(137, 171, 206), }, accent: { - primary: { r: 106, g: 195, b: 255 }, - secondary: { r: 129, g: 217, b: 255 }, - tertiary: { r: 180, g: 231, b: 164 }, + primary: rgb(106, 195, 255), + secondary: rgb(129, 217, 255), + tertiary: rgb(180, 231, 164), }, - info: { r: 118, g: 208, b: 255 }, - success: { r: 166, g: 228, b: 149 }, - warning: { r: 255, g: 211, b: 131 }, - error: { r: 239, g: 118, b: 132 }, + info: rgb(118, 208, 255), + success: rgb(166, 228, 149), + warning: rgb(255, 211, 131), + error: rgb(239, 118, 132), selected: { - bg: { r: 68, g: 105, b: 139 }, - fg: { r: 236, g: 243, b: 255 }, + bg: rgb(68, 105, 139), + fg: rgb(236, 243, 255), }, disabled: { - fg: { r: 95, g: 121, b: 149 }, - bg: { r: 39, g: 55, b: 72 }, + fg: rgb(95, 121, 149), + bg: rgb(39, 55, 72), }, diagnostic: { - error: { r: 239, g: 118, b: 132 }, - warning: { r: 255, g: 211, b: 131 }, - info: { r: 118, g: 208, b: 255 }, - hint: { r: 149, g: 187, b: 228 }, + error: rgb(239, 118, 132), + warning: rgb(255, 211, 131), + info: rgb(118, 208, 255), + hint: rgb(149, 187, 228), }, focus: { - ring: { r: 118, g: 208, b: 255 }, - bg: { r: 63, g: 96, b: 126 }, + ring: rgb(118, 208, 255), + bg: rgb(63, 96, 126), }, }, }); @@ -105,52 +105,52 @@ const NIGHT_SHIFT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 176, g: 133, b: 255 }, + focusRingColor: rgb(176, 133, 255), }, colors: { bg: { - base: { r: 19, g: 22, b: 33 }, - elevated: { r: 28, g: 31, b: 46 }, - overlay: { r: 37, g: 41, b: 60 }, - subtle: { r: 24, g: 27, b: 40 }, + base: rgb(19, 22, 33), + elevated: rgb(28, 31, 46), + overlay: rgb(37, 41, 60), + subtle: rgb(24, 27, 40), }, fg: { - primary: { r: 244, g: 246, b: 252 }, - secondary: { r: 202, g: 185, b: 252 }, - muted: { r: 131, g: 146, b: 186 }, - inverse: { r: 19, g: 22, b: 33 }, + primary: rgb(244, 246, 252), + secondary: rgb(202, 185, 252), + muted: rgb(131, 146, 186), + inverse: rgb(19, 22, 33), }, accent: { - primary: { r: 176, g: 133, b: 255 }, - secondary: { r: 129, g: 235, b: 255 }, - tertiary: { r: 119, g: 255, b: 196 }, + primary: rgb(176, 133, 255), + secondary: rgb(129, 235, 255), + tertiary: rgb(119, 255, 196), }, - info: { r: 129, g: 235, b: 255 }, - success: { r: 110, g: 249, b: 174 }, - warning: { r: 255, g: 207, b: 124 }, - error: { r: 255, g: 118, b: 132 }, + info: rgb(129, 235, 255), + success: rgb(110, 249, 174), + warning: rgb(255, 207, 124), + error: rgb(255, 118, 132), selected: { - bg: { r: 68, g: 76, b: 112 }, - fg: { r: 244, g: 246, b: 252 }, + bg: rgb(68, 76, 112), + fg: rgb(244, 246, 252), }, disabled: { - fg: { r: 99, g: 111, b: 146 }, - bg: { r: 28, g: 31, b: 46 }, + fg: rgb(99, 111, 146), + bg: rgb(28, 31, 46), }, diagnostic: { - error: { r: 255, g: 118, b: 132 }, - warning: { r: 255, g: 207, b: 124 }, - info: { r: 129, g: 235, b: 255 }, - hint: { r: 206, g: 158, b: 255 }, + error: rgb(255, 118, 132), + warning: rgb(255, 207, 124), + info: rgb(129, 235, 255), + hint: rgb(206, 158, 255), }, focus: { - ring: { r: 176, g: 133, b: 255 }, - bg: { r: 64, g: 57, b: 96 }, + ring: rgb(176, 133, 255), + bg: rgb(64, 57, 96), }, border: { - subtle: { r: 43, g: 49, b: 71 }, - default: { r: 72, g: 81, b: 116 }, - strong: { r: 104, g: 115, b: 156 }, + subtle: rgb(43, 49, 71), + default: rgb(72, 81, 116), + strong: rgb(104, 115, 156), }, }, }); @@ -168,52 +168,52 @@ const RED_ALERT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 255, g: 112, b: 112 }, + focusRingColor: rgb(255, 112, 112), }, colors: { bg: { - base: { r: 24, g: 12, b: 19 }, - elevated: { r: 34, g: 15, b: 24 }, - overlay: { r: 46, g: 21, b: 32 }, - subtle: { r: 29, g: 13, b: 22 }, + base: rgb(24, 12, 19), + elevated: rgb(34, 15, 24), + overlay: rgb(46, 21, 32), + subtle: rgb(29, 13, 22), }, fg: { - primary: { r: 255, g: 238, b: 242 }, - secondary: { r: 244, g: 190, b: 205 }, - muted: { r: 170, g: 122, b: 139 }, - inverse: { r: 24, g: 12, b: 19 }, + primary: rgb(255, 238, 242), + secondary: rgb(244, 190, 205), + muted: rgb(170, 122, 139), + inverse: rgb(24, 12, 19), }, accent: { - primary: { r: 255, g: 114, b: 144 }, - secondary: { r: 255, g: 182, b: 120 }, - tertiary: { r: 255, g: 220, b: 146 }, + primary: rgb(255, 114, 144), + secondary: rgb(255, 182, 120), + tertiary: rgb(255, 220, 146), }, - success: { r: 134, g: 247, b: 176 }, - warning: { r: 255, g: 181, b: 112 }, - error: { r: 255, g: 93, b: 117 }, - info: { r: 255, g: 141, b: 153 }, + success: rgb(134, 247, 176), + warning: rgb(255, 181, 112), + error: rgb(255, 93, 117), + info: rgb(255, 141, 153), selected: { - bg: { r: 82, g: 34, b: 52 }, - fg: { r: 255, g: 238, b: 242 }, + bg: rgb(82, 34, 52), + fg: rgb(255, 238, 242), }, disabled: { - fg: { r: 142, g: 96, b: 112 }, - bg: { r: 34, g: 15, b: 24 }, + fg: rgb(142, 96, 112), + bg: rgb(34, 15, 24), }, diagnostic: { - error: { r: 255, g: 93, b: 117 }, - warning: { r: 255, g: 181, b: 112 }, - info: { r: 255, g: 141, b: 153 }, - hint: { r: 255, g: 203, b: 133 }, + error: rgb(255, 93, 117), + warning: rgb(255, 181, 112), + info: rgb(255, 141, 153), + hint: rgb(255, 203, 133), }, focus: { - ring: { r: 255, g: 112, b: 112 }, - bg: { r: 76, g: 35, b: 50 }, + ring: rgb(255, 112, 112), + bg: rgb(76, 35, 50), }, border: { - subtle: { r: 86, g: 45, b: 61 }, - default: { r: 124, g: 65, b: 86 }, - strong: { r: 172, g: 86, b: 112 }, + subtle: rgb(86, 45, 61), + default: rgb(124, 65, 86), + strong: rgb(172, 86, 112), }, }, }); @@ -244,14 +244,10 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -type ColorInput = Rgb | Rgb24; +type ColorInput = Rgb24; -function packRgb(value: Rgb): Rgb24 { - return ( - ((clampChannel(value.r) & 0xff) << 16) | - ((clampChannel(value.g) & 0xff) << 8) | - (clampChannel(value.b) & 0xff) - ); +function packRgb(value: Rgb24): Rgb24 { + return (Math.round(value) >>> 0) & 0x00ff_ffff; } function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { @@ -259,17 +255,10 @@ function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { } function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { - if (typeof value === "number") { - return Object.freeze({ - r: rgbChannel(value, 16), - g: rgbChannel(value, 8), - b: rgbChannel(value, 0), - }); - } return Object.freeze({ - r: clampChannel(value.r), - g: clampChannel(value.g), - b: clampChannel(value.b), + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), }); } diff --git a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts index dccacde3..5bbe85dd 100644 --- a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts +++ b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts @@ -1,27 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { parseColor } from "../../translation/colorMap.js"; test("parseColor maps named colors", () => { - assert.deepEqual(parseColor("green"), { r: 0, g: 205, b: 0 }); - assert.deepEqual(parseColor("whiteBright"), { r: 255, g: 255, b: 255 }); - assert.deepEqual(parseColor("GRAY"), { r: 127, g: 127, b: 127 }); + assert.equal(parseColor("green"), rgb(0, 205, 0)); + assert.equal(parseColor("whiteBright"), rgb(255, 255, 255)); + assert.equal(parseColor("GRAY"), rgb(127, 127, 127)); }); test("parseColor parses hex colors", () => { - assert.deepEqual(parseColor("#00ff7f"), { r: 0, g: 255, b: 127 }); - assert.deepEqual(parseColor("#abc"), { r: 170, g: 187, b: 204 }); + assert.equal(parseColor("#00ff7f"), rgb(0, 255, 127)); + assert.equal(parseColor("#abc"), rgb(170, 187, 204)); }); test("parseColor parses rgb() colors", () => { - assert.deepEqual(parseColor("rgb(12, 34, 56)"), { r: 12, g: 34, b: 56 }); - assert.deepEqual(parseColor("rgb( 0 , 255 , 127 )"), { r: 0, g: 255, b: 127 }); + assert.equal(parseColor("rgb(12, 34, 56)"), rgb(12, 34, 56)); + assert.equal(parseColor("rgb( 0 , 255 , 127 )"), rgb(0, 255, 127)); }); test("parseColor parses ansi256() colors", () => { - assert.deepEqual(parseColor("ansi256(196)"), { r: 255, g: 0, b: 0 }); - assert.deepEqual(parseColor("ansi256(244)"), { r: 128, g: 128, b: 128 }); + assert.equal(parseColor("ansi256(196)"), rgb(255, 0, 0)); + assert.equal(parseColor("ansi256(244)"), rgb(128, 128, 128)); assert.equal(parseColor("ansi256(999)"), undefined); }); diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index 711ead54..72888f2e 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { type InkHostContainer, @@ -83,10 +84,10 @@ test("bordered box maps per-edge border styles", () => { const vnode = translateTree(containerWith(node)) as any; assert.equal(vnode.kind, "box"); - assert.deepEqual(vnode.props.borderStyleSides.top, { fg: { r: 205, g: 0, b: 0 }, dim: true }); - assert.deepEqual(vnode.props.borderStyleSides.right, { fg: { r: 0, g: 205, b: 0 } }); - assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: { r: 0, g: 0, b: 238 } }); - assert.deepEqual(vnode.props.borderStyleSides.left, { fg: { r: 205, g: 205, b: 0 }, dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.top, { fg: rgb(205, 0, 0), dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.right, { fg: rgb(0, 205, 0) }); + assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: rgb(0, 0, 238) }); + assert.deepEqual(vnode.props.borderStyleSides.left, { fg: rgb(205, 205, 0), dim: true }); }); test("bordered row box nests ui.row inside ui.box", () => { @@ -116,7 +117,7 @@ test("background-only box explicitly disables default borders", () => { assert.equal(vnode.kind, "box"); assert.equal(vnode.props.border, "none"); - assert.deepEqual(vnode.props.style, { bg: { r: 28, g: 28, b: 28 } }); + assert.deepEqual(vnode.props.style, { bg: rgb(28, 28, 28) }); }); test("background-only row box keeps row layout without implicit border", () => { @@ -138,7 +139,7 @@ test("styled text maps to text style", () => { assert.equal(vnode.kind, "text"); assert.equal(vnode.text, "Hello"); - assert.deepEqual(vnode.props.style, { fg: { r: 0, g: 205, b: 0 }, bold: true }); + assert.deepEqual(vnode.props.style, { fg: rgb(0, 205, 0), bold: true }); }); test("nested text produces richText spans", () => { @@ -187,7 +188,7 @@ test("ANSI SGR sequences map to richText styles", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "Red"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[1]?.text, " plain "); assert.equal("inverse" in (vnode.props.spans[1]?.style ?? {}), false); assert.equal(vnode.props.spans[2]?.text, "Inv"); @@ -203,11 +204,11 @@ test("ANSI reset restores parent style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "A"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(0, 205, 0)); assert.equal(vnode.props.spans[1]?.text, "B"); - assert.deepEqual(vnode.props.spans[1]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[2]?.text, "C"); - assert.deepEqual(vnode.props.spans[2]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[2]?.style?.fg, rgb(0, 205, 0)); }); test("ANSI truecolor maps to RGB style", () => { @@ -219,7 +220,7 @@ test("ANSI truecolor maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "C"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 120, g: 80, b: 200 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(120, 80, 200)); }); test("ANSI truecolor colon form maps to RGB style", () => { @@ -231,7 +232,7 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "X"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 255, g: 120, b: 40 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); }); test("spacer virtual node maps to ui.spacer", () => { @@ -496,7 +497,7 @@ test("scroll overflow maps scroll props and scrollbar style", () => { assert.equal(vnode.props.overflow, "scroll"); assert.equal(vnode.props.scrollX, 2); assert.equal(vnode.props.scrollY, 5); - assert.deepEqual(vnode.props.scrollbarStyle, { fg: { r: 18, g: 52, b: 86 } }); + assert.deepEqual(vnode.props.scrollbarStyle, { fg: rgb(18, 52, 86) }); }); test("hidden overflow stays hidden without scroll axis", () => { diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts index 0b98f472..63953f08 100644 --- a/packages/node/src/frameAudit.ts +++ b/packages/node/src/frameAudit.ts @@ -145,10 +145,18 @@ export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( ); // Match zr_debug category/code values. +export const ZR_DEBUG_CAT_FRAME = 1; export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CAT_PERF = 6; +export const ZR_DEBUG_CODE_FRAME_BEGIN = 0x0100; +export const ZR_DEBUG_CODE_FRAME_SUBMIT = 0x0101; +export const ZR_DEBUG_CODE_FRAME_PRESENT = 0x0102; +export const ZR_DEBUG_CODE_FRAME_RESIZE = 0x0103; export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; +export const ZR_DEBUG_CODE_PERF_TIMING = 0x0600; +export const ZR_DEBUG_CODE_PERF_DIFF_PATH = 0x0601; function readContextRoute(): string | null { const g = globalThis as { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index b07aef8c..5d58b40f 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -12,9 +12,17 @@ import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_FRAME, + ZR_DEBUG_CAT_PERF, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, + ZR_DEBUG_CODE_FRAME_SUBMIT, + ZR_DEBUG_CODE_PERF_DIFF_PATH, + ZR_DEBUG_CODE_PERF_TIMING, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -318,6 +326,27 @@ const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; +const DEBUG_FRAME_RECORD_BYTES = 56; +const DEBUG_PERF_RECORD_BYTES = 24; +// Must cover sizeof(zr_diff_telemetry_record_t) (includes native trailing pad). +const DEBUG_DIFF_PATH_RECORD_BYTES = 64; +const NATIVE_FRAME_AUDIT_CATEGORY_MASK = + (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); + +function nativeFrameCodeName(code: number): string { + if (code === ZR_DEBUG_CODE_FRAME_BEGIN) return "frame.begin"; + if (code === ZR_DEBUG_CODE_FRAME_SUBMIT) return "frame.submit"; + if (code === ZR_DEBUG_CODE_FRAME_PRESENT) return "frame.present"; + if (code === ZR_DEBUG_CODE_FRAME_RESIZE) return "frame.resize"; + return "frame.unknown"; +} + +function nativePerfPhaseName(phase: number): string { + if (phase === 0) return "poll"; + if (phase === 1) return "submit"; + if (phase === 2) return "present"; + return "unknown"; +} type FrameAuditMeta = { frameSeq: number; @@ -408,7 +437,7 @@ function maybeEnableNativeFrameAudit(): void { enabled: true, ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, minSeverity: 0, - categoryMask: 0xffff_ffff, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, captureRawEvents: false, captureDrawlistBytes: true, }); @@ -431,6 +460,54 @@ function drainNativeFrameAudit(reason: string): void { if (engineId === null) return; const headersCap = DEBUG_HEADER_BYTES * 64; const headersBuf = new Uint8Array(headersCap); + const tryReadDebugPayload = ( + recordId: bigint, + code: number, + expectedBytes: number, + opts: Readonly<{ allowPartialOnShortRead?: boolean }> = {}, + ): Uint8Array | null => { + if (engineId === null) return null; + const payload = new Uint8Array(expectedBytes); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + detail: safeDetail(err), + }); + return null; + } + if (wrote <= 0) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "payload read returned non-positive length", + }); + return null; + } + if (wrote < expectedBytes) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "short payload read", + }); + if (opts.allowPartialOnShortRead !== true) { + return null; + } + } + return payload.subarray(0, Math.min(wrote, payload.byteLength)); + }; + for (let iter = 0; iter < 8; iter++) { let result: DebugQueryResultNative; try { @@ -438,7 +515,7 @@ function drainNativeFrameAudit(reason: string): void { engineId, { minRecordId: nativeFrameAuditNextRecordId, - categoryMask: 1 << ZR_DEBUG_CAT_DRAWLIST, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, minSeverity: 0, maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), }, @@ -487,32 +564,21 @@ function drainNativeFrameAudit(reason: string): void { if (code === ZR_DEBUG_CODE_DRAWLIST_CMD && payloadSize > 0) { const cap = Math.min(Math.max(payloadSize, 1), 4096); - const payload = new Uint8Array(cap); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote > 0) { - const view = payload.subarray(0, Math.min(wrote, payload.byteLength)); - const fp = drawlistFingerprint(view); - frameAudit.emit("native.drawlist.payload", { - reason, - recordId: recordId.toString(), - frameId: frameId.toString(), - payloadSize: view.byteLength, - hash32: fp.hash32, - prefixHash32: fp.prefixHash32, - head16: fp.head16, - tail16: fp.tail16, - }); - } + const view = tryReadDebugPayload(recordId, code, cap, { + allowPartialOnShortRead: true, + }); + if (view === null) continue; + const fp = drawlistFingerprint(view); + frameAudit.emit("native.drawlist.payload", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + payloadSize: view.byteLength, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + head16: fp.head16, + tail16: fp.tail16, + }); continue; } @@ -520,38 +586,104 @@ function drainNativeFrameAudit(reason: string): void { (code === ZR_DEBUG_CODE_DRAWLIST_VALIDATE || code === ZR_DEBUG_CODE_DRAWLIST_EXECUTE) && payloadSize >= DEBUG_DRAWLIST_RECORD_BYTES ) { - const payload = new Uint8Array(DEBUG_DRAWLIST_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_DRAWLIST_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_DRAWLIST_RECORD_BYTES, - ); - frameAudit.emit("native.drawlist.summary", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - totalBytes: dvPayload.getUint32(8, true), - cmdCount: dvPayload.getUint32(12, true), - version: dvPayload.getUint32(16, true), - validationResult: dvPayload.getInt32(20, true), - executionResult: dvPayload.getInt32(24, true), - clipStackMaxDepth: dvPayload.getUint32(28, true), - textRuns: dvPayload.getUint32(32, true), - fillRects: dvPayload.getUint32(36, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_DRAWLIST_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DRAWLIST_RECORD_BYTES, + ); + frameAudit.emit("native.drawlist.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + totalBytes: dvPayload.getUint32(8, true), + cmdCount: dvPayload.getUint32(12, true), + version: dvPayload.getUint32(16, true), + validationResult: dvPayload.getInt32(20, true), + executionResult: dvPayload.getInt32(24, true), + clipStackMaxDepth: dvPayload.getUint32(28, true), + textRuns: dvPayload.getUint32(32, true), + fillRects: dvPayload.getUint32(36, true), + }); + continue; + } + + if ( + (code === ZR_DEBUG_CODE_FRAME_BEGIN || + code === ZR_DEBUG_CODE_FRAME_SUBMIT || + code === ZR_DEBUG_CODE_FRAME_PRESENT || + code === ZR_DEBUG_CODE_FRAME_RESIZE) && + payloadSize >= DEBUG_FRAME_RECORD_BYTES + ) { + const payload = tryReadDebugPayload(recordId, code, DEBUG_FRAME_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_FRAME_RECORD_BYTES, + ); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { + const payload = tryReadDebugPayload(recordId, code, DEBUG_PERF_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const payload = tryReadDebugPayload(recordId, code, DEBUG_DIFF_PATH_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DIFF_PATH_RECORD_BYTES, + ); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); } } if (!advanced) return; diff --git a/scripts/__tests__/check-native-vendor-integrity.test.mjs b/scripts/__tests__/check-native-vendor-integrity.test.mjs index 14ee256c..5abe8ea2 100644 --- a/scripts/__tests__/check-native-vendor-integrity.test.mjs +++ b/scripts/__tests__/check-native-vendor-integrity.test.mjs @@ -3,6 +3,7 @@ */ import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -21,6 +22,10 @@ function makeFixtureRoot() { return mkdtempSync(join(tmpdir(), "rezi-native-vendor-")); } +function runGit(cwd, args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + function writeBaseFixture(root, commit = COMMIT_A) { writeUtf8( join(root, "packages/native/build.rs"), @@ -133,4 +138,25 @@ describe("check-native-vendor-integrity", () => { rmSync(root, { recursive: true, force: true }); } }); + + test("ignores uninitialized gitlink directory in superproject checkout", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + runGit(root, ["init"]); + runGit(root, ["config", "user.name", "Test User"]); + runGit(root, ["config", "user.email", "test@example.com"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "fixture"]); + + const result = checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + }); + assert.equal(result.success, true); + assert.equal(result.pinnedCommit, COMMIT_A); + assert.equal(result.gitlinkCommit, COMMIT_A); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs index df2c85ed..dd7ecb7d 100644 --- a/scripts/check-native-vendor-integrity.mjs +++ b/scripts/check-native-vendor-integrity.mjs @@ -67,6 +67,13 @@ function readSubmoduleHead(rootDir) { return null; } + // A gitlink path may exist as an ordinary directory when submodules are not + // initialized. In that case `git -C ` resolves to the parent repo and + // returns the superproject HEAD, which is not a submodule HEAD. + // Only treat vendor/zireael as checked out when it has its own git metadata. + const submoduleGitMeta = join(submoduleDir, ".git"); + if (!existsSync(submoduleGitMeta)) return null; + try { const head = runGit(submoduleDir, ["rev-parse", "HEAD"]).toLowerCase(); return isHex40(head) ? head : null;