The Cost of Two Surfaces
c1402026-11-12 · Vue and QGUIkit, kept togetherThe web app and the native app show pages that are, for most purposes, the same. Each surface draws them its own way. This page answers the prompt's honest question: how cumbersome is it, how much danger of drift is there, and how easy is parity to keep?
Read at qnative e609f42 and design 4453a8c085. It is row 8 of the scorecard, in full.
Not drift hell, not yet. The danger is silent drift, not effort.
For scale: after the layer existed, each Inventory listing took an agent 11 to 23 minutes to port (2026-11-10, commit timestamps). Status, Sensor and the inbox were built from mocks and design documents rather than ported from the web.
Where one change lands
Three realistic changes, followed through every repo until both surfaces and the lab's mocks agree.
A component’s look: one of UxPill’s tones
- Pill.vue’s TONES map (q-nuxt-layer)
- A layer release and the lockfiles in design and qdash
- The design demo, and component-registry.ts if the values change
- ux.rs’s Tone enum and its colours in qnative (the warn tone is hex, not a token)
- The native doc page (demos/furniture.rs)
- The scorecard row, by hand
- The token bridge and its two outputs, if a palette step moved
- Call sites if a tone’s meaning changes: 47 Tone:: uses in 12 Rust files, 42 <UxPill in 21 qdash files
- Native recaptures and their manifest entries
Nothing flags the Rust twin when Pill.vue changes.
A new column on the Tools listing
- The qdash handler (ToolRow in web.rs) and its tests
- The TypeScript type (types/api/tool.ts)
- ToolsLedger.vue
- ts-to-rust.py rerun for crates/types/src/api.rs
- cut-world.py rerun for the four worlds
- The column catalog in pages/tools.rs
- The region body in app-map.json, if the guide reads it
- sync-worlds.sh for design’s copies
- The design web mock (phase1/tools.vue)
- Recaptures in both atlases
The worlds’ contract test fails if the type and the worlds disagree. Nothing checks that the Rust page shows the column.
A sentence of copy, e.g. “No tool invocations observed yet.”
- ToolsLedger.vue and pages/tools.rs
- The design mock’s sibling string
- The guide’s region bodies (app-map.json against app-map.ts), and the story tests that assert a region’s words
The c5 story tests catch a copy change on the pages they cover; the Inventory stories have no assertions.
What a machine checks, and what an eye does
The data contracts are held by machines. Everything a person looks at, plus the record of where the surfaces differ, is held by eye or by nothing.
| What must agree | Held by | How |
|---|---|---|
| Served shapes (types) | machine | api.rs generated from qdash’s TypeScript; the worlds’ contract test in CI. Compares against the committed worlds, not against qdash as it serves today; the strict variant is not in CI. |
| The stream’s logic | machine | live-vectors.sh runs qdash’s own TypeScript over a corpus; the Rust tests compare against its output. The strongest mechanism; regenerated by hand. |
| The worlds | machine | One source in qnative, copied by sync-worlds.sh (by hand); byte-identical today. |
| Token values | eye | Generated by tokens-to-kit.mjs, run by hand; no check that tokens.rs matches the layer. They matched on 2026-11-12 because nothing has changed since the last run. |
| Number formats | eye | format.rs’s tests hard-code outputs computed once by running the JavaScript in node. |
| Component look and behaviour | eye | The scorecard’s fidelity 0–3, judged against the design site’s demo by the porting agent; no test reads it. |
| Page against page | eye | Wipes on /pages/native/bake-off (UxImageCompare). No pixel diff. |
| The bridge fixtures | nothing | Hand-written twice; design is declared the source and qnative already moved past it. |
| Divergences | nothing | Spread across the scorecard, three cycle documents and the Rust module comments. Nothing on the web side. |
Drift so far
Every case the records and the code show, from the first day of the port to today.
- 2026-11-10inside the web
The contract inside the web stack: the port found 8 places where qdash’s TypeScript and its handlers disagree (ts-to-rust.py OVERRIDES, 3 EXTRA_TS shapes).
Worked around in the generator; none fixed in qdash.
- 2026-11-10drift
Tools column widths: web 260/90/160/100/95/160/110, native 200 (min)/84/150/120/116/150/112.
Unrecorded.
- 2026-11-11drift
The rail: “the native rail grew its own look while the pages were built” (qnative c7). The rail exists four times: qdash’s NavVertical, design’s governance Sidebar, the native skin’s Window, qnative’s shell.
Fix on an unmerged branch (dd4d176).
- 2026-11-11drift
The bridge fixture: qnative c4 added it_contact to bridge/pilot.json and bridge.rs; design’s copy and types.ts lack it.
Live. Running sync-bridge.sh would delete it.
- 2026-11-11ruled
Activity’s lanes: native stacks each day by harness, as the lab’s mock does; the web app stacks by compliance.
Ruled by Mark (c5 call 9). The web is unchanged, so the surfaces differ on purpose.
- 2026-11-12drift
The layer’s uncommitted restyles of JumpPills, PageTitle and useStickyBand. The Rust NavJumpPills and PageHeader were ported against the published look.
In flight. Nothing will flag the twins when it lands.
- 2026-11-12stale record
The scorecard: rows 82 and 84 say the sticky header and the expand row’s keyboard are not built; D19 built both on 2026-11-11. DataHistoryGraph (c5) has no row.
Stale.
Logic held more than once
Some copies are the price of the decision: the components and pages are drawn twice on purpose (c131 D18). The rest could be held by one source and tests: the formatters, the rollups and the request key. live-vectors.sh already shows how, for the stream.
| What | The copies | Held by |
|---|---|---|
| fmtN, fmtC, ago, shortDate |
| By hand. format.rs’s to_fixed rounds half away from zero, not as JavaScript’s toFixed does: a latent difference. |
| The Inventory rollups |
| By hand: a port of a port. |
| The nozzle’s request key |
| The Rust test hard-codes its keys; the TypeScript has none. |
| The app map and its search |
| Translated by hand, “the search ported as is”. |
| Every component and page |
| By hand, by design (c131 D18). This is the cost the decision bought. |
Where the surfaces differ
The seed of a divergence register: every difference the records name, sorted the way the Surface glossary entry (brand/glossary.md) asks. A flavor is welcome and should be written down for the operator. A gap should be written down until it closes. Today these live in the scorecard, three cycle documents and Rust comments, and nowhere on the web side.
| Component | Kind | On Vue | On QGUIkit | Written down |
|---|---|---|---|---|
| DataTable (expand row) | flavor | Per-row open refs: a sort or a refresh closes open rows | The page holds open keys on stable ids: rows stay open through a sort | scorecard.rs:84 |
| DataTable | flavor | Keyboard from the DOM (tab order only) | ↑ ↓ Home End Enter Space Esc, AccessKit roles, row index and expanded state | table.rs:757 |
| DataTable | gap | A scoped slot per column (#<key>="{ row, value }") | Pre-built cells per row, zipped with the columns; a count mismatch truncates without a word until the table is next touched (D6) | c140 D6 |
| DataTable | gap | title, limit, search, initialSort, pill cells | Not carried; the page does it | nowhere |
| DataRankedBars | gap | Stacked labels break words | Labels end in an ellipsis (GPUI wraps only at word breaks) | scorecard.rs:69 |
| Skill Markdown | gap | Placeholders and shell spans outlined | Tinted (text runs carry no border) | review-3.md |
| UxSelectInline | flavor | A native <select> | A click that cycles through the options | scorecard.rs:83 |
| PeopleOwnerCard | flavor | The owner of a fleet resource | Translated away: on one machine the owner is you | scorecard.rs:107 |
| Activity (page) | flavor | Each day stacked by compliance | Each day stacked by the harness that ran (Mark, c5 call 9) | qnative c5 review-2.md |
| Every focusable control but the table | gap | Tab order and roles from the DOM | No focus handle, no role, in V1; the pass comes after V1 ships (D7) | c140 D7 |
| Docs | flavor | Docs on the design site | Docs inside the app, in Dev › Components, with the scorecard row on each page | demos/mod.rs:5 |
Both surfaces in the public docs
Today the design site's registry (193 components) has no surface field, and no page mentions QGUIkit. The native docs are good, but they live inside developer mode, out of the build that ships. Here is what each component's entry would carry, proposed for a ruling.
surfaces: {
vue: { symbol: 'DataTable', status: 'stable' },
gpui: { symbol: 'layer::DataTable', fidelity: 3,
checkedAt: { vue: 'e7e1454', gpui: 'e609f42' },
capture: '/cycles/qnative/c2-doc-data-table-light.png' },
divergences: [
{ kind: 'flavor', text: 'Open rows stay open through a sort.' },
{ kind: 'gap', text: 'No per-column slot: the page builds each cell.' },
],
}- The scorecard becomes data: one JSON both the Rust scorecard and component-registry.ts read (the parity register).
- Each component page on the design site gains a Surfaces panel: Vue and QGUIkit side by side, the native capture from the atlas, fidelity, and each divergence marked flavor or gap.
- A /components view filtered by surface, so an operator deploying on GPUI sees what exists there and what does not.
- The check: a row whose Vue file changed after its checkedAt SHA shows as “unchecked” on the page and fails a CI step in qnative.
The records
- Two Surfaces, Scored →all twenty principles
- Skills and Tools →what would make parity a machine’s job
- c140 Surface Evaluation →the cycle record
- The plan →the rubric and the method, written before the evidence
- The findings →the scores in one table, and what is open
- Review stop 1 →the calls for Mark
- qnative c2, review stop 3 →the port, the scorecard and the budget
- c132 process.md →the two-media loop and the token bridge
- Native App Footprint →every strobe reading, c1 to c5