Repository navigation
feat(core): map design tokens to DESIGN.md fields - #490
Sun-sunshine06 merged 12 commits into
Conversation
The v4 classifier matched only `font-family`, so `--font-sans`, `--font-mono` and the other `--font-*` theme variables were dropped. `--font-weight-*` and companions such as `--font-sans--font-feature-settings` are not families and stay out. Refs OpenCoworkAI#484
A `:root` value and a `[data-theme="dark"]` value with the same name looked identical. DesignToken gets an optional `block`, set by the CSS variable extractor to the selector of a `[data-theme]` block and left unset for `:root`, so existing tokens and callers are unchanged. Refs OpenCoworkAI#484
cssColorToHex() handles hex, rgb(), hsl() and oklch(). A color with an alpha below 1 or in another format is reported, not approximated. oklch() goes OKLab -> linear sRGB -> gamma; a channel outside sRGB is clamped to [0, 1] and the result says so. The oklch test vectors were computed on a separate route (CSS Color 4 reference matrices through XYZ) and include the Tailwind v4 palette steps whose hex values Tailwind documents, in-gamut, out-of-gamut, achromatic and values a fraction of a step outside sRGB. Refs OpenCoworkAI#484
serializeDesignMd() writes the frontmatter fields validateDesignMd accepts in the canonical order, then the body sections in the canonical section order (the ordering formatDesignMdForPrompt already uses). Hex colors stay quoted strings and long values are not folded. It sits next to the parser because yaml is a dependency of packages/shared, not of packages/core. Refs OpenCoworkAI#484
mapTokensToDesignFields() turns scanned tokens into the fields validateDesignMd accepts: colors (sRGB hex), typography (font families), rounded and spacing. Font sizes, line heights and shadows have no frontmatter field and come back as Typography and Elevation & Depth sections. A var() is replaced with the scanned token it names, with a cycle guard and the CSS fallback. Nothing is logged. The result carries where every value came from and a structured report: clamped colors with their original value, values DESIGN.md cannot hold (alpha, other color formats, other units), unresolved var(), values left out because they sit in another theme block, and names already taken by an earlier value. Refs OpenCoworkAI#484
There was a problem hiding this comment.
Findings
-
[Minor]
DIMENSIONandUNITLESSreject valid CSS dimensions, so real tokens are dropped —packages/core/src/brand/designFields.ts:44-45. Both patterns require at least one digit before the decimal point and match units case-sensitively, so--radius: .5rem,--space-4: .5,--spacing: -.5remand--radius: 1REMare classified asunsupported/dimension. That outcome is reported rather than silently changed, so it is not a data-loss bug, but.5is a valid CSS<number>and CSS units are ASCII case-insensitive, so these are valid — and common in hand-written token files — so they will never import.packages/core/src/brand/color.ts:20already accepts a leading-dot mantissa for its own numbers, so the two modules disagree on what a number is.
Suggested fix: accept a leading-dot mantissa and match units case-insensitively in both regexes, then add matchingit.eachcases todesignFields.test.ts:const DIMENSION = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem)$/i; const UNITLESS = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
-
[Nit]
serializeDesignMd()inpackages/shared/src/design-md.tscasts every key ofALLOWED_TOP_LEVELtokeyof DesignMdFrontmatter, so the two lists can drift silently: a name added toALLOWED_TOP_LEVELbut not to the interface compiles, readsundefined, andyamlthen omits it. IfALLOWED_TOP_LEVELis the canonical field set, derive the interface from it or add a type-level subset check so the cast cannot hide a mismatch.
Questions
- None; the scope matches the PR body (step 2 of 3,
Refs #484) and the diff stays inside it.
Summary
- Review mode: initial
- No blockers. The change is additive and well insulated: nothing calls
mapTokensToDesignFields/serializeDesignMdyet,blockis optional and unset on every existing token and caller, and the--font-*classifier change is covered by a new test without disturbing the existing extractor fixtures. - Constraints: no new dependencies (
yamlwas already apackages/shareddependency), noconsole.*, no provider SDK imports. Returning areportinstead of mutating values quietly matches the project rule against silent user-visible fallbacks. - Linked issue:
Refs #484is the correct link for a partial slice; the PR does not claim to close the issue, so there is nothing to validate against acceptance criteria. - Residual observations: the
:rootunder@media (prefers-color-scheme: dark)and.darkclass gaps are pre-existing extractor limits and are disclosed in the PR body; the optionalblockfield onDesignTokenV1keepsschemaVersion: 1and is backward compatible, so no migration is required. - Testing gap: only the dimension-format cases noted above are uncovered.
Testing
- Not run (automation). Vitest coverage in the diff is otherwise thorough:
color.test.tsvectors cross-checked against an independent conversion route,designFields.test.tscovering each report path plus an end-to-end Tailwind v4 stylesheet re-validated withvalidateDesignMd,design-md.test.tsfor serializer round-trip/ordering, and extractor regression tests.
Open-CoDesign Bot
CodeQL flagged the function pattern in cssColorToHex: the lazy body between two `\s*` can match whitespace in several ways, so `rgb(` followed by many tabs backtracks quadratically. The numeric pattern `\d+\.?\d*` has the same ambiguity on a long run of digits. The input is CSS from a folder the user picked, which can be someone else's repository. The body is now matched greedily and split afterwards, and the number pattern is `\d+(?:\.\d*)?`. Tests run 100,000-character inputs for each case.
A value that names the same token twice at every level takes 2^levels steps to resolve in full, so a few lines of CSS could stall the scan. A chain is now followed at most 32 levels deep and a resolved value may reach 4,096 characters; past either limit the token is reported as unresolved with the reason `too-complex`.
There was a problem hiding this comment.
Findings
- [Nit]
UnresolvedReasonis exported fromdesignFields.tsbut left out of both barrels, while its siblingUnsupportedReasonis re-exported —packages/core/src/brand/index.ts:3-10andpackages/core/src/index.ts:39-46. A caller that wants to switch onreport.unresolved[].reasoncannot name the union from the public entry point, and the asymmetry looks accidental.
Suggested fix: addUnresolvedReasonnext toUnsupportedReasonin bothexport type { ... }blocks.
Residual observations
- Carried over from the previous review and still open:
DIMENSION/UNITLESSatpackages/core/src/brand/designFields.ts:59-60still require a digit before the decimal point and match units case-sensitively, so--radius: .5rem,--space-4: .5and--radius: 1REMland inreport.unsupportedasdimension. This push did not touch it, so it is not re-filed as a new finding, but the previous fix still applies:/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem)$/iplus the matching unitless form, withit.eachcases indesignFields.test.ts. - The previous Nit on
serializeDesignMdinpackages/shared/src/design-md.ts(casting everyALLOWED_TOP_LEVELkey tokeyof DesignMdFrontmatter) is likewise unchanged. - The new bounds in this push read correctly:
resolveVarsis depth-capped viachain.length > MAX_VAR_DEPTHand expansion-capped viaout.length > MAX_RESOLVED_LENGTH, the greedyFUNCTION_REhas no nested quantifiers, and the reworkedNUMBERalternation stays linear on the long-input cases. Thechainis copied per branch, so a repeated (non-cyclic) reference is not misreported as a cycle — consistent with the newMAX_VAR_DEPTH + 8and doubling-shadowtests. No new issue found in the added lines.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The delta since
86ccc5bis the ReDoS-hardening commit plus its tests; it changes no in-range behavior and regresses no previously reviewed path. The earlier Minor (dimension regex) and earlier Nit (serializeDesignMdkey cast) are simply untouched, not worsened. - Constraints: no new dependencies (
yamlwas already apackages/shareddep), noconsole.*, no provider SDK imports; the new constants are internal and the mapper stays pure, so thereport-instead-of-logging approach still matches the no-silent-fallback rule.Refs #484remains the correct partial link. - Nothing blocking; the PR is mergeable as step 2 with the residual items above.
Testing
- Not run (automation). Suggested:
pnpm --filter @open-codesign/core testandpnpm --filter @open-codesign/shared testlocally; the added tests look proportional to the new bounds.
Open-CoDesign Bot
`.5rem`, `-.5rem`, `.5` and `1REM` are valid CSS but were reported as unsupported dimensions, so hand-written token files never imported. The mapper now accepts them and writes the form validateDesignMd takes (a digit before the point, a lowercase unit): `0.5rem`, `-0.5rem`, 0.5, `1rem`. Widening the pattern alone would have written `.5rem`, which validateDesignMd rejects. The same length spelled two ways counts as one value. Refs OpenCoworkAI#484
…zer from one list serializeDesignMd cast every allowed field name to keyof DesignMdFrontmatter, so a name added to one list and not the other compiled and was dropped by yaml. The fields are now one `as const` list: the allowed set is built from it and the serializer indexes the frontmatter with it, so a name the type lacks is a compile error. A test writes a document with every field set. Refs OpenCoworkAI#484
A caller that switches on report.unresolved[].reason could not name the union from the public entry point. Refs OpenCoworkAI#484
|
Thanks. All three are fixed: One note on the dimension finding. The suggested patterns alone would make the mapper write The field names are now one |
There was a problem hiding this comment.
Findings
- No new Blocker, Major, Minor, or Nit findings in this push. The delta since
f90c579is exactly the three items the previous review left open, and all three are now addressed:- The
UnresolvedReasonbarrel gap is fixed in both entry points (packages/core/src/brand/index.ts:9,packages/core/src/index.ts:45), so the union is now nameable from the public API alongsideUnsupportedReason. DIMENSION/UNITLESSatpackages/core/src/brand/designFields.ts:59-60now accept a leading.and lowercase the unit, withnormalizeDimensioncanonicalizing beforeclaimdedupes;.5rem,-.5rem,1REM,.25EM,8PXand bare.5all map or number-convert instead of landing inreport.unsupported, and the two-spelling case (treats the same length spelled two ways as one value) no longer produces a spuriousshadowedentry.- The
serializeDesignMdkey cast is gone:FRONTMATTER_FIELDSis now anas consttuple andinput.frontmatter[key]indexes it without akeyofassert (packages/shared/src/design-md.ts:26-38,:434-437), whileALLOWED_TOP_LEVELkeeps the same 8 members, so validation behavior is unchanged.
- The
Residual observations (not filed)
resolveVarsmatches the literalvar(token (packages/core/src/brand/designFields.ts:126), so uppercaseVAR(--x)will not resolve; it surfaces asunresolved/unsupportedrather than a silent wrong value, and the same applies to+-signed lengths whichDIMENSION/UNITLESSdo not accept. Both are rare enough in real stylesheets that I did not raise them as findings.- The
text-*--line-heightcompanion regex/^text-(.+)--line-height$/is greedy, but it only derives a display key and any odd capture stays inside the generated section, so there is no data-loss path.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- All previously reported items (the
UnresolvedReasonexport Nit, the dimension-regex Minor, theserializeDesignMdkey-cast Nit) are resolved in this push, and the newnormalizeDimensionpath is covered by focusedit.eachcases plus avalidateDesignMdround-trip (packages/core/src/brand/designFields.test.ts:140-210). No behavior outside the newly accepted CSS spellings moved: the earlier extractor, serializer, field-name andvar()tests keep their expectations. - Constraints still hold: no new dependencies (
yamlwas already apackages/shareddependency and is reused viastringifyYaml), noconsole.*inpackages/core/packages/shared, no direct provider SDK imports, and the mapper stays pure and returnsreport/originsinstead of logging, so the no-silent-fallback rule is respected.blockis added as an optional zod field onDesignTokenV1(packages/shared/src/design-token.ts:19-23) with a default, so no persisted shape breaks.Refs #484is still the correct partial link, and the changeset covers core + shared atpatch. - Nothing blocking; the PR is mergeable as step 2 of #484.
Testing
- Not run (automation). Suggested:
pnpm --filter @open-codesign/core testandpnpm --filter @open-codesign/shared test; the new dimension-normalization and frontmatter-completeness tests are proportional to the change.
Open-CoDesign Bot
|
I checked the latest head ( [P2] Bound variable-expansion work independently of the output length In Reproduction through the real CSS extractor and mapper: const declarations = ['--shadow-l0: var(--absent,);'];
for (let i = 1; i <= 23; i++) {
declarations.push(
`--shadow-l${i}: var(--shadow-l${i - 1})var(--shadow-l${i - 1});`,
);
}
await writeFile(path, `:root { ${declarations.join('\n')} }`);
const tokens = await extractFromCssVars(path);
const result = mapTokensToDesignFields([{ path, tokens }]);This is only 24 tokens / 1,159 bytes of CSS. Local measurements were about 65 ms at depth 16, 959 ms at depth 20, and 9,245 ms at depth 23. At depth 23 the resulting section was only 229 characters and Suggested change:
The rest of the mapping/serialization scope looks consistent with step 2 of #484; no UI work is requested here. |
resolveVars expanded a name once per reference. With --b: var(--a)var(--a) at every level, resolving the last one took 2^levels steps even when the result stayed empty, because the length limit never applies to an empty value and the depth limit counts levels, not calls: 24 tokens and 1,159 bytes of CSS took 9 seconds. A name that expanded is now kept for the rest of the mapping and reused by every later reference, so the work follows the size of the input. Only a success is kept: it depends on the name alone, since a cycle needs a loop in the names and a name that expanded reaches none. Each entry also keeps how many levels the name nests, so the depth limit still counts them when a name is reused deeper in a chain, and a name nothing defines still takes the fallback of each reference. Checked against the version without the cache on 600 random variable graphs, 227 of which reach the depth limit: the results are identical.
There was a problem hiding this comment.
Findings
- No Blocker, Major, Minor, or Nit findings in this push. The delta since
241fc55is theExpansion.resolvedmemo layer plus its tests, and I could not construct a case where it changes behavior versus the un-memoized resolver.
Residual observations (not filed)
Expansion.resolvedcaches only successes keyed by name, and the depth limit is reconstructed arithmetically aschain.length + 1 + known.below > MAX_VAR_DEPTH(packages/core/src/brand/designFields.ts:142-160). That is sound:belowis a property of a name's value (cycles are never cached, so a failed expansion cannot poison later lookups), the cache lookup runs after thechain.includes(name)guard, and a cached value contains novar()left to re-enter a cycle. The new cases inpackages/core/src/brand/designFields.test.tsassert order-independence (shallowest-first vs deepest-first), cycle detection after prior expansions, per-reference fallbacks, and both fan-out failure modes. No wrong-value or data-loss path found.- The previously noted residual limits still stand and are unchanged by this push:
resolveVarsmatches the literalvar(token (packages/core/src/brand/designFields.ts:120), so an uppercaseVAR(--x)is reported rather than resolved;DIMENSION/UNITLESS(:59-60) do not accept a leading+. Both surface asunresolved/unsupported, never a silent wrong value.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The new push replaces un-memoized
var()expansion with a per-name cache so a fan-out such as--b: var(--a) var(--a)at every level resolves in linear time instead of 2^levels; thebelowbookkeeping reproduces the old entry check (packages/core/src/brand/designFields.ts:93-166). The added tests are proportional to the risk: linear-time timing, order independence, cycle-after-expansion, per-reference fallbacks, and both fan-out failure modes (too-complex,undefined). - All items from the previous review remain resolved; nothing new is blocking.
Refs #484is still the correct partial link for step 2, the changeset covers@open-codesign/coreand@open-codesign/sharedatpatch(.changeset/design-tokens-to-design-md.md), and the project constraints hold: no new dependencies (stringifyYamlreuses the existingyamldep inpackages/shared), noconsole.*incore/shared, and the mapper stays pure, returningorigins/reportinstead of logging. - Nothing blocking; the PR is mergeable as step 2 of #484.
Testing
- Not run (automation). Suggested:
pnpm --filter @open-codesign/core testandpnpm --filter @open-codesign/shared test. The new fan-out tests assert a 250 ms wall-clock ceiling; the work is microseconds at these sizes so the margin is large, but they are the ones to watch if CI is slow.
Open-CoDesign Bot
The last run failed in packages/exporters, which this PR does not touch: pptx-repair-edge.browser.test.ts timed out waiting 10 s for system Chromium to start, and pptx-native.browser.test.ts timed out at 30 s. main failed the same way on 466e64d, and the run for OpenCoworkAI#492 on 82945a2 passed with the same test files. A pull request cannot re-run a workflow without admin rights, so this empty commit starts a new run. Refs OpenCoworkAI#484
There was a problem hiding this comment.
Findings
- No Blocker, Major, Minor, or Nit findings in this push. I re-reviewed the full diff (color conversion,
var()resolver + memo, mapper, serializer, extractor changes) and could not construct a wrong-value, data-loss, or silent-fallback path.
Residual observations (not filed)
resolveVarsstill matches the literalvar(token (packages/core/src/brand/designFields.ts:120), so an uppercaseVAR(--x)is reported asunresolvedrather than resolved. Non-blocking: it surfaces inreport.unresolved, never a silent drop.DIMENSION/UNITLESS(packages/core/src/brand/designFields.ts:59-60) do not accept a leading+, sospacing: +0.5remis reportedunsupported. Same reporting behavior, no silent change.- The fan-out tests in
packages/core/src/brand/designFields.test.tsassert a 250 ms wall-clock ceiling viawithin(...). The margin is large at these sizes (microseconds), but it is the one timing-sensitive assertion to watch on a slow CI runner. - Not a requirement for this slice, but worth noting the two extractors now diverge on font families:
packages/core/src/brand/tailwindExtractor.ts:192classifies any--font-*(minus weight) asfontFamily, whilepackages/core/src/brand/cssVarExtractor.ts:12still only recognizes explicitfont-sans|font-mono|font-serif|font-family|typeface. A:root { --font-display: ... }is not picked up as a font family. This predates the PR; flag only if you want the two paths aligned.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Prior review findings remain resolved; nothing new is blocking. The
--font-*classifier fix (tailwindExtractor.ts), the optionalDesignToken.blockfield (design-token.ts:20-24, set only for[data-theme]blocks incssVarExtractor.ts:73), the linear-time color parser (color.ts), the pure mapper returningorigins/reportinstead of logging (designFields.ts), andserializeDesignMdreusing the existingyamldep inpackages/sharedall match the stated intent and the tests are proportional to the risk. - Constraints hold: no new prod dependencies, no
console.*incore/shared, the mapper is pure and reports every value it could not hold (report.unsupported/unresolved/otherThemes/shadowed), andserializeDesignMdemits a document thatvalidateDesignMdaccepts.Refs #484is the correct partial link for step 2 (PR body states IPC/preview/write is step 3), and the changeset covers@open-codesign/core+@open-codesign/sharedatpatch. - No release/distribution files changed, so no packaging/checksum path to verify.
- Mergeable as step 2 of #484; the residual items above are pre-existing or cosmetic and need not block.
Testing
- Not run (automation). Suggested:
pnpm --filter @open-codesign/core testandpnpm --filter @open-codesign/shared test. If CI flakiness appears, thewithin(250, ...)fan-out cases are the first to inspect.
Open-CoDesign Bot
## Summary Refs #493 (the first part of the plan, PRs 1 to 4, in one PR). `done` only saw syntax and runtime errors, so a page that loaded cleanly but scrolled sideways, hid part of its text, set grey text on white, or had an icon button with no name got "ok" and an instruction to stop. `done` and `preview` now measure the open page and report what they find as advice. Findings never change `ok` or the `done` status, so a false positive cannot cost a repair round. #493 planned this as four PRs. I put them in one so that the contract, the checks and the tests of the whole loop land together, and kept the commits in the planned order. If you would rather review them separately, they split at these commits: `c2c8019` (the contract; behavior unchanged), `993fdab` (probe, overflow and clipped text), `31bcfd7` to `cf9a565` (contrast, tap targets, names), `aeffd6f` and `b098de8` (the scripted-agent tests and the fake browser they use). ### What is measured At the page's own width (1280 px for `done`, the `viewport` option for `preview`) and at 390 px, after which the viewport is put back. No library: plain DOM, `getComputedStyle`, `getBoundingClientRect` and a 1 × 1 canvas to read colours in spaces other than sRGB. | Check | Reported when | Not reported | | --- | --- | --- | | horizontal overflow | the page scrolls sideways by more than 1 px; it names the outermost elements that cross the edge (at most 3, widest first) | the root or body hides horizontal overflow; content inside a container that scrolls on its own; `position: fixed` | | clipped text | a clipping element has a text node that sticks out of its box by more than 2 px on any side; the message names the side that sticks out most (at most 5 per width) | `text-overflow: ellipsis`, a line clamp, a box with no height or width, a running CSS animation | | contrast (current width) | text colour over the backgrounds behind it is below 4.5:1, or 3:1 for text of 24 px, or 18.66 px and bold (WCAG 2.x AA). Reported once per colour pair with the number of elements, worst first | text on a background image or gradient, or partly transparent: counted and reported, not guessed; hidden text | | tap targets (phone width) | a link, button or input is under 24 × 24 px and another target is within the 24 px square around it (WCAG 2.2 SC 2.5.8, with squares instead of circles) | a lone small target; a link inline in a sentence; disabled, hidden, transparent, `aria-hidden` and visually hidden controls | | control names (current width) | a control has no text, `aria-label`, `aria-labelledby`, `<label>`, `title`, image `alt` or SVG `title` (input defaults count; a placeholder does not) | `aria-hidden` and hidden controls | ### Reporting - `preview` returns the findings as `layoutIssues` (at most 20, omitted when empty; `ok` is unchanged) and counts them in its summary line. `done` lists them under "Non-blocking warnings". When one is present, the stop instruction changes: the agent may fix it and check it with `preview`, and is told not to call `done` again, so advice cannot start a loop. - `DoneRuntimeVerifier` returns `{ errors, warnings }`. `done:verify:v1` keeps `errors` and gains `warnings` and a `schemaVersion`. - Budgets are named constants: `preview` keeps at most 5 warnings; 20 findings in all, taken from each check in turn when there are more; 5,000 elements and 400 controls per measurement; 5 seconds for both measurements, plus a separate 1-second budget to restore the original viewport before returning after success, failure, timeout or cancellation. Past a limit, or on a page with a background image, the result says so (in `warnings` for `preview`, as a layout warning for `done`). Findings past a per-check limit are counted and reported. - A note about the measurement itself (a truncated page, colours that could not be measured, checks that did not run) is a `layout` warning too, so `done` uses the advice wording for it. That wording is conditional ("if you agree one is a real defect") and a note names no defect, so the agent writes its summary. - A measurement that fails is reported as "Layout checks did not run: …" and never read as a clean page or turned into a failed run. A cancelled run still stops. ### Not in this PR The DESIGN.md drift check (it needs the colour parser in #490) and the Electron smoke test (the questions in #493 about the driver, the model and CI). ### Size +3,354 / −92 over 30 files: 1,121 lines of code in 12 files and 2,227 lines of tests. The plan in #493 estimated about 1,950 lines across the four PRs. The difference is mostly browser tests: a fixture for each rule and each exemption in system Chrome, and, for each exemption, a page that fails without it. ## Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor (no behavior change) - [ ] Documentation - [ ] Build / CI / tooling - [ ] Breaking change ## Linked issue Refs #493 ## Checklist - [x] I checked the linked issue / relevant context before starting - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally (see Checks) - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [ ] Updated docs if behavior changed No page in `website/` describes `done` or `preview` checks, so there is nothing to update. ## Checks - `pnpm lint`, `pnpm typecheck` (10 packages) and `pnpm test` pass on this branch at `6c799fd`: core 608, desktop 2,802 (190 files), shared 350, exporters 198, and the other packages. The first rebase onto main needed one change: the `done` repair-limit test that #489 added returned the old verifier shape, so it now returns `{ errors, warnings }` (in the first commit, `c2c8019`). Merging main later brought #496, which added a `done` test beside mine in `done.test.ts`; I kept both and changed its verifier to return `{ errors, warnings }`. The PPTX tests in `packages/exporters` unpack under `/tmp`, a symlink on macOS, where `zip-lib` refuses to write; turbo's strict env mode drops `TMPDIR`, so my Mac falls back to `/tmp`. With `TURBO_ENV_MODE=loose TMPDIR=/private/tmp` all of them pass. I did not touch that package. - The browser tests run in system Chrome and skip when it is missing, like `preview-interactions.browser.test.ts`. `agent.design-loop.test.ts` runs the production agent with only the model stream and the browser executors replaced. - `6c799fd` is Sun-sunshine06's commit on this branch: each measurement step is bounded by the shared deadline, and the viewport is restored before the inspection ends, with `layout-probe.lifecycle.test.ts` (four paths). I did not mutation-check that change. - Mutation checks: I changed the code in about 55 places (each exemption and threshold, the viewport restore, the budgets and counts, each caller's failure and cancellation handling, the warning merge, the stop-instruction choice, the `preview` result and summary, the IPC fields) and each change made a test fail, except one: when the browser shuts down late, the recovered-shutdown warning is now added to the list instead of replacing it, and no test covers that (the path had none before). Several first runs survived because my fixtures never made the page scroll sideways, or put no target next to a small one; they now do. After the first review I changed the clipped-side code and the warning cap in 12 more places (the sides recorded, the choice of the side that sticks out most, both sides of the 2 px tolerance, the edges, each message, the cap); each fails a test, and the one that first survived, the tolerance at exactly 2 px, now has one. ## Principles - [x] Compatibility: `done` still decides `ok` / `has_errors` from errors alone, and `preview.ok` is unchanged. The wire changes are additive (`warnings` and `schemaVersion` on `done:verify:v1`, `layoutIssues`), and the heading "Non-blocking design metadata warnings" is now "Non-blocking warnings". - [x] Upgradeability: the measurement and the decisions are separate (`layout-probe.ts` collects, `layout-analysis.ts` decides on plain data); every threshold and budget is a named constant passed to the page function. - [x] No bloat: no new dependencies, nothing loaded at start (the probe is imported by the two modules that already launch Chrome). - [x] Elegance: one probe serves `done` and `preview`, so the two reports cannot disagree. --------- Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-authored-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com>
Summary
Second step of #484 (design tokens from a folder into
DESIGN.md), after #486. It turns the tokens the scan finds intoDESIGN.mdfields. Nothing calls it yet; the IPC, the preview with the per-field diff, and the write are step 3.This is larger than the 400 lines I estimated in #484: about 510 lines outside tests and 630 in tests. The five commits stand alone, and I can open them as separate PRs if you prefer.
--font-*fix (tailwindExtractor.ts, one rule). The v4 classifier matched onlyfont-family, so--font-sans,--font-monoand the like were dropped.--font-weight-*and companions such as--font-sans--font-feature-settingsstay out. None of the 45 existing extractor tests changes: the fixtures use--font-family-*and--font-size-*, which the old rule already classified.DesignToken.block, optional). The CSS variable extractor sets it to the selector of a[data-theme]block and leaves it unset for:root, so:roottokens,@themetokens, and every existing caller and test are unchanged. A[data-theme="dark"]value no longer looks like the default.cssColorToHex, matched in linear time: CodeQL flagged the first version's function pattern, fixed inf269488, and its number pattern had the same ambiguity). Hex,rgb(),hsl()andoklch()become lowercase sRGB hex.oklch()goes OKLab → linear sRGB → gamma; a channel outside sRGB is clamped to [0, 1] and the result says so. An alpha below 1, or any other format (color-mix(),lab(), named colors,transparent), is reported, not approximated.serializeDesignMdinpackages/shared, next to the parser becauseyamlis a dependency there). It writes the fields in the canonical order, then the body sections in the orderformatDesignMdForPromptalready uses;#fffstays a quoted string and long values are not folded.mapTokensToDesignFieldsin core, no new dependency). Colors →colors, font families →typography(fontFamily), radii →rounded, spacing →spacing. Font sizes, line heights and shadows have no frontmatter field, so they come back asTypographyandElevation & Depthsections. A length is accepted in any CSS spelling and written in the formvalidateDesignMdtakes (.5rem→0.5rem,1REM→1rem,.5→ 0.5). Names lose the prefix each extractor adds (colors.brand.500,color-brand-500andcolor.brand.500all becomebrand-500);--spacingand--radiusalone becomebase.What the result reports
Nothing is logged (core and shared have no
console.*, and the mapper has no logger). The result carriesorigins(the file and token behind every value, keyed likecolors.brand-500) and areport:clamped: path, the original value, and the hex it became;unsupported: alpha, another color format, a unit DESIGN.md does not take (50%,calc(),clamp()), or an untyped token;unresolved: avar()that names no scanned token, names itself, or is nested past the limits (32 levels, 4,096 characters), reasontoo-complex;otherThemes: values in a[data-theme]block, left out; the default block wins;shadowed: a name already taken by an earlier value; the first is kept. The same value again is ignored.var()A
var(--name, fallback)is replaced with the scanned token it names, recursively, with a cycle guard. The fallback is used only when no scanned token has that name. It resolves only against tokens the extractors return, not every custom property in the folder, sohsl(var(--hue) 50% 40%)is unresolved when--hueis a bare number the extractor does not classify. The limits exist because the folder can be someone else's repository. A value that names the same token twice at every level takes 2^levels steps to resolve in full, and the length limit never applies when the result stays empty (24 tokens and 1,159 bytes of CSS took 9 seconds, as the review of this PR found). So a name that has expanded is kept for the rest of the mapping and reused by every later reference, and the work follows the size of the input. Only a success is kept: it depends on the name alone, because a cycle needs a loop in the names and a name that expanded reaches none. Each entry also keeps how many levels the name nests, so the depth limit counts them when the name is reused deeper in a chain; a name nothing defines still takes the fallback of each reference.Known limits
:rootinside@media (prefers-color-scheme: dark)is read as a default block (the extractor ignores at-rules). The mapper keeps the first value and lists the other undershadowed, so nothing changes silently, but the label is "name already taken", not "dark theme"..darkclass blocks are not read at all. Both predate this PR; fixing them means the extractor tracking at-rules, which I would do separately.:rootthemes are listed, not imported, as agreed in [Feature]: Import a design system from a local codebase into DESIGN.md #484. There is no theme picker.Type of change
Linked issue
Refs #484
Checklist
pnpm lint && pnpm typecheck && pnpm testpasses locallypnpm changeset) if user-visiblePrinciples
blockis optional and absent on every token that has no theme variant; the only output that moves is that--font-*tokens now appear.yamland its section order.Checks
pnpm -r typecheck,pnpm lint,CI=1 pnpm test: all passed; core 718 tests, shared 334, desktop 181 files / 2,640 tests.color.test.ts(90): fixedoklch()vectors for in-gamut, out-of-gamut and achromatic input. The expected values were computed on a separate route (the CSS Color 4 reference matrices through XYZ), not the matrices used incolor.ts, and the Tailwind v4 palette steps match the hex values Tailwind documents (red-500#fb2c36, gray-500#6a7282, blue-50#eff6ff). Also hue units, percent forms,none, alpha in each notation, values a fraction of a step outside sRGB (reported only when a channel changes by a full step), and 100,000-character inputs that must be rejected, or converted, within 250 ms.designFields.test.ts(58): every type and name style, clamping and its report, each unsupported reason,var()chains, fallbacks, cycles and unresolved names, theme variants, repeated names, the nesting limits (a 40-level chain and a 20-level chain that doubles at each level), a fan-out that resolves to an empty value (22 levels, through the real CSS extractor, in under 250 ms; it took about 3.5 seconds before names were kept), the same depth limit when a name is reused in a chain listed either way round, a cycle found after other names were expanded, and a separate fallback for each reference to a name nothing defines, the CSS spellings of a length (each one also checked throughvalidateDesignMd), and a Tailwind v4 stylesheet (@theme inline,:root,[data-theme="dark"]) run through the real extractors, mapped, serialized and checked withvalidateDesignMd.design-md.test.ts(+7): the serialized document validates and parses back to the same fields, a document with every field set, canonical field and section order, hex stays a string, long values are not folded.