← Design · c140

The Cost of Two Surfaces

c1402026-11-12 · Vue and QGUIkit, kept together

The 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.
Porting is fast and the data contracts are generated. What grows is the work of keeping each Vue change and its Rust twin together, and nothing tells the twin.
A component's look
places one change lands, across three repos
9–10
A new column
places, plus a type regeneration and a world recut
9–11
Drifts between the surfaces
in the first three days, unchosen; the log below adds a stale record, a ruled difference and a web-side gap
4
Checked by a machine
of 9 kinds of parity; 4 by eye, 2 by nothing
3

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.

3 repos

A component’s look: one of UxPill’s tones

9–10 places
  1. Pill.vue’s TONES map (q-nuxt-layer)
  2. A layer release and the lockfiles in design and qdash
  3. The design demo, and component-registry.ts if the values change
  4. ux.rs’s Tone enum and its colours in qnative (the warn tone is hex, not a token)
  5. The native doc page (demos/furniture.rs)
  6. The scorecard row, by hand
  7. The token bridge and its two outputs, if a palette step moved
  8. Call sites if a tone’s meaning changes: 47 Tone:: uses in 12 Rust files, 42 <UxPill in 21 qdash files
  9. Native recaptures and their manifest entries

Nothing flags the Rust twin when Pill.vue changes.

3 repos

A new column on the Tools listing

9–11 places
  1. The qdash handler (ToolRow in web.rs) and its tests
  2. The TypeScript type (types/api/tool.ts)
  3. ToolsLedger.vue
  4. ts-to-rust.py rerun for crates/types/src/api.rs
  5. cut-world.py rerun for the four worlds
  6. The column catalog in pages/tools.rs
  7. The region body in app-map.json, if the guide reads it
  8. sync-worlds.sh for design’s copies
  9. The design web mock (phase1/tools.vue)
  10. 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.

3 repos

A sentence of copy, e.g. “No tool invocations observed yet.”

2–4 places
  1. ToolsLedger.vue and pages/tools.rs
  2. The design mock’s sibling string
  3. 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 agreeHeld byHow
Served shapes (types)machineapi.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 logicmachinelive-vectors.sh runs qdash’s own TypeScript over a corpus; the Rust tests compare against its output. The strongest mechanism; regenerated by hand.
The worldsmachineOne source in qnative, copied by sync-worlds.sh (by hand); byte-identical today.
Token valueseyeGenerated 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 formatseyeformat.rs’s tests hard-code outputs computed once by running the JavaScript in node.
Component look and behavioureyeThe scorecard’s fidelity 0–3, judged against the design site’s demo by the porting agent; no test reads it.
Page against pageeyeWipes on /pages/native/bake-off (UxImageCompare). No pixel diff.
The bridge fixturesnothingHand-written twice; design is declared the source and qnative already moved past it.
DivergencesnothingSpread 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.

  1. 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.

  2. 2026-11-10drift

    Tools column widths: web 260/90/160/100/95/160/110, native 200 (min)/84/150/120/116/150/112.

    Unrecorded.

  3. 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).

  4. 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.

  5. 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.

  6. 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.

  7. 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.

WhatThe copiesHeld by
fmtN, fmtC, ago, shortDate
  • qdash utils/format.ts
  • qnative layer::data::format (“ported literally, quirks included”)
  • design useNativeMock.ts
By hand. format.rs’s to_fixed rounds half away from zero, not as JavaScript’s toFixed does: a latent difference.
The Inventory rollups
  • qdash lib/*-rollups.ts
  • qnative pages/rollups.rs and the per-page Rust
  • design app/data/native-inventory.ts (ported from the Rust)
By hand: a port of a port.
The nozzle’s request key
  • types/src/lib.rs
  • scripts/cut-world.py
  • design useNativeWorld.ts
The Rust test hard-codes its keys; the TypeScript has none.
The app map and its search
  • design app-map.ts
  • qnative guide/map.rs and app-map.json
Translated by hand, “the search ported as is”.
Every component and page
  • 36 Vue components and the qdash pages
  • about 9,900 lines of the Rust layer and 7,785 lines of pages
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.

flavor6gap5flaw0
ComponentKindOn VueOn QGUIkitWritten down
DataTable (expand row)flavorPer-row open refs: a sort or a refresh closes open rowsThe page holds open keys on stable ids: rows stay open through a sortscorecard.rs:84
DataTableflavorKeyboard from the DOM (tab order only)↑ ↓ Home End Enter Space Esc, AccessKit roles, row index and expanded statetable.rs:757
DataTablegapA 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
DataTablegaptitle, limit, search, initialSort, pill cellsNot carried; the page does itnowhere
DataRankedBarsgapStacked labels break wordsLabels end in an ellipsis (GPUI wraps only at word breaks)scorecard.rs:69
Skill MarkdowngapPlaceholders and shell spans outlinedTinted (text runs carry no border)review-3.md
UxSelectInlineflavorA native <select>A click that cycles through the optionsscorecard.rs:83
PeopleOwnerCardflavorThe owner of a fleet resourceTranslated away: on one machine the owner is youscorecard.rs:107
Activity (page)flavorEach day stacked by complianceEach day stacked by the harness that ran (Mark, c5 call 9)qnative c5 review-2.md
Every focusable control but the tablegapTab order and roles from the DOMNo focus handle, no role, in V1; the pass comes after V1 ships (D7)c140 D7
DocsflavorDocs on the design siteDocs inside the app, in Dev › Components, with the scorecard row on each pagedemos/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.' },
  ],
}
  1. The scorecard becomes data: one JSON both the Rust scorecard and component-registry.ts read (the parity register).
  2. 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.
  3. A /components view filtered by surface, so an operator deploying on GPUI sees what exists there and what does not.
  4. 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

Qpoint Brand Style Guide