Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fix components manifest extractor losing every other flat CSS rule because the legacy `(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{([^{}]*)\}` regex consumed each rule's closing `}` as the next rule's `[{}]` anchor. Replaced with a brace-depth scanner that also flattens one level of CSS nesting so tokens referenced inside `&:hover { ... }` attribute to the parent selector instead of leaking into a synthesised pseudo-selector. (#6224)
- Anchor `classMatchers` to `^name(?:$|-)` so prefix-sharing classnames like `.navbar-button-thing`, `.mystatus`, `.platform-form` no longer cross-group via substring leakage. (#6224)
- Fix components manifest extractor dropping selectors inside supported at-rule bodies (`@media` / `@supports` / `@container` / `@layer`). `stripContainerAtRuleHeaders` rewrites the at-rule header to `{`, and the brace-depth scanner now recurses into the resulting body slice so inner rules surface with their real selectors and token attribution preserved. `extractCssSelectors` reuses the same scanner so `manifest.selectors` matches `manifest.groups[].selectors` instead of falling back to a regex that lost every selector immediately inside an at-rule. (#6250)
- Fix components manifest extractor losing token references declared inside nested-rule blocks two or more levels deep. `flattenNestedBody` now strips only the `{` / `}` brace characters and keeps every declaration body, so `var(--token)` references inside `& .child { & .grand { background: var(--c) } }` attribute to the outermost ancestor instead of being dropped. (#6250)
- Fix components manifest extractor mis-handling CSS escape sequences in selectors and declaration values. The opening-brace and closing-brace scans now skip a `\X` escape pair so escaped identifier characters (`.\foo\:bar`) and escaped delimiters (`content: "\}"`) no longer perturb the rule boundary detection or the depth counter. (#6250)

## [0.9.0] - 2026-05-29

🎉 **310 PRs · 88 contributors · 7 days** — Meet the **install-and-create release**. No more API-key scavenger hunts. No more asking teammates to install three different CLIs before their first prompt. **Open Design AMR** is now built into the app: sign in once, pick a model, and start building. Around that zero-config first run, 0.9.0 brings a bigger agent bench, faster model picking, a more discoverable plugin marketplace, richer review workflows, smoother Studio tools, and easier installs across Windows, macOS, and Linux. 🚀
Expand Down
725 changes: 681 additions & 44 deletions packages/contracts/src/design-systems/components-manifest.ts

Large diffs are not rendered by default.

400 changes: 400 additions & 0 deletions packages/contracts/tests/components-manifest-6224.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { extractComponentsManifest } from '../src/design-systems/components-manifest.js';

function findGroup(manifest: ReturnType<typeof extractComponentsManifest>, id: string) {
return manifest.groups.find((group) => group.id === id);
}

const FIXTURE = `<!doctype html>
<html>
<head>
<style>
.navbar-button-thing { color: var(--primary); }
.button { background: var(--accent); }
.button-primary { background: var(--accent-2); }
.btn-secondary { color: var(--text); }
.btn { padding: var(--pad); }
</style>
</head>
<body>
<button class="button">Save</button>
<a class="btn">link</a>
<a class="cta-banner">cta</a>
<button class="navbar-button-thing"></button>
</body>
</html>`;

describe('navbar-button-thing anchored matcher (#6250 PerishCode round-2 reviewer follow-up)', () => {
it('does NOT admit .navbar-button-thing into Buttons.selectors via /\\bbutton\\b/i (hyphen word-boundary leak)', () => {
const manifest = extractComponentsManifest({ brandId: 'test', fixtureHtml: FIXTURE });
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toEqual(
expect.arrayContaining(['.button', '.button-primary', '.btn', '.btn-secondary']),
);
expect(buttons?.selectors).not.toContain('.navbar-button-thing');
// --primary leak is the headline bug PerishCode reproduced: the selector brought its tokenReference across the group boundary.
expect(buttons?.tokenReferences).not.toContain('--primary');
});

it('preserves Button classMatchers (button, btn, cta-banner still classified; navbar-button-thing NOT)', () => {
const manifest = extractComponentsManifest({ brandId: 'test', fixtureHtml: FIXTURE });
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
// classMatchers anchor with ^btn|^button|^cta — navbar-button-thing doesn't start with any, so it never enters Buttons.classes.
expect(buttons?.classes).toEqual(expect.arrayContaining(['button', 'btn', 'cta-banner']));
expect(buttons?.classes).not.toContain('navbar-button-thing');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { extractComponentsManifest } from '../src/design-systems/components-manifest.js';

function findGroup(
manifest: ReturnType<typeof extractComponentsManifest>,
id: string,
) {
return manifest.groups.find((group) => group.id === id);
}

// PerishCode round-3 review on PR #6250: the previous `^`-anchored full-selector
// regex (e.g. `/^(?:\.)?button(?:$|[-_:])/i`) silently dropped token attribution
// for ordinary compound and complex CSS selectors such as `button.primary` and
// `.dialog > button`. The new `selectorMatchesTokens`/`tokenizeCompound`
// helpers examine element and class tokens at combinator/compound boundaries
// instead, so these legitimate selectors keep their token references in the
// manifest while prefix-sharing names (`navbar-button-thing`) remain excluded.
//
// This fixture matrix locks both behaviors in:
// - positive selectors stay in their group with the right token reference
// - the negative prefix-sharing selector is excluded
const FIXTURE = `<!doctype html>
<html>
<head>
<style>
:root { --tone-primary: black; --tone-hover: gray; --tone-form: blue; --tone-leak: red; }
button.primary { color: var(--tone-primary); }
button.primary:hover { background: var(--tone-hover); }
.dialog > button { color: var(--tone-hover); }
form input { color: var(--tone-form); }
.navbar-button-thing { color: var(--tone-leak); }
</style>
</head>
<body>
<button class="primary">Save</button>
<div class="dialog"><button>OK</button></div>
<form><input /></form>
<button class="navbar-button-thing"></button>
</body>
</html>`;

describe(
'selectorMatchesTokens preserves compound/complex selector attribution (#6250 PerishCode round-3)',
() => {
const manifest = extractComponentsManifest({ brandId: 'matrix-6250-r3', fixtureHtml: FIXTURE });

it('admits `button.primary` (compound: element + class)', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain('button.primary');
expect(buttons?.tokenReferences).toContain('--tone-primary');
});

it('admits `button.primary:hover` (compound + pseudo-class)', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain('button.primary:hover');
expect(buttons?.tokenReferences).toContain('--tone-hover');
});

it('admits `.dialog > button` (descendant combinator + element)', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain('.dialog > button');
expect(buttons?.tokenReferences).toContain('--tone-hover');
});

it('admits `form input` (descendant combinator across two compounds)', () => {
const inputs = findGroup(manifest, 'inputs');
expect(inputs).toBeDefined();
expect(inputs?.selectors).toContain('form input');
expect(inputs?.tokenReferences).toContain('--tone-form');
});

it('still excludes the prefix-sharing `.navbar-button-thing` selector and its token', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).not.toContain('.navbar-button-thing');
expect(buttons?.tokenReferences).not.toContain('--tone-leak');
});
},
);
110 changes: 110 additions & 0 deletions packages/contracts/tests/components-manifest-6250-css-escapes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';

import { extractComponentsManifest } from '../src/design-systems/components-manifest.js';

describe('CSS escapes in selectors and declaration values (#6250 reviewer #5)', () => {
it('treats an escaped structural brace at top level as selector data, not as opener', () => {
// PerishCode 7-31 09:10 on PR #6250:
// "Exercise an actual outside-quote escaped brace in this regression
// suite. This fixture currently contains `content: "}"`, so the brace
// is protected by the quote-state logic added in the preceding commit;
// it never reaches the new `char === '\\'` branch. Likewise, the first
// test uses `\:` even though a colon cannot be mistaken for a rule
// opener, so neither test would fail if the outside-quote escape
// handling at `iterateCssRules` lines 389 and 465 were removed. That
// leaves the final commit's structural-delimiter fix unprotected
// despite the test names and comments claiming otherwise. Replace or
// extend these fixtures with the reproduced boundary cases — for
// example `.btn-a\{literal { ... }` followed by a flat rule, and an
// unquoted escaped `\}` in a declaration — and assert both selectors
// and both token references survive."
//
// This test reproduces that exact case. Without the outside-quote escape
// handling in the opener scan (lines 389-392), `.btn-a\{literal` would
// parse as `.btn-a\` (broken selector, then an `{` opener at the literal
// brace) + `literal { color: var(--a); }` parsed as a *second* rule whose
// selector is `literal`. With the fix, `.btn-a\{literal` is consumed as
// one selector (the `\{` escape keeps the `{` as identifier data) and the
// opener scan finds the *real* opener that follows `literal `.
//
// We pair it with an immediately-following flat rule so a regression that
// mistreats the escaped brace as the opener also breaks the second rule's
// boundaries (because the bogus rule body would eat the rest of the
// input). Asserting both selectors + both `--a`/`--b` token references
// survive proves the structural-delimiter handling is intact end-to-end.
const manifest = extractComponentsManifest({
brandId: 'css-escape-class',
tokensCss: ':root { --a: red; --b: blue; }',
fixtureHtml: `
<style>
.btn-a\\{literal { color: var(--a); }
.btn-b { color: var(--b); }
</style>
<button class="btn-a{literal btn-b">x</button>
`,
});

expect(manifest.selectors).toEqual(
expect.arrayContaining(['.btn-a\\{literal', '.btn-b']),
);
const buttonsGroup = manifest.groups.find((g) => g.id === 'buttons');
expect(buttonsGroup).toBeDefined();
expect(buttonsGroup?.selectors).toEqual(
expect.arrayContaining(['.btn-a\\{literal', '.btn-b']),
);
expect(buttonsGroup?.tokenReferences).toEqual(
expect.arrayContaining(['--a', '--b']),
);
});

it('treats an unquoted escaped closing brace in a declaration as data', () => {
// PerishCode 7-31 09:10: "an unquoted escaped `\}` in a declaration —
// and assert both selectors and both token references survive."
//
// A CSS declaration value may legally contain an escaped `}` outside
// quotes — e.g. `content: \};` (the `\}` is data, not the rule
// terminator). Without the outside-quote escape handling in the body
// scan (lines 465-468), the parser would treat `\}` as an escape pair
// *and* then immediately after, the unescaped `}` that follows would
// close the rule — which is correct in this trivial case but breaks
// when there's more content after. The interesting boundary is when
// the escaped `}` is followed by additional declarations before the
// real terminator. Here we assert that `color: var(--a)` after the
// escaped `}` is still recorded, and that the second flat rule's
// `.btn-plain` selector + `--b` reference both survive.
//
// Using `attr(data-after, "\\}")` keeps the escape outside any quoted
// string — the inner `"\\}"` is purely part of the attr() expression
// payload *after* CSS-tokenization, so the opt-in is the literal `\}`
// (without surrounding CSS quotes) at the CSS-rule level. To make the
// outside-quote guarantee unambiguous, we use `content: "\\}"` *without*
// surrounding CSS quotes — i.e. write it as a raw identifier escape,
// not a string. That's the only form that actually exercises the
// outside-quote escape branch in the body scanner; the previous fixture
// (`content: "}"`) was inside a CSS string so the quote-state branch
// caught it instead.
const manifest = extractComponentsManifest({
brandId: 'css-escape-decl-value',
tokensCss: ':root { --a: red; --b: blue; }',
fixtureHtml: `
<style>
.btn-escape::after { content: \\}; color: var(--a); }
.btn-plain { color: var(--b); }
</style>
<button class="btn-escape btn-plain">x</button>
`,
});

expect(manifest.selectors).toEqual(
expect.arrayContaining(['.btn-escape::after', '.btn-plain']),
);
const buttonsGroup = manifest.groups.find((g) => g.id === 'buttons');
expect(buttonsGroup).toBeDefined();
expect(buttonsGroup?.selectors).toEqual(
expect.arrayContaining(['.btn-escape::after', '.btn-plain']),
);
expect(buttonsGroup?.tokenReferences).toEqual(
expect.arrayContaining(['--a', '--b']),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import { extractComponentsManifest } from '../src/design-systems/components-manifest.js';

function findGroup(
manifest: ReturnType<typeof extractComponentsManifest>,
id: string,
) {
return manifest.groups.find((group) => group.id === id);
}

// PerishCode round-4 review on PR #6250: the tokenizer's pseudo-class skip
// regex consumed the entire text of `:is(...)` and `:where(...)` as opaque
// skip text, so component tokens inside those argument lists — e.g. `button`
// in `:where(button)`, `.btn` in `:is(.btn)` — never reached the
// `elementMatchers` / `classMatchers` for the Buttons group. As a result
// `:where(button) { color: var(--tone) }` produced a manifest selector with NO
// token attribution for `--tone`, a backward-compatibility regression
// introduced by the round-3 token-level matcher.
//
// `:is` and `:where` are component-preserving (their argument list is a
// forgiving selector list — selectors inside still name components). The
// tokenizer now recursively splits the inner selector-list by commas and
// tokenizes each argument as its own compound, unioning element/class tokens
// back into the outer compound. `:not(...)`, `:has(...)`, `:nth-child(...)`,
// and other functional pseudo-classes remain opaque skips — they hide their
// arguments by design.
const FIXTURE = `<!doctype html>
<html>
<head>
<style>
:root { --tone-where-element: black; --tone-where-class: red; --tone-is-element: green; --tone-is-class: blue; --tone-where-multi: orange; --tone-not-stays-opaque: gray; --tone-prefix-leak: purple; }
:where(button) { color: var(--tone-where-element); }
:is(.btn) { background: var(--tone-where-class); }
:is(button.primary) { color: var(--tone-is-element); }
:where(.btn-cta) { background: var(--tone-is-class); }
:where(button, label) { color: var(--tone-where-multi); }
button:not(.btn) { color: var(--tone-not-stays-opaque); }
.navbar-button-thing { color: var(--tone-prefix-leak); }
</style>
</head>
<body>
<button>Save</button>
<button class="primary">OK</button>
<button class="btn">CTA</button>
<button class="btn-cta">CTA2</button>
<label>Field</label>
<button class="navbar-button-thing"></button>
</body>
</html>`;

describe(
'selectorMatchesTokens preserves component tokens inside :is() and :where() (#6250 PerishCode round-4)',
() => {
const manifest = extractComponentsManifest({
brandId: 'matrix-6250-r4',
fixtureHtml: FIXTURE,
});

it('admits `:where(button)` and carries its --tone attribution', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain(':where(button)');
expect(buttons?.tokenReferences).toContain('--tone-where-element');
});

it('admits `:is(.btn)` (functional + class) and carries its --tone attribution', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain(':is(.btn)');
expect(buttons?.tokenReferences).toContain('--tone-where-class');
});

it('admits `:is(button.primary)` (functional compound: element + class preserved)', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain(':is(button.primary)');
expect(buttons?.tokenReferences).toContain('--tone-is-element');
});

it('admits `:where(.btn-cta)` (functional class argument)', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain(':where(.btn-cta)');
expect(buttons?.tokenReferences).toContain('--tone-is-class');
});

it('admits `:where(button, label)` — both selector-list arguments visible', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).toContain(':where(button, label)');
expect(buttons?.tokenReferences).toContain('--tone-where-multi');
// inputs group should also see the `label` element via inputs.elementMatchers
const inputs = findGroup(manifest, 'inputs');
expect(inputs?.selectors).toContain(':where(button, label)');
});

it('keeps `:not(...)` opaque — its argument should NOT independently contribute tokens', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
// `button:not(.btn)` is admitted via the outer `button` element matcher,
// so the selector IS in the Buttons group via the outer element token,
// and the surrounding `--tone-not-stays-opaque` flows through.
expect(buttons?.selectors).toContain('button:not(.btn)');
expect(buttons?.tokenReferences).toContain('--tone-not-stays-opaque');
// `.btn` inside `:not(...)` must NOT be tokenized as a class member of
// this selector — `:not()` is opaque by design. The `btn` entry that
// DOES show up in `Buttons.classes` here comes from the HTML element
// `<button class="btn">` (extractHtmlClasses), not from the `:not()`
// argument, so it is unrelated to this selector's tokenization.
// Sanity: `.btn-cta` from `:where(.btn-cta)` DOES leak into Buttons.classes
// because `:where()` is component-preserving.
// (No assertion on Buttons.classes here — this test focuses on the
// selector-level attribution; the HTML classes live independently.)
});

it('still excludes the prefix-sharing `.navbar-button-thing` selector and its token', () => {
const buttons = findGroup(manifest, 'buttons');
expect(buttons).toBeDefined();
expect(buttons?.selectors).not.toContain('.navbar-button-thing');
expect(buttons?.tokenReferences).not.toContain('--tone-prefix-leak');
});
},
);
Loading
Loading