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:
mainless 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.mjsplus 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.
- each role with its light and dark hex, its
- Radii, each with its
rounded-Nandradius::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'sColumn::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:
- the spec, measured and committed into the porting cycle, with the hand-written half (wire, derivations, oddities) on c2's model;
- the six fleet → personal rules from c131's phase-2 plan;
- the layer-first checklist: the pantry test, roles not hex,
text::T*andradius::R*notpx(literal), shared pieces first, no cycle ids in copy, nounwrap_or_default()on reads; - the app-map region;
- a story per state, with Loading and Failed from
nozzle::scenario::Condition, and aregion_texttest incargo test -p stories; - the shot program and
npm run shots -- --native; - the scorecard row, with the Rust symbol;
- 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.mdpointer now goes to/native-port(c131 D11's hand-off file is still unwritten); - the world list is the four bundled worlds,
meridian-day90the 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
| key | header | declared | hand @1120 | measured | Δ | within 1 px |
|---|---|---|---|---|---|---|
tool | tool (search) | 260 | 278.2 | 278.3 | +0.1 | yes |
kind | type | 90 | 72 | 72.0 | 0 | yes |
server | server | 160 | 137.2 | 137.2 | 0 | yes |
calls | invocations (sorted) | 100 | 106.9 | 106.9 | 0 | yes |
agents | harnesses | 95 | 99.5 | 99.5 | 0 | yes |
success_pct | outcome | 160 | 88.9 | 88.9 | 0 | yes |
lastMs | last used | 110 | 90.5 | 90.5 | 0 | yes |
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
| header | web measured | native Column | Δ | within 1 px |
|---|---|---|---|---|
| tool | 278.3 | flexible, min(200.) → 200 | −78.3 | no |
| type | 72.0 | width(84.) | +12.0 | no |
| server | 137.2 | width(150.) | +12.8 | no |
| invocations | 106.9 | width(120.) | +13.1 | no |
| harnesses | 99.5 | width(116.) | +16.5 | no |
| outcome | 88.9 | width(150.) | +61.1 | no |
| last used | 90.5 | width(112.) | +21.5 | no |
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.
outcomeis 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 narrowoutcometo 89.
- The web's are HTML auto-layout results: the declared widths are hints, and the browser hands out the room by content.
- 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 namesspec-measureas 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
0while it loads and when the backend is down.--statesshows it:- Tool type
0and0 builtin · 0 mcp · 0 skill; Invocations0, withnone 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 forqdash-todos.md(D12). - Tool type
- A glob trap. Playwright's
**/api/**also matches the dev server's modules undercomposables/api/. Holding it stops the app from booting, so the script matches the request path/api/…instead.
Not covered
spec-measuremeasures, 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 inother, 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. --nativecompares 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.jsonand checks the drawn column widths./native-porthas not been run end to end: no page was ported here.- The scorecard's
rustcolumn waits on qnative c8 (D8); until then the skill puts the symbol at the start ofnote. - Its Loading test pattern (draw before the read lands) is written from the code (
Condition::Slowsleeps on the background executor), not yet run.
- The scorecard's
- 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.