← Design · c140

native-port-tools.md

The native-port tools (D11)

2026-11-12. For: Mark, and whoever ports the next page. What's here: the two tools D11 ruled first, built: spec-measure (the script) and /native-port (the skill), plus the fixes to /gpui-story that /native-port needed. One worked run on the Tools listing, compared with the hand spec and with the native page. Nothing is committed.

What each does

scripts/spec-measure.mjs measures one qdash page at 1120 × 720 and writes its spec as data: <name>.spec.json (everything) and <name>.spec.md (a summary to read and diff). Usage is in the script's header; design has no scripts/README.md.

node scripts/spec-measure.mjs --route /inventory/tools --out <cycle>/specs \
  [--base http://localhost:3030] [--theme dark|light|both] [--name tools] \
  [--states loading,failed,empty=?kind=none] [--native ../qnative/crates/app/src/pages/tools.rs]

It records:

  • The content column: main less its padding.
  • Every DataTable: each column's header label, declared and measured width, header and cell alignment, and the cell type (font, size, line height, weight, colour). Also the table's width against its box (does it scroll?), row heights and the empty seat.
  • Type per text role: crumb, title, subtitle, section heading, lede, card title, description, value, device and provenance, table toolbar, header, cell, pill, empty state, banner, link. Each gets its native token (text::T13 · weight::SEMI), and a line height off the scale is flagged (text::T28 (line 28, not 36)).
  • Colours by semantic role, read against q-nuxt-layer/tokens/semantic.mjs plus the chart's categorical slots:
    • each role with its light and dark hex, its role::* name, its uses (text, fill, border, SVG) and the alphas seen;
    • with --theme both, an element counts as a role only if it reads as that role's light hex in light and its dark hex in dark;
    • unmatched colours are reported as hex, with the palette step when there is one;
    • the page's own --qp-* values are checked against the checkout for drift.
  • Radii, each with its rounded-N and radius::R*.
  • The copy: crumbs, title, the subtitle's runs, headings, ledes, cards, provenance, pills (with box, fill, border and text role), empty states, banners and links.
  • --states: loads the page with its /api/… feeds held (Loading) or refused (Failed), and with a query that empties it, then records what each state says.
  • --native: compares each table's measured widths with the Rust page's Column::new(…).width(…), within 1 px or not.

The DOM reader is illustrator-export.mjs's own. Its page.evaluate block is loaded from that file's source at run time, not copied, and provides the radii (its box shapes) and the inventory of every distinct text style (its text frames). It flattens translucent colours over their backdrop, which is right for Illustrator and wrong for role matching. So the roles, colours and tables come from the script's own pass over the same DOM.

/native-port (.claude/skills/native-port/SKILL.md) is the whole loop for one page:

  1. the spec, measured and committed into the porting cycle, with the hand-written half (wire, derivations, oddities) on c2's model;
  2. the six fleet → personal rules from c131's phase-2 plan;
  3. the layer-first checklist: the pantry test, roles not hex, text::T* and radius::R* not px(literal), shared pieces first, no cycle ids in copy, no unwrap_or_default() on reads;
  4. the app-map region;
  5. a story per state, with Loading and Failed from nozzle::scenario::Condition, and a region_text test in cargo test -p stories;
  6. the shot program and npm run shots -- --native;
  7. the scorecard row, with the Rust symbol;
  8. the review-stop entry.

Paths read crates/qguikit, with the note that older records say crates/layer. For the story and capture details it points to /gpui-story rather than repeating them.

/gpui-story, fixed:

  • the page tests run in -p stories, not -p app;
  • the PATTERNS.md pointer now goes to /native-port (c131 D11's hand-off file is still unwritten);
  • the world list is the four bundled worlds, meridian-day90 the default;
  • the region-text pattern is written out;
  • the crate path note is added.

The worked run

node scripts/spec-measure.mjs --route /inventory/tools --out cycles/c140.surface-evaluation/specs --name tools --native ../qnative/crates/app/src/pages/tools.rs --states 'loading,failed,empty=?kind=none' → specs/tools.spec.md and specs/tools.spec.json.

It ran against qdash feat/new-pages @ 28182424c, the same commit the hand spec was read at, with layer 0.9.36 installed and roles from the layer at e7e1454. The run takes about 20 s with the three states.

Against the hand spec (rescued-specs/spec-tools.md §2): every column within 1 px

keyheaderdeclaredhand @1120measuredΔwithin 1 px
tooltool (search)260278.2278.3+0.1yes
kindtype907272.00yes
serverserver160137.2137.20yes
callsinvocations (sorted)100106.9106.90yes
agentsharnesses9599.599.50yes
success_pctoutcome16088.988.90yes
lastMslast used11090.590.50yes

The rest of the hand spec agrees too:

  • The table: 874 px in the 844 box, scrolling by 30. Declared widths sum to 975. Rows are 43 px, and 59 px for skill rows.
  • The type: title 28/700, subtitle 15/400 in two runs (muted, then subtle, with the primary link), band values 40/800, kind pill 11/600 with r 4 and padding 2 × 6, and the three kind-pill tones.

One difference, carried into native: the hand spec gives the kind pills as 19 px tall (builtin, bordered) and 17 px (mcp, skill). The run measures 20 and 18 on the live page with Inter loaded. The pill is an inline box, so its height is the font's content area plus padding and border, not the 18 px line. QGUIkit's kind_pill (data/surface.rs) draws the hand spec's numbers: a 13 px line plus 2 × 2 padding, so 17, and 19 with the border. It is a pixel short in both tones.

Cross-check (scratch only, not filed): I also ran the Bash tool detail page. Its eleven columns in two tables all fall within 0.1 px of the hand spec's §3.

Against the native page (../qnative/crates/app/src/pages/tools.rs): no column within 1 px

headerweb measurednative ColumnΔwithin 1 px
tool278.3flexible, min(200.) → 200−78.3no
type72.0width(84.)+12.0no
server137.2width(150.)+12.8no
invocations106.9width(120.)+13.1no
harnesses99.5width(116.)+16.5no
outcome88.9width(150.)+61.1no
last used90.5width(112.)+21.5no

Why not.

  • The native widths come from neither source. They match neither the declared widths (90, 160, 100, 95, 160, 110) nor the rendered ones. They were set by hand for GPUI.
  • The two surfaces lay a table out differently.
    • The web's are HTML auto-layout results: the declared widths are hints, and the browser hands out the room by content. outcome is declared 160 and renders at 88.9.
    • The native table is fixed widths plus one flexible column, and fitted() can only widen a column to its widest cell. It cannot narrow outcome to 89.
  • The native grid is wider. At 844 it needs 732 + 200 = 932 px and scrolls by 88, where the web scrolls by 30.

The comparison reads the declared Rust values, not the rendered native widths: fitted() may widen a column further. To agree within a pixel, the native catalogue would take the measured widths as fixed widths (tool as the flexible column with min(278.)). That is a one-commit change for the next qnative session. TOOLS 2's "a Rust test checks the widths against the spec" would then hold it there.

The detail page shows the same pattern. tool.rs's Recent invocations widths are each about 10 px over the web's (110 / 110 / 150 / 100 / 110 against 104.9 / 96.8 / 137.9 / 89.8 / 97.6), and its Harnesses ledger drops two fleet columns, so its flexible column is 474 against 256.8.

Found along the way

  • The specs are already home. qnative c8 committed the ten specs to cycles/c2.expressive-range/specs/ (bc60635, D13), and its README already names spec-measure as the way specs are written from now on. rescued-specs/ is now a duplicate. Delete it, or keep it as c140's evidence: your call.
  • The web's Tools band reads 0 while it loads and when the backend is down. --states shows it:
    • Tool type 0 and 0 builtin · 0 mcp · 0 skill; Invocations 0, with none denied; 0 ok · 0 failed · 0 denied.
    • Meanwhile the ledger shows skeleton rows (loading) or — under the "Can't reach qdash" banner (failed).

    That is an honest-state gap (R31: a zero drawn as data). The native page draws its loading skeleton instead. It is a candidate for qdash-todos.md (D12).
  • A glob trap. Playwright's **/api/** also matches the dev server's modules under composables/api/. Holding it stops the app from booting, so the script matches the request path /api/… instead.

Not covered

  • spec-measure measures, it does not read code. It does not record the API calls on the wire, the derivations, the interactions (sort, expand, hover, polling), animation or sticky behaviour, or the fleet → personal reading. The porter writes those, on the model of c2's §1, §5 and §6.
  • Roles follow qdash's and the layer's markup. It knows ._title, PageHeader's <p>, DataMachine's .hairline, DataTable's toolbar seat and empty seat, and [role=status]. Text from a component with none of those lands in other, and pills are found by a heuristic.
  • Colour matching is exact, give or take one per channel. A role seen through stacked opacity is reported as its alpha, and a colour blended by the browser is reported as hex.
  • One viewport per run. An empty state needs a query that empties the page. Loading and Failed hold or refuse every /api/… request after the shell boots on --via /settings.
  • --native compares declared Rust widths only. Nothing measures the native render yet. The next step is a Rust test in the stories crate that reads <page>.spec.json and checks the drawn column widths.
  • /native-port has not been run end to end: no page was ported here.
    • The scorecard's rust column waits on qnative c8 (D8); until then the skill puts the symbol at the start of note.
    • Its Loading test pattern (draw before the read lands) is written from the code (Condition::Slow sleeps on the background executor), not yet run.
  • The other eight tools on the tools page (parity register, page-state matrix, recut and the rest) are not built: D11 ruled these two first.

Qpoint Brand Style Guide