Skip to content

feat: add pnpm probe for inspecting rendered HTML & CSS - #1324

Merged
tenphi merged 4 commits into
mainfrom
feat/probe-command
Aug 17, 2026
Merged

feat: add pnpm probe for inspecting rendered HTML & CSS#1324
tenphi merged 4 commits into
mainfrom
feat/probe-command

Conversation

@tenphi

@tenphi tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Member

Describe changes

Answering "what CSS does this actually produce?" required hand-writing a throwaway vitest spec, running it, reading the output and deleting the file. Cube Cloud grew a yarn probe for exactly this (cube-2 #14024) and it already imports its DOM helpers from this repo's @cube-dev/ui-kit/probe entry (#1315) — so the tool belongs here too, where the config, the palette and the components it inspects actually live. The orphaned __screenshots__ dirs left behind by deleted describe('probe') specs are the local evidence of the old habit.

Same modes and flags as Cloud's, so a recipe carries between the two repos:

pnpm probe styles '{"fill":"#purple","padding":"2x","preset":"t3"}'
pnpm probe tokens --scheme dark --hc --filter surface
pnpm probe globals
pnpm probe render <<'TSX'
import { Button } from '@cube-dev/ui-kit';

<Button type="primary">Hello</Button>
TSX

render reports the markup plus only the CSS that snippet caused, by rendering <Root> empty, capturing, mounting and subtracting. Measuring a wrapper div directly would have been simpler and wrong: <Root> is the portal target, so a Dialog renders as its sibling — measured that way its inline markup is 0 chars, while the subtraction still captures all 169 of its rules, reported separately under PORTALS.

pnpm probe:browser adds the four things jsdom cannot do — computed values, geometry, pointer input, screenshots. Same Card, both tiers: var(--surface-2-color) / calc(3 * var(--gap)) under jsdom, rgb(248, 248, 249) / 24px in Chromium. It shares its Chromium and setup.browser.ts with pnpm test:browser, which — unlike Cloud's equivalent — is already wired into CI.

@cube-dev/ui-kit is aliased to src, so a snippet reads like consumer code and the same snippet runs unchanged through Cloud's probe.

Two deliberate divergences from Cloud's implementation

  • globals reads getCSSText(), not the per-node baseline. Console-ui hands its palette to <Root> through a tasty tokens prop, so the token block is attributed to that node and a per-node dump carries all ~119KB of it. Here GlobalStyles injects it through useGlobalStyles, so it lives on a global sheet no per-node dump can see: the node-scoped answer was 3 rules and 995 bytes, which reads as "<Root> barely styles anything". It now reports all 82 rules and prints how many of them render actually subtracts.
  • The browser tier imports the snippet by URL, not by filesystem path. Chromium can only fetch what Vite serves, so an absolute path is requested as http://localhost:PORT/Users/… and 404s — surfacing as "Snippet failed to compile" for a snippet that compiles fine. The input now carries both addresses. Cloud's probe:browser render likely hits this too; I did not test it there, so I would rather flag it than assert it.

Reviewer notes

  • Harness files are named *.probe.tsx so they cannot match vitest's default include. vitest list confirms neither pnpm test nor pnpm test:browser collects them, and dist/ carries no test/probe after a build.
  • The browser project deletes the base project's include before merging. mergeConfig concatenates arrays, so merging over it leaves both globs in place and every browser spec in the repo would run on each probe.
  • assertConfigApplied() fails the run if <Root>'s module body did not take effect. configure() no-ops once any style has been generated, and that failure has no other symptom — units, recipes and presets silently go unresolved while the output still looks authoritative. The check is end-to-end (1x must resolve to var(--gap)) rather than a config-key lookup, so it asserts the thing the probe promises.
  • --computed / --rect / --screenshot are rejected on the jsdom tier rather than silently ignored: asking for computed values and getting none reads as "no styles applied", the opposite of the truth. probe:browser likewise refuses every mode but render, instead of passing an undefined snippet path into a dynamic import.
  • --hc is new relative to Cloud. This is where palette work happens, and the four-variant sweep the Glaze docs require needs the high-contrast variant resolved flat, not only as a state map. Verified against the state map: tokens --scheme dark --hc gives #surface-text: oklch(1 0 0), matching its @dark & @hc entry.
  • The harness reads input and writes results through a file. Stdout is not a usable channel: React warnings can quote the probed markup, so no delimiter is safe against arbitrary snippet content.

Verified by hand

Both tiers end to end: all four modes; render on <Button type="primary"> (57 rules, baseline subtracted) and on a DialogContainer (2 portals, 169 rules, 0 inline chars); --computed / --rect / --screenshot in Chromium including a dark-scheme PNG; and every rejection path — --computed on jsdom, non-render modes on browser, unknown mode, no trailing JSX, and a syntax error (reports Vite's code frame pointing at the snippet, not at the wrapper).

Checklist
  • Pipeline is passed
  • Tests are passed successfully — the jsdom suite has 0 failures outside .claude/worktrees/. Note for whoever runs this locally: two stale git worktrees are checked out inside the repo, and vitest.config.ts sets test.exclude explicitly (replacing vitest's defaults) without listing them, so they get collected with their own node_modules and fail ~390 tests. That is pre-existing and unrelated to this PR, but it does trip the pre-push hook — worth its own fix.
  • Changeset(s) is(are) added — n/a on purpose. Internal tooling and docs only: src/probe/ is untouched and the published output is byte-identical, which AGENTS.md says to skip a changeset for.
  • You have passed the threshold of the library size — nothing new is published.
  • Commit message follows commit guidelines

Closes: N/A

Other information

The DOM helpers this depends on (captureCss, diffRules, canonicalize) already ship as @cube-dev/ui-kit/probe and are unchanged here — Cloud imports them from there rather than keeping a copy, so the two repos cannot drift on exactly the thing a probe exists to answer.

Docs: docs/rules/probe.md, reachable from AGENTS.md and from docs/rules/tests.md, which now carries the rule that a look is not a test.


Note

Low Risk
Internal dev tooling and documentation only; published src/probe/ and package build output are unchanged per the PR description.

Overview
Adds pnpm probe and pnpm probe:browser as a one-shot alternative to throwaway Vitest specs for styling questions, aligned with Cube Cloud’s yarn probe and loading real <Root> config (tasty configure(), Glaze palette).

The CLI (scripts/probe.mjs) drives dedicated Vitest harnesses (*.probe.tsx, excluded from pnpm test) with four modes: styles, tokens, render (stdin JSX, baseline-subtracted CSS, separate PORTALS markup), and globals (full getCSSText() here vs Cloud’s per-node dump). Runs use per-invocation .probe/<runId>/ scratch dirs; jsdom is default; Chromium adds --computed, --rect, --screenshot, and scheme flags on render only. assertConfigApplied() runs before any answer; invalid tier/mode/flag combos error instead of no-oping.

Docs land in docs/rules/probe.md with AGENTS.md / tests.md updates (probe before writing a spec; don’t verify styles via bare @tenphi/tasty). Existing @cube-dev/ui-kit/probe helpers are reused, not modified.

Reviewed by Cursor Bugbot for commit f1b1ff3. Bugbot is set up for automated code reviews on this repo. Configure here.

Answering "what CSS does this actually produce?" required hand-writing a
throwaway vitest spec, running it, reading the output and deleting the file.
Cube Cloud grew a `yarn probe` for exactly this (cube-2 #14024) and it already
imports its DOM helpers from this repo's `@cube-dev/ui-kit/probe` entry, so the
tool belongs here too — and this is where the config, the palette and the
components it inspects actually live. Orphaned `__screenshots__` dirs from
deleted `describe('probe')` specs are the local evidence of the old habit.

Same modes and flags as Cloud's, so a recipe carries between the two repos:
styles, tokens, render, globals. `render` reports the markup plus only the CSS
that snippet caused, by rendering `<Root>` empty, capturing, mounting and
subtracting. Measuring a wrapper div directly would have been simpler and
wrong: `<Root>` is the portal target, so a Dialog renders as its sibling —
measured that way its inline markup is 0 chars while the subtraction still
captures all 169 of its rules, reported separately under PORTALS.

`pnpm probe:browser` adds the four things jsdom cannot do — computed values,
geometry, pointer input, screenshots. Same Card, both tiers:
`var(--surface-2-color)` / `calc(3 * var(--gap))` under jsdom,
`rgb(248, 248, 249)` / `24px` in Chromium. It shares its Chromium and setup
with `pnpm test:browser`, which unlike Cloud's equivalent is wired into CI.

`@cube-dev/ui-kit` is aliased to `src`, so a snippet reads like consumer code
and the same snippet runs unchanged through Cloud's probe.

Two deliberate divergences from Cloud's implementation:

- `globals` reads `getCSSText()`, not the per-node baseline. Console-ui hands
  its palette to `<Root>` through a tasty `tokens` prop, so the token block is
  attributed to that node and a per-node dump carries all ~119KB of it. Here
  `GlobalStyles` injects it through `useGlobalStyles` instead, so it lives on a
  global sheet no per-node dump can see: the node-scoped answer was 3 rules and
  995 bytes, which reads as "`<Root>` barely styles anything". It now reports
  all 82 rules and prints how many of them `render` actually subtracts.
- The browser tier imports the snippet by URL, not by filesystem path.
  Chromium can only fetch what Vite serves, so an absolute path is requested as
  `http://localhost:PORT/Users/…` and 404s — reported as "Snippet failed to
  compile" for a snippet that compiles fine. The input carries both addresses.

Notes:

- Harness files are named `*.probe.tsx` so they cannot match vitest's default
  include; `vitest list` confirms neither `pnpm test` nor `pnpm test:browser`
  collects them, and `dist/` carries no `test/probe` after a build.
- The browser project DELETES the base project's `include` before merging.
  `mergeConfig` concatenates arrays, so merging over it leaves both globs in
  place and every browser spec in the repo would run on each probe.
- `assertConfigApplied()` fails the run if `<Root>`'s module body did not take
  effect. `configure()` no-ops once any style has been generated, and that
  failure has no other symptom — units, recipes and presets silently go
  unresolved while the output still looks authoritative. The check is
  end-to-end (`1x` must resolve to `var(--gap)`) rather than a config-key
  lookup, so it asserts the thing the probe promises.
- `--computed` / `--rect` / `--screenshot` are rejected on the jsdom tier
  rather than silently ignored: asking for computed values and getting none
  reads as "no styles applied", the opposite of the truth. `probe:browser`
  likewise refuses every mode but `render`, instead of passing an undefined
  snippet path into a dynamic import.
- `--hc` is new relative to Cloud. This is where palette work happens, and the
  four-variant sweep the Glaze docs require needs the high-contrast variant
  resolved flat, not only as a state map.
- The harness reads its input and writes its result through a file. Stdout is
  not a usable channel: React warnings can quote the probed markup, so no
  delimiter is safe against arbitrary snippet content.
- No changeset. Internal tooling and docs only — `src/probe/` is untouched and
  the published output is byte-identical.
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 17, 2026 1:27pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f1b1ff3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-fabe7b5.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 487.43 KB (0% 🟰) Yes 🎉
Tree shaking (just a Button) 119.6 KB (0% 🟰) Yes 🎉

Compared against main at 8261652run 32034201743, 2026-08-17T13:16:27Z.

Click here if you want to find out what is changed in this build

Comment thread src/test/probe/harness.browser.probe.tsx
Comment thread src/test/probe/harness.browser.probe.tsx
Review round on #1324. Two findings from Cursor Bugbot, plus two more of the
same shape found while checking them — all four are the failure mode this PR
already rejects for `--computed`: a flag accepted, recorded, and then quietly
dropped, so the answer looks like the answer to the question that was asked.

- `--canonical` was applied by the jsdom harness and ignored by the browser one,
  which returned raw tasty hashes and `useId` counters. That defeats exactly the
  comparison the flag exists for, and a browser run is where you would diff one
  scheme or viewport against another.
- The browser harness never ran `assertConfigApplied`. It matters more there,
  not less: an unresolved unit surfaces as a confident `rgb(...)` and a real
  pixel geometry on the tier trusted for real numbers, rather than as visibly
  missing output. Moved to `config-guard.ts` and shared, rather than copied.
- `--scheme hc` could not express dark + high contrast, so the fourth palette
  variant was unreachable in a browser even though `tokens --scheme dark --hc`
  reports it. Contrast is a separate axis from schema, so it is now a separate
  parameter and composes: `--scheme dark --hc` computes rgb(0,0,0) on
  `#surface` and rgb(255,255,255) on `#surface-text`, matching the
  `@dark & @hc` entries in the state map. `--scheme hc` stays accepted as
  Cloud's spelling for light + high contrast.
- An unknown `--scheme` reached `renderColorTokens`, missed the variant lookup
  and threw inside the token renderer — arriving as a vitest stack trace that
  reads like a harness bug rather than a typo in a flag. Validated in the CLI
  now, per tier. `--scheme` / `--hc` are also refused on modes with no scheme
  instead of being dropped: `styles` and `globals` report every scheme at once,
  and their state maps and `@media` blocks already ARE the per-scheme answer.

Verified: `--canonical` now yields `tcls0 tcls1` in Chromium; dark+hc differs
from dark (rgb(0,0,0) vs rgb(31,32,34)); every rejection path prints its own
message and exits 1; and the shared guard was proved to actually fire by
inverting its assertion and watching the browser run fail with it, not merely
by checking the call was added.
@tenphi

tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Review round addressed in 932bed6.

Both Bugbot findings were real and are fixed, and checking them turned up two more instances of the same shape — a flag accepted, recorded into the probe input, then quietly dropped, so the output looks like the answer to the question that was asked. That is precisely the failure this PR argues against for --computed, so having four of them in it was worth cleaning out as one batch:

Finding Fix
--canonical ignored on the browser tier (Bugbot) applied on both tiers via one normalise() over markup, portals and CSS
browser tier skipped assertConfigApplied (Bugbot) extracted to config-guard.ts, shared by both harnesses
--scheme hc could not express dark + high contrast contrast is now its own axis, so --scheme dark --hc reaches the fourth variant; --scheme hc still means light + hc, as in Cloud
unknown --scheme threw inside the token renderer validated in the CLI, per tier; --scheme / --hc also refused on modes that have no scheme

The third one mattered more than it looks: tokens --scheme dark --hc could already report the @dark & @hc variant, but no probe:browser invocation could compute it — so the one variant most likely to need a real browser was the one the browser tier could not reach.

Verification, rather than assertion:

  • --canonical in Chromium now yields <div class="tcls0 tcls1"> where it previously returned raw hashes.
  • --scheme dark --hc computes rgb(0, 0, 0) on #surface and rgb(255, 255, 255) on #surface-text, matching the @dark & @hc entries the jsdom tokens mode reports; plain --scheme dark gives rgb(31, 32, 34).
  • The shared guard was proved to fire, not merely to be wired: inverting its assertion makes pnpm probe:browser fail with the guard message surfaced through the CLI, and reverting restores a green run.
  • Every rejection path prints its own message and exits 1.

Docs updated alongside (docs/rules/probe.md, --help), including a "nothing is silently ignored" paragraph that now has to stay true.

Still no changeset, for the same reason as before: src/probe/ is untouched and the published output is byte-identical.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b7b1828. Configure here.

Comment thread src/test/probe/harness.probe.tsx Outdated
Comment thread scripts/probe.mjs Outdated
Comment thread scripts/probe.mjs
… runs

Second review round on #1324. Three more Bugbot findings, all real.

**Hooks in a snippet threw (High).** Both harnesses called `module.default()`
and mounted what came back, which runs the body outside React's render phase —
so any hook threw "invalid hook call", including in the
`export default function Snippet() { … }` form the CLI's own --help documents.
`useState` in a snippet is not exotic: it is what probing a controlled input or
a disclosure takes. React now renders `<Snippet />`.

That also fixes a mislabel the same code caused. A throw from the snippet body
was caught by the import's try/catch and reported as "Snippet failed to
compile", sending you to look for a syntax error in code that had parsed
perfectly well. The render is now its own try/catch reporting `kind: 'render'`
— a branch the CLI's printer already had and could never reach.

**Concurrent probes clobbered each other (Medium).** Only the result file was
per-run; `input.json` and `snippet.tsx` were fixed paths and every run began by
`rmSync`-ing the whole `.probe` tree. Two probes at once therefore deleted each
other's input mid-flight or answered with the wrong snippet — and the docs
explicitly invite probing freely, so this was reachable by following them.
Everything is now under `.probe/<runId>/`. Tidiness comes from pruning
directories older than an hour instead: a run takes seconds, so age cannot
misfire on a live sibling, whereas the blanket delete was guaranteed to.

**A flag missing its value read as "flag absent" (Medium).** `--computed` with
no selector stored `undefined`, which every check downstream treats as not
passed, so the run succeeded and looked like an answer to a computed-values
question nobody had answered. `--computed --scheme dark` was worse: it took
`--scheme` as the selector and silently dropped the scheme too. Values are now
required, and a value that is itself a flag is refused by name.

Verified: a `useState` snippet reports `count 3` on jsdom and `browser hook 7`
plus real computed values in Chromium; a throwing snippet now says "failed to
render"; bare JSX still works; two concurrent probes each got their own answer
with zero cross-talk (previously impossible); a backdated run dir is pruned
while live ones survive; and every new rejection prints its own message.
@tenphi

tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Second review round addressed in f1b1ff3. All three findings were real; the High one was the best catch so far.

Finding Fix
Snippet default export broke hooks (High) React renders <Snippet /> instead of the harness calling it
Concurrent probes shared scratch paths everything under .probe/<runId>/; age-based pruning replaces the blanket rmSync
A flag missing its value read as "flag absent" values required, and a value that is itself a flag refused by name

Two things worth pulling out, because they made the findings bigger than they looked:

The hooks bug broke a form the tool documents. --help tells you to write export default function Snippet() { … }, and any useState in that form threw "invalid hook call" — so the advertised shape was one the harness could not run. It also caused a mislabel: a throw from the snippet body was caught by the import's try/catch and reported as "failed to compile", pointing at a syntax error in code that had parsed fine. Render now has its own catch reporting kind: 'render' — a branch the printer already had and could never reach.

The concurrency bug was reachable by following the docs, which is what moved it off my "unlikely in practice" pile: this doc tells you to probe as freely as you like, so parallel runs are the expected usage, not an edge case.

Verified rather than reasoned about:

  • useState snippet: count 3 under jsdom, browser hook 7 plus real computed values in Chromium.
  • A throwing snippet now says "Snippet failed to render", not "failed to compile".
  • Two pnpm probe render runs raced with different snippets: each got its own answer, zero cross-talk, two separate run dirs. This was not possible before.
  • A backdated run dir is pruned on the next run; live ones survive.
  • Bare JSX form, and every new rejection path, still behave.

Full CI was green on b7b18289 before this push, including Tests & lint and Browser tests.

@tenphi
tenphi merged commit 6e2bd43 into main Aug 17, 2026
16 checks passed
@tenphi
tenphi deleted the feat/probe-command branch August 17, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant