Skip to content

feat(core): map design tokens to DESIGN.md fields - #490

Merged
Sun-sunshine06 merged 12 commits into
OpenCoworkAI:mainfrom
wudilyy999:feat/design-system-token-mapping
Oct 20, 2026
Merged

Sun-sunshine06 merged 12 commits into
OpenCoworkAI:mainfrom
wudilyy999:feat/design-system-token-mapping

Conversation

@wudilyy999

@wudilyy999 wudilyy999 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Second step of #484 (design tokens from a folder into DESIGN.md), after #486. It turns the tokens the scan finds into DESIGN.md fields. 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.

  1. --font-* fix (tailwindExtractor.ts, one rule). The v4 classifier matched only font-family, so --font-sans, --font-mono and the like were dropped. --font-weight-* and companions such as --font-sans--font-feature-settings stay out. None of the 45 existing extractor tests changes: the fixtures use --font-family-* and --font-size-*, which the old rule already classified.
  2. Theme block field (DesignToken.block, optional). The CSS variable extractor sets it to the selector of a [data-theme] block and leaves it unset for :root, so :root tokens, @theme tokens, and every existing caller and test are unchanged. A [data-theme="dark"] value no longer looks like the default.
  3. Color conversion (cssColorToHex, matched in linear time: CodeQL flagged the first version's function pattern, fixed in f269488, and its number pattern had the same ambiguity). Hex, rgb(), hsl() and oklch() 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.
  4. Serializer (serializeDesignMd in packages/shared, next to the parser because yaml is a dependency there). It writes the fields in the canonical order, then the body sections in the order formatDesignMdForPrompt already uses; #fff stays a quoted string and long values are not folded.
  5. Mapping (mapTokensToDesignFields in 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 as Typography and Elevation & Depth sections. A length is accepted in any CSS spelling and written in the form validateDesignMd takes (.5rem → 0.5rem, 1REM → 1rem, .5 → 0.5). Names lose the prefix each extractor adds (colors.brand.500, color-brand-500 and color.brand.500 all become brand-500); --spacing and --radius alone become base.

What the result reports

Nothing is logged (core and shared have no console.*, and the mapper has no logger). The result carries origins (the file and token behind every value, keyed like colors.brand-500) and a report:

  • 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: a var() that names no scanned token, names itself, or is nested past the limits (32 levels, 4,096 characters), reason too-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, so hsl(var(--hue) 50% 40%) is unresolved when --hue is 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

  • :root inside @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 under shadowed, so nothing changes silently, but the label is "name already taken", not "dark theme". .dark class blocks are not read at all. Both predate this PR; fixing them means the extractor tracking at-rules, which I would do separately.
  • Non-:root themes 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

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Build / CI / tooling
  • Breaking change

Linked issue

Refs #484

Checklist

  • I checked the linked issue / relevant context before starting
  • pnpm lint && pnpm typecheck && pnpm test passes locally
  • Added/updated tests for the change
  • Added a changeset (pnpm changeset) if user-visible
  • Updated docs if behavior changed

Principles

  • Compatibility: no existing test expectation changes; block is optional and absent on every token that has no theme variant; the only output that moves is that --font-* tokens now appear.
  • Upgradeability: no pi internals; the mapping and the conversion are pure functions over plain values.
  • No bloat: no new dependencies; the color math is one small module; the serializer reuses the parser's yaml and its section order.
  • Elegance: each commit is one concern; the mapper returns a report instead of logging, so the renderer decides what to show.

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): fixed oklch() 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 in color.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 through validateDesignMd), and a Tailwind v4 stylesheet (@theme inline, :root, [data-theme="dark"]) run through the real extractors, mapped, serialized and checked with validateDesignMd.
  • 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.
  • Each rule fails a test when it is removed or loosened: 24 changes to the mapper, 13 to the color module, 6 to the serializer, 3 to the font classifier and 2 to the block field. One change is deliberately not caught: altering the tenth decimal of an OKLab matrix coefficient, which does not change an 8-bit hex. For the name cache, removing it, keeping names nothing defines, not checking the depth on a reuse, and not counting the levels each fail a test. I also compared the mapper with the version before the cache on 600 random variable graphs, 227 of which reach the depth limit: the results are identical.

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
@github-actions github-actions Bot added docs Documentation area:core packages/core (generation orchestration) labels Oct 14, 2026
Comment thread packages/core/src/brand/color.ts Fixed

@github-actions github-actions 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.

Findings

  • [Minor] DIMENSION and UNITLESS reject 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: -.5rem and --radius: 1REM are classified as unsupported / dimension. That outcome is reported rather than silently changed, so it is not a data-loss bug, but .5 is 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:20 already 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 matching it.each cases to designFields.test.ts:

    const DIMENSION = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem)$/i;
    const UNITLESS = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
  • [Nit] serializeDesignMd() in packages/shared/src/design-md.ts casts every key of ALLOWED_TOP_LEVEL to keyof DesignMdFrontmatter, so the two lists can drift silently: a name added to ALLOWED_TOP_LEVEL but not to the interface compiles, reads undefined, and yaml then omits it. If ALLOWED_TOP_LEVEL is 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 / serializeDesignMd yet, block is 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 (yaml was already a packages/shared dependency), no console.*, no provider SDK imports. Returning a report instead of mutating values quietly matches the project rule against silent user-visible fallbacks.
  • Linked issue: Refs #484 is 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 :root under @media (prefers-color-scheme: dark) and .dark class gaps are pre-existing extractor limits and are disclosed in the PR body; the optional block field on DesignTokenV1 keeps schemaVersion: 1 and 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.ts vectors cross-checked against an independent conversion route, designFields.test.ts covering each report path plus an end-to-end Tailwind v4 stylesheet re-validated with validateDesignMd, design-md.test.ts for 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`.

@github-actions github-actions 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.

Findings

  • [Nit] UnresolvedReason is exported from designFields.ts but left out of both barrels, while its sibling UnsupportedReason is re-exported — packages/core/src/brand/index.ts:3-10 and packages/core/src/index.ts:39-46. A caller that wants to switch on report.unresolved[].reason cannot name the union from the public entry point, and the asymmetry looks accidental.
    Suggested fix: add UnresolvedReason next to UnsupportedReason in both export type { ... } blocks.

Residual observations

  • Carried over from the previous review and still open: DIMENSION / UNITLESS at packages/core/src/brand/designFields.ts:59-60 still require a digit before the decimal point and match units case-sensitively, so --radius: .5rem, --space-4: .5 and --radius: 1REM land in report.unsupported as dimension. 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)$/i plus the matching unitless form, with it.each cases in designFields.test.ts.
  • The previous Nit on serializeDesignMd in packages/shared/src/design-md.ts (casting every ALLOWED_TOP_LEVEL key to keyof DesignMdFrontmatter) is likewise unchanged.
  • The new bounds in this push read correctly: resolveVars is depth-capped via chain.length > MAX_VAR_DEPTH and expansion-capped via out.length > MAX_RESOLVED_LENGTH, the greedy FUNCTION_RE has no nested quantifiers, and the reworked NUMBER alternation stays linear on the long-input cases. The chain is copied per branch, so a repeated (non-cyclic) reference is not misreported as a cycle — consistent with the new MAX_VAR_DEPTH + 8 and doubling-shadow tests. No new issue found in the added lines.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The delta since 86ccc5b is 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 (serializeDesignMd key cast) are simply untouched, not worsened.
  • Constraints: no new dependencies (yaml was already a packages/shared dep), no console.*, no provider SDK imports; the new constants are internal and the mapper stays pure, so the report-instead-of-logging approach still matches the no-silent-fallback rule. Refs #484 remains 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 test and pnpm --filter @open-codesign/shared test locally; 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
@wudilyy999

Copy link
Copy Markdown
Contributor Author

Thanks. All three are fixed: 189c1f1 (lengths), 224c3b0 (the serializer's field list), 241fc55 (UnresolvedReason).

One note on the dimension finding. The suggested patterns alone would make the mapper write .5rem, -.5rem and 1REM as they are, and validateDesignMd rejects all three ("Expected a dimension with px, em, or rem unit"): its own validateDimension needs a digit before the point and a lowercase unit. So the mapper now accepts the CSS spellings and writes the form validateDesignMd takes (0.5rem, -0.5rem, 1rem, and the number 0.5 for unitless spacing). The new tests run each spelling through validateDesignMd, and the same length written two ways counts as one value.

The field names are now one as const list that builds the allowed set and drives the serializer, so a name the DesignMdFrontmatter type lacks is a compile error; a test writes a document with every field set. UnresolvedReason is exported from both entry points.

@github-actions github-actions 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.

Findings

  • No new Blocker, Major, Minor, or Nit findings in this push. The delta since f90c579 is exactly the three items the previous review left open, and all three are now addressed:
    • The UnresolvedReason barrel 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 alongside UnsupportedReason.
    • DIMENSION / UNITLESS at packages/core/src/brand/designFields.ts:59-60 now accept a leading . and lowercase the unit, with normalizeDimension canonicalizing before claim dedupes; .5rem, -.5rem, 1REM, .25EM, 8PX and bare .5 all map or number-convert instead of landing in report.unsupported, and the two-spelling case (treats the same length spelled two ways as one value) no longer produces a spurious shadowed entry.
    • The serializeDesignMd key cast is gone: FRONTMATTER_FIELDS is now an as const tuple and input.frontmatter[key] indexes it without a keyof assert (packages/shared/src/design-md.ts:26-38, :434-437), while ALLOWED_TOP_LEVEL keeps the same 8 members, so validation behavior is unchanged.

Residual observations (not filed)

  • resolveVars matches the literal var( token (packages/core/src/brand/designFields.ts:126), so uppercase VAR(--x) will not resolve; it surfaces as unresolved/unsupported rather than a silent wrong value, and the same applies to +-signed lengths which DIMENSION/UNITLESS do not accept. Both are rare enough in real stylesheets that I did not raise them as findings.
  • The text-*--line-height companion 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 UnresolvedReason export Nit, the dimension-regex Minor, the serializeDesignMd key-cast Nit) are resolved in this push, and the new normalizeDimension path is covered by focused it.each cases plus a validateDesignMd round-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 and var() tests keep their expectations.
  • Constraints still hold: no new dependencies (yaml was already a packages/shared dependency and is reused via stringifyYaml), no console.* in packages/core/packages/shared, no direct provider SDK imports, and the mapper stays pure and returns report/origins instead of logging, so the no-silent-fallback rule is respected. block is added as an optional zod field on DesignTokenV1 (packages/shared/src/design-token.ts:19-23) with a default, so no persisted shape breaks. Refs #484 is still the correct partial link, and the changeset covers core + shared at patch.
  • Nothing blocking; the PR is mergeable as step 2 of #484.

Testing

  • Not run (automation). Suggested: pnpm --filter @open-codesign/core test and pnpm --filter @open-codesign/shared test; the new dimension-normalization and frontmatter-completeness tests are proportional to the change.

Open-CoDesign Bot

@Sun-sunshine06

Copy link
Copy Markdown
Collaborator

I checked the latest head (241fc55) and ran the core tests (709), shared tests (334), and both packages' typechecks successfully. I reproduced one additional issue that I would like addressed before merging.

[P2] Bound variable-expansion work independently of the output length

In resolveVars, lines 124–131, the depth and output-length limits do not bound the number of recursive calls. A variable that references the preceding variable twice is expanded twice each time. If the leaf resolves to an empty fallback, the output never reaches MAX_RESOLVED_LENGTH, so the work grows exponentially while staying within MAX_VAR_DEPTH.

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 report.unresolved was empty. The empty fallback is the key difference from the existing test whose output grows past the length limit. The mapper has no UI caller yet, but this exported synchronous operation would block its caller when connected.

Suggested change:

  • Add a shared expansion-step budget covering recursive branches, returning too-complex when exhausted. If the intended bound is per import, do not reset it for each recursive branch or top-level token.
  • Alternatively, memoize successfully resolved variables so repeated references reuse their results, while preserving path-sensitive cycle detection and the existing depth/length limits. A work budget alongside caching also gives an explicit upper bound.
  • Add regression coverage for the empty-fallback fan-out above, checking that it either resolves efficiently or reports too-complex. Retain the existing cycle, depth, and growing-output tests.

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.

@github-actions github-actions 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.

Findings

  • No Blocker, Major, Minor, or Nit findings in this push. The delta since 241fc55 is the Expansion.resolved memo 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.resolved caches only successes keyed by name, and the depth limit is reconstructed arithmetically as chain.length + 1 + known.below > MAX_VAR_DEPTH (packages/core/src/brand/designFields.ts:142-160). That is sound: below is a property of a name's value (cycles are never cached, so a failed expansion cannot poison later lookups), the cache lookup runs after the chain.includes(name) guard, and a cached value contains no var() left to re-enter a cycle. The new cases in packages/core/src/brand/designFields.test.ts assert 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: resolveVars matches the literal var( token (packages/core/src/brand/designFields.ts:120), so an uppercase VAR(--x) is reported rather than resolved; DIMENSION/UNITLESS (:59-60) do not accept a leading +. Both surface as unresolved/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; the below bookkeeping 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 #484 is still the correct partial link for step 2, the changeset covers @open-codesign/core and @open-codesign/shared at patch (.changeset/design-tokens-to-design-md.md), and the project constraints hold: no new dependencies (stringifyYaml reuses the existing yaml dep in packages/shared), no console.* in core/shared, and the mapper stays pure, returning origins/report instead of logging.
  • Nothing blocking; the PR is mergeable as step 2 of #484.

Testing

  • Not run (automation). Suggested: pnpm --filter @open-codesign/core test and pnpm --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

@github-actions github-actions 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.

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)

  • resolveVars still matches the literal var( token (packages/core/src/brand/designFields.ts:120), so an uppercase VAR(--x) is reported as unresolved rather than resolved. Non-blocking: it surfaces in report.unresolved, never a silent drop.
  • DIMENSION/UNITLESS (packages/core/src/brand/designFields.ts:59-60) do not accept a leading +, so spacing: +0.5rem is reported unsupported. Same reporting behavior, no silent change.
  • The fan-out tests in packages/core/src/brand/designFields.test.ts assert a 250 ms wall-clock ceiling via within(...). 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:192 classifies any --font-* (minus weight) as fontFamily, while packages/core/src/brand/cssVarExtractor.ts:12 still only recognizes explicit font-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 optional DesignToken.block field (design-token.ts:20-24, set only for [data-theme] blocks in cssVarExtractor.ts:73), the linear-time color parser (color.ts), the pure mapper returning origins/report instead of logging (designFields.ts), and serializeDesignMd reusing the existing yaml dep in packages/shared all match the stated intent and the tests are proportional to the risk.
  • Constraints hold: no new prod dependencies, no console.* in core/shared, the mapper is pure and reports every value it could not hold (report.unsupported/unresolved/otherThemes/shadowed), and serializeDesignMd emits a document that validateDesignMd accepts. Refs #484 is 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/shared at patch.
  • 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 test and pnpm --filter @open-codesign/shared test. If CI flakiness appears, the within(250, ...) fan-out cases are the first to inspect.

Open-CoDesign Bot

@Sun-sunshine06
Sun-sunshine06 merged commit 761b3b8 into OpenCoworkAI:main Oct 20, 2026
6 checks passed
Sun-sunshine06 added a commit that referenced this pull request Oct 20, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core packages/core (generation orchestration) docs Documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants