diff --git a/spikes/gamut-emitter-poc/.gitignore b/spikes/gamut-emitter-poc/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/spikes/gamut-emitter-poc/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/spikes/gamut-emitter-poc/README.md b/spikes/gamut-emitter-poc/README.md new file mode 100644 index 0000000000..8d0bd3dd45 --- /dev/null +++ b/spikes/gamut-emitter-poc/README.md @@ -0,0 +1,529 @@ +# gamut-emitter-poc — can Gamut emit its own CSS without Panda? + +Ticket: [`wayfinder/tickets/011-emitter-byte-diff-oracle.md`](../wayfinder/tickets/011-emitter-byte-diff-oracle.md). +Question: **emitter adoption** only — nothing here touches **Emotion deletion** +(see [`../CONTEXT.md`](../CONTEXT.md)). The runtime injector's fate is unaffected +either way. + +**Commits under test:** gamut `cass-tsdown-poc` @ `8c2ce2b03`, spike branch +`cass-GMT-1715` @ `d969350dd` (read via `git show`, never checked out); base camp +@ `e98ec58` (local-only, no remote). `@codecademy/variance` 0.26.1, +`@pandacss/*` 0.53.7 (read only, from `panda-consumer-poc/node_modules`). + +--- + +## Verdicts — the two tiers do NOT get the same answer + +### Atomics tier — Panda earns nothing. The diff is empty. + +**Tier 1.** A 96-line Gamut-owned generator with **zero `@pandacss/*` imports** +reproduces `gamut-atomics-poc/dist/atomics-base.css` and `dist/atomics.css` +**byte-for-byte** — literally, `Buffer.equals` and matching SHA-256, not +"equivalent after normalisation". + +``` +atomics-base.css 115,867 B sha f3adb343a3ae243d (both) +atomics.css 707,557 B sha 2e7b89b130fdc54f (both) +``` + +`diff` is empty on both. All five breakpoints, 7,650 rules, media-query wrapping +and ordering included. **There were no content differences and no ordering +differences to report** — the section the ticket asked for is empty because the +generator was written to reproduce Panda's ordering rule after that rule was read +out of Panda's source (§"What Panda actually does" below). Had it been written +naively it would have diverged on rule order for 7 of 47 props; that +counterfactual is stated as Tier 2, not sold as a result. + +The generator needs **no `node_modules` at all** (`mv node_modules _off && node +emit.mjs` still passes), because its only inputs are the Gamut prop config and the +Core theme. + +### Recipe tier — materially more machinery, but still small, and Panda's version is *less* faithful + +**Tier 1, against a different oracle.** No Panda recipe artifact exists to diff +against (see *What this cannot answer*), so the oracle here is **the runtime**, +which is what actually decides what a user sees. A Gamut-owned recipe emitter — +**53 lines** on top of the precompute step Gamut needs either way — matches the +runtime deep merge on **85 matrix points** across 5 recipes / 94 emitted rules, +declaration for declaration, per selector context. + +And it is *more* faithful than the Panda route, in two ways that are proven by +running code, not argued: + +1. **`variant({ base })` + `defaultVariant` is mis-precomputed today.** + `panda-styling-poc`'s `src/precompute/generate.ts` reads the base contribution + as `fn({ theme })`, but `createVariant` defaults the selection + (`packages/variance/src/core.ts:265`), so that call returns + **base ∪ defaultVariant**, not base. Gamut's *shipping* `sizeVariants` has + `base: { fontWeight: 'title' }` (`packages/gamut/src/Button/shared/variants.ts:119`) + — the spike's copy of it dropped that key, so the spike never exercised the path. +2. **Panda's single `base` slot is order-lossy.** `createButtonComponent` composes + five style layers (`fontSmoothPixel`, `modeColorProps`, `buttonStyles`, the + variant/state args, `buttonProps`), several of which can carry their own `base`. `defineRecipe` + has exactly one `base`, so every layer's base flattens into it and is emitted + *before* earlier layers' variant classes — inverting who wins. `FOLD_VARIANT_BASE=1` + reproduces this and **fails loudly on 4 matrix points** of a deliberately + adversarial recipe (`gmt-fold-hazard`). It is masked on the real Buttons only + because `fontWeight` is the sole property `sizeVariants.base` declares and no + colour variant sets it. Luck, not a guarantee. + +So the recipe verdict is: **the machinery is ~4× the atomics tier and it is where +the real risk lives, but it is 53 lines, not a build system** — and the two known +fidelity hazards are both *created* by fitting Gamut's multi-layer composition into +`defineRecipe`'s single-`base` shape, so a Gamut-owned emitter does not inherit +them. + +**Where Panda still does something Gamut would have to build:** nothing in the +recipe tier that Gamut's components actually use. See the feature audit below. + +### One-line answer to the ticket + +Atomics: **replaceable, proven by empty diff.** Recipes: **replaceable, proven +against the runtime, and the replacement is more faithful than the current +Panda-shaped precompute.** The answer is *not* the same for both — the atomics +answer is a byte diff, the recipe answer is a fidelity check with a different +oracle — but both land on the same side. + +--- + +## Reproduce + +```sh +cd "~/code/base camp/reboot/gamut-emitter-poc" +ln -s ../gamut-atomics-poc/node_modules node_modules # already present +./build-recipe-source.sh # bundles real variance (esbuild) + +node emit.mjs && node verify.mjs # atomics — exit 0 +node recipes.mjs && node verify-recipes.mjs # recipes — exit 0 + +# the diff the ticket asked for, directly: +diff dist/atomics-base.css ../gamut-atomics-poc/dist/atomics-base.css # empty +diff dist/atomics.css ../gamut-atomics-poc/dist/atomics.css # empty +cmp dist/atomics.css ../gamut-atomics-poc/dist/atomics.css # silent + +# negative controls — both MUST exit non-zero: +EMITTER_MODE=physical node emit.mjs && node verify.mjs ; echo $? # 1 +FOLD_VARIANT_BASE=1 node recipes.mjs && FOLD_VARIANT_BASE=1 node verify-recipes.mjs ; echo $? # 1 +``` + +`gamut-atomics-poc/` was not modified. Its `node_modules` is symlinked, its +`dist/` is read-only input. + +### The loud checks + +`claim-ledger.md` found only 5–6 of 12 spikes fail loudly and that nothing runs +any of them in CI. Both checks here set `process.exitCode = 1`, and **both were +verified to actually fire** (the two negative controls above), which is the part +usually skipped. + +| assertion | file | oracle | fails loudly | +| --- | --- | --- | --- | +| 1. bytes | `verify.mjs:33` | `gamut-atomics-poc/dist/*.css` | yes — prints first differing byte + line | +| 2. atomic fidelity | `verify.mjs:93` | real `css()` | yes | +| 3. priority table | `verify.mjs:134` | Panda's own `getPropertyPriority` | yes — **caught a real gap, see below** | +| 4. recipe fidelity | `verify-recipes.mjs:133` | runtime deep merge, 85 points | yes | +| 5. `states()` overlap (A22) | `verify-recipes.mjs:173` | runtime declaration order | yes | +| 6. U1 force-emission | `verify-recipes.mjs:198` | the enumerated matrix | yes | + +Assertion 3 is the one that earned its keep. The first draft hand-derived the +longhand table from the 8 CSS shorthands Gamut's props can touch — 57 entries. +Output was still byte-identical, and assertion 2 still passed. Assertion 3 failed +on **21 of Gamut's 126 props** (`gridTemplateColumns`, `justifyContent`, +`flexDirection`, `listStyleType`, …): all currently *open* props, so they emit no +atomics today, but the day any of them gains a `scale` the sheet silently reorders. +That is a **silent failure caught by a loud check** — the risk class +`MAP.md:103` names as the one to prefer against. + +--- + +## What Panda actually does, in the atomics tier + +The ticket's framing — "Panda's residual contribution is iterate → transform → +substitute vars → wrap in media queries → serialize" — is right, with **one item +missing that is the whole difficulty of byte-identity**: + +**Rule order.** Panda sorts atomic rules by +`getPropertyPriority(entry.prop)` — `0` for `all`, `2` if the key is in the +longhand set derived from its 59-shorthand table, else `1` — with a *stable* sort, +so config order survives inside each bucket +(`@pandacss/core/dist/index.mjs:518-551`, `@pandacss/shared/dist/index.mjs:661-780`). + +Two things about that are worth writing down: + +- **It sorts on the utility KEY, not the CSS property.** So Gamut's abbreviated + props (`mx`, `bg`, `mt`) score 1 because they aren't CSS names, while the seven + whose names happen to *be* CSS longhands — `fontFamily`, `fontWeight`, + `fontSize`, `lineHeight` (longhands of `font`), `rowGap`, `columnGap` (of `gap`), + `borderColor` (of `border`) — score 2 and are moved to the **end** of the + utilities layer. `borderColorLeft` *is* a longhand in CSS, but not under that + name, so it scores 1 and stays put. **The emitted order of Gamut's own atomics is + therefore partly an accident of Gamut's naming conventions.** +- **Some such table is unavoidable, and it is CSS's, not Panda's.** A sheet that + emits `border-color` before `border` lets the shorthand win. The table is + vendored in [`longhands.mjs`](longhands.mjs) — **178 entries** — with provenance + and the loud cross-check. That is the concrete size of "the table Gamut would own". + Note it is only needed for *byte-identity with Panda*: correctness needs + shorthand-before-longhand, which is a much weaker requirement a Gamut-owned + emitter could satisfy with its own ordering rule. + +The rest of the byte-identity work was mechanical but not guessable, and is the +honest answer to "what a hand-rolled generator wouldn't anticipate": + +| what | detail | +| --- | --- | +| `@layer` preamble | `@layer reset, base, tokens, recipes, utilities;` then a `base` layer whose only content is `--made-with-panda: '🐼'` (a 4-byte emoji — `String.length` 115,865 vs 115,867 bytes on disk) | +| token layer selector | `:where(:root, :host)`, not `:root` | +| token names kebab-cased, class values NOT | `--line-heights-spaced-title` but `.lineHeight_spacedTitle` | +| Panda appends two token categories | `--breakpoints-*` and `--sizes-breakpoint-*`, same five values | +| breakpoint px → rem in queries | `480px` → `screen and (min-width: 30rem)` at a 16px root | +| PostCSS raws formatting | selector at depth×2 spaces, declarations one deeper, closing `}` at **column 0 regardless of depth**; a blank line before each child of the utilities layer but **not** before rules nested in a media query; **no trailing newline** | +| responsive selector escaping | class `xs:m_0`, selector `.xs\:m_0` | +| ordering across conditions | all unconditioned rules first, then at-rule rules grouped by breakpoint (`sortStyleRules` partitions into declarations / selectors-only / at-rules) | + +Four of these are invisible in a normalised comparison and only show up in a byte +diff. **None of them is a reason to keep Panda** — they are reasons to keep *some* +serializer, and the serializer is 20 lines. + +### The four silent traps the ticket asked about + +`panda.config.mjs` cost the oracle four silent traps and a 720-class hole. A +Gamut-owned generator's exposure: + +| trap | origin | does the Gamut generator inherit it? | +| --- | --- | --- | +| `presets: []` is not enough; only `eject: true` opts out of `preset-base`, so `bg` → `background` | Panda's preset merge | **No — cannot occur.** There is no preset layer to inherit from. | +| `PropertyConfig.property` is types-only; without a `transform` the utility KEY becomes the CSS property (`bg: …`) | Panda's utility API | **No — cannot occur.** The generator reads Gamut's `property`/`properties` directly; there is no second, types-only channel. | +| Gamut's third property shape (`property` as a mode-keyed object) → `[object Object]`, 720 missing classes | Gamut's own config, not Panda | **Yes — inherited in full.** Same three-shape handling, same hazard (`emit.mjs`, `cssPropertiesFor`). Guarded by assertion 2, which fires (the `EMITTER_MODE=physical` control produces 664 mismatches). | +| `['*']` auto-emits negative spacing variants Gamut has no keys for — 1,020 dead rules | Panda's `staticCss` expansion | **No — cannot occur.** Enumeration is `Object.keys(scaleValues(scale))`; there is no `'*'`. | +| logical-vs-physical default (`useLogicalProperties ?? true`) | Gamut's own config | **Yes — inherited.** Same `EMITTER_MODE` switch, same 31%-of-matrix cost for dual mode. | + +So: **two of the four traps are Panda-shaped and disappear with Panda; the two that +survive are Gamut's own config, and would survive any emitter choice.** This is the +opposite of the "keep a maintained tool because it knows the traps" argument — the +maintained tool *supplied* half of them. What Panda genuinely supplies is the +178-entry ordering table, now vendored and asserted. + +--- + +## Sizing the recipe tier — measured, not estimated + +`panda-rationale-sweep.md:147` (U2) holds up: `variant()`/`states()` map onto +`defineRecipe` 1:1. The question the ticket asks is what *replacing* it costs. +Answer, by section of [`recipes.mjs`](recipes.mjs) (non-comment lines): + +| part | lines | is it a cost of dropping Panda? | +| --- | --- | --- | +| A. authoring, reproduced from `Button/shared/{styles,variants}.ts` | 110 | **No** — Gamut's existing source, unchanged. Kept verbatim so `templateVariants`, computed enum selector keys, `transitionConcat()` calls and the `textButton` ternary are all present; none is parsed, all are executed. | +| B. precompute + descriptors → `{ className, base, variants, defaultVariants }` | 131 | **No** — the Panda route needs exactly this (`generate.ts` on `cass-GMT-1715` is the same 100 lines). Shared. | +| C. **emit**: nested-selector flattening, kebab-case, one class per variant key, force-emission | **53** | **Yes — this is the whole replacement for `defineRecipe`'s CSS emission.** | +| of which: the runtime `recipeClasses()` (props → class list, applies `defaultVariants`) | 12 | Yes — replaces Panda's generated `recipe()` function. | + +### `defineRecipe` feature audit — what Gamut would need vs. what it has + +| `defineRecipe` feature | Gamut's use | needed by a Gamut-owned emitter? | +| --- | --- | --- | +| `base` | yes | yes — 1 flatten call | +| `variants` (matrix expansion) | yes, `variant({ prop, variants })` | yes — one class per key, `variants` already IS the matrix; `variance` supplies the keys | +| `defaultVariants` | yes, `variant({ defaultVariant })` | yes, but **runtime-only** (12 lines). Emits no CSS, so a byte-diff could never have caught a bug here — assertion 4's all-props-omitted arm does | +| `compoundVariants` | **no** — `variance` has no such concept | **no.** Would only be needed if the `states()`-overlap collapse in `verify-fidelity.ts`'s failure branch were ever required; it isn't (see A22 below) | +| slot recipes | **no** — no Gamut component uses slots | **no** | +| conditions (`_hover`, `_dark`, breakpoint keys) | **no** — `variance` emits literal `&:hover` selectors | **no.** Panda's condition system is bypassed entirely, exactly as its token layer is | +| token resolution | **no** — `variance` output is post-token (`var(--color-primary)`), hence the `[value]` escape | **no.** Removing Panda also removes the `[value]` escape and its documented leak (`panda-via-gamut-option-a.md:521-525`: `[transparent]` → `var(--colors-transparent)`) | +| `staticCss: { recipes }` force-emission | yes, required by U1 | **not as a feature** — force-emission is the *default* with no extractor. See U1 | +| generated TS types / `cva` runtime / jsx patterns | **no** — `panda-via-gamut-option-b.md:155-218` rejects them as an authoring surface | **no** | + +**Nine of eleven features are unused.** The recipe tier is "materially more +machinery than the atomics tier" (`panda-rationale-sweep.md:271`) — 53 lines vs +20 — and that ratio holds, but the absolute number is small because Gamut's +authoring model bypasses most of `defineRecipe` before it starts. + +### The one genuinely non-obvious piece + +Nested-selector resolution. `variance` emits keys like +`"[disabled], &:disabled, &[aria-disabled='true']"` — a comma list that **mixes** +descendant and self-attaching forms. Under stylis/Emotion semantics the part +without `&` is a *descendant*, so the correct expansion is +`.cls [disabled], .cls:disabled, .cls[aria-disabled='true']`. Splitting per comma +part is mandatory; treating the key as one selector, or prefixing `&` wholesale, +silently changes which elements get styled. That is 8 of the 53 lines +(`resolveSelector`) and it is the place a hand-rolled recipe emitter is most likely +to be quietly wrong. Assertion 4 covers it — the disabled context appears in every +one of the 85 points. + +### A22 re-checked, and its status changes + +`panda-via-gamut-option-a.md:577-582` records the `states()`-overlap pass as "a +property of **Panda's** emission order, not a guarantee in the authoring model", +kept as a regression test. Under a Gamut-owned emitter that sentence stops being +true in the way that matters: the emitter walks `Object.keys` of the states config +itself, so declaration order and stylesheet order coincide **by construction in +code Gamut owns**, not by a vendor's incidental behaviour. Assertion 5 reproduces +the original probe (declaration order `warning→error`, alphabetical order +`error→warning`, so it still discriminates) and passes. This is a **reduction in +risk from dropping Panda**, and it is the risk `panda-rationale-sweep.md:274` +flagged as the recipe tier's real hazard. + +--- + +## U1 — force-emission. Confirmed, and it favours a Gamut-owned generator. + +`styling-engine-rfc.md:612-620` / `rspack-mf-spike.md:26-40`: across a Module +Federation boundary the host must style variants it never renders, so the sheet +must enumerate the matrix rather than follow usage. + +**Both tiers satisfy this by construction, not by configuration.** Assertion 6 +checks all 33 recipe classes exist while nothing in the process renders anything; +the atomics tier enumerates 1,275 × 6 from the theme. There is no extractor to +disable, no `include: [self]` trick, no `staticCss` block to keep in sync with the +prop list — the enumeration *is* the program. Contrast the oracle, which needs +three separate config-level defences (`eject: true`, `include: ['./panda.config.mjs']`, +an explicit `staticCss` matrix) precisely because Panda's default is +usage-driven, and where each of the three failing is silent. + +This is the strongest surviving pro-Panda argument in `panda-rationale-sweep.md` +(S17, un-superseded, T1) — and it turns out to point the other way once the +alternative is an emitter with no extractor at all. + +--- + +## vanilla-extract `sprinkles` — assessed from documented API, NOT run + +**Tier 3.** No install: `@vanilla-extract/*` is not vendored anywhere in this tree +and the constraints forbid network installs. So this is reasoning about a published +API against the same 1,275-rule target, and is graded accordingly. It is *not* +"assessed against the oracle" — nothing was diffed. + +Where it lands against the atomics tier: + +- **`defineProperties({ properties, conditions, defaultCondition, + responsiveArray })` is a direct analogue of what `emit.mjs` does** — a declared + prop × value × condition matrix, exhaustively emitted, usage-independent. R1/R2/R4 + by design, so U1 is satisfied. `styling-engine-rfc.md:96-98` already named + sprinkles beside Panda config as equals; that reads as prescient. +- **Two structural mismatches against Gamut's config**, both Tier 3: + 1. sprinkles' `properties` maps a **prop name → value record**, one CSS property + per key. Gamut has **12 of 47** props (`mx`, `borderRadiusTop`, …) that expand + to **two** CSS properties, and **16 more** where the single property is + mode-keyed — 28 mode-dependent in total, which is the 31%-of-matrix figure + `verify.mjs` reports. + sprinkles has no `transform` hook equivalent — the shorthand mechanism + (`shorthands: { mx: ['marginInlineStart','marginInlineEnd'] }`) covers the + two-property case, but the physical/logical mode switch has to be resolved + before the config is built, i.e. by generating two configs — the same cost + this spike's `EMITTER_MODE` switch carries, not a new one. + 2. **Class names are hashed per file, not derived from the declaration.** Gamut's + entire resolver design (`gamut-atomics-poc/resolve.mjs`, the zero-byte variant) + depends on `${prop}_${value}` being *derivable* client-side without shipping a + 244kB manifest. sprinkles requires shipping its generated `Sprinkles` + runtime/mapping. The sweep's R3 relaxation (`panda-rationale-sweep.md:226`) + dissolves the *MF* objection because Gamut ships sheet and resolver from one + build — but it does not restore derivability, so the zero-byte resolver result + does not carry over. +- **`recipe()`** covers the variant tier and has `variants` / + `defaultVariants` / `compoundVariants`, i.e. the same 1:1 mapping U2 found for + `defineRecipe` — including, note, the **single `base`** shape whose order-loss is + proven above. It would inherit that hazard. +- **Cost side unchanged:** `.css.ts` files, a consumer bundler plugin (moot per R7), + and capacity-constrained maintainers — the sweep's only surviving objection. + +**Recommendation for the option set:** sprinkles belongs in it as a **named +alternative emitter**, which is what the sweep asked. But on the evidence here it +is strictly worse than the Gamut-owned generator for *this* job: it costs a +dependency, loses the derivable-class-name property the resolver is built on, and +brings the single-`base` recipe shape. Its advantage over a Gamut-owned generator +is maintenance you don't do yourself — against 96 + 53 + 178 lines, of which the +178 are a vendored CSS fact. + +--- + +## What this cannot answer + +- **Nothing here validates in a browser.** No paint, no cascade against real DOM. + Assertion 4 *simulates* the cascade assuming single-class specificity and + file-order tie-breaking. That is correct for these selectors, but it is a model, + not a rendering. +- **The recipe tier has no Panda artifact to diff.** `panda-styling-poc`'s + `src/gamut-static.css` is gitignored and `@pandacss/*` is not installed in + `cc/gamut`, so **"byte-identical to Panda's recipes" is untested and unclaimable**. + The recipe verdict rests on agreement with the runtime, which is a different (and + for user-visible behaviour, better) oracle — but it is not the same kind of + evidence as the atomics verdict, and the two should not be quoted with the same + confidence. +- **Five recipes, not 109.** Three real button families plus two probes. `variance` + usage across `packages/gamut` (~109 `styled` sites) is not swept, so "every Gamut + recipe survives this" is Tier 3. The `states()` count that matters (62 sites in + mono) is unswept too — assertion 5 covers the *shape*, not the population. +- **`variantMeta`/`stateMeta` do not exist on shipping `variance`.** Both this + emitter and the Panda route need the variant key list to be readable; the spike + added the metadata on `cass-GMT-1715`, and here the descriptor carries it instead. + Roughly 6 lines in `variance` either way, but it is unwritten work in both routes, + so it discriminates between neither. +- **Container queries and non-Core themes are out.** The atomics run is the five + viewport breakpoints on Core, matching the oracle. Six `c_*` container breakpoints + and four other themes are matrix multipliers nobody has emitted. +- **No consumer build ran.** Same limitation `MAP.md:199` records for the tsdown + work. Nothing here proves a `.css` artifact integrates into TI, front, platform + or mono. +- **Maintenance cost is not measurable from a spike.** 96 + 53 + 178 lines is the + *writing* cost. The `longhands.mjs` table tracks the CSS shorthand spec, and + assertion 3 will only tell you it went stale if Gamut adds a prop that lands in + it. Whether the team wants to own that is a staffing question, not a research one. + +--- + +## Corrections + +Pointers only; nothing above was edited in place. + +### C1 — the oracle spike is no longer runnable, and its README does not say so + +`gamut-atomics-poc/package.json:8` — `"generate": "panda cssgen --outfile +dist/atomics.css && node build-manifest.mjs"`. + +**`@pandacss/*` is not installed in `cc/gamut/node_modules`.** `panda cssgen` +cannot run; there is no `node_modules/.bin/panda`. Separately, +`gamut-atomics-poc/gamut-source.mjs:13-15` imports +`@codecademy/gamut-styles/dist/variance/config.js`, `.../variance/props.js` and +`.../themes/core.js` — all three are now **unreachable**: the package's `exports` +map no longer publishes those subpaths, and `gamut-styles/dist` contains **zero +`.js` files** in this tree (types only, post-tsdown). So `panda.config.mjs` cannot +resolve its inputs either. + +Consequences worth carrying into the rewrite: + +- The atomics result is **still verifiable** — `node verify.mjs` and + `build-manifest.mjs` import the pre-bundled `dist/gamut-source.bundle.mjs`, not + the live packages. Confirmed: it runs clean today (1,275 pairs, 2,561 resolver + cases, exit 0). Also independently re-verified here by assertion 2. +- But it is **no longer regenerable**, so `dist/atomics.css` has become a + checked-in artifact whose producer is gone. Anyone quoting "1,275 rules, + byte-identical" should know the number can be *re-checked* but not *re-derived*. +- `dist/gamut-source.bundle.mjs` is now the only surviving executable copy of the + Gamut prop config + Core theme in this tree, which is why this spike depends on it + too. That is fragile provenance for the effort's single strongest Tier 1 result. + +### C2 — "byte-identical" in the atomics docs still means normalised maps; here it means bytes + +`claim-ledger.md` already corrected this (`gamut-atomics-poc/verify.mjs:106` +compares normalised declaration maps after kebab-casing and one `var()` deref) and +that correction stands. Recording the resolution rather than re-litigating it: + +- **`gamut-atomics-poc/verify.mjs:120`** prints + `✓ all 1275 produce byte-identical declarations`. The word is still wrong at that + line — it is a normalised-map comparison. `claim-ledger.md`'s correction is right. +- **This spike's assertion 1 is literal**: `Buffer.equals` plus SHA-256 over whole + files. **Assertion 2 is explicitly not** — its own output says + `(normalised: kebab-case + one var() deref, NOT bytes)`. +- Net: the *result* the docs assert survives and is now **also** true in the literal + sense, but only for the Panda-vs-Gamut-generator comparison. "Byte-identical to + today's `css()`" remains an overstatement, because `css()` returns a JS object, + not bytes. There is no byte comparison to be had on that side, ever. + +### C3 — `panda-styling-poc`'s precompute has a real defect, unreported + +`spikes/panda-styling-poc/src/precompute/generate.ts` (`cass-GMT-1715` @ `d969350dd`): + +> `const withoutVariant = hasBase ? fn({ theme }) : {};` +> `// subtract the base contribution so it isn't duplicated per variant` + +`fn({ theme })` does not return the base contribution. `createVariant` destructures +`{ [prop]: selected = defaultVariant } = props` (`packages/variance/src/core.ts:265`), +so with the prop omitted it returns **base merged with the default variant**. +Folding that into `base` moves the default variant's declarations into the shared +class and empties the default key. + +The spike could not observe it: its `authoring.ts` copy of `sizeVariants` has no +`base` key, while the shipping one does +(`packages/gamut/src/Button/shared/variants.ts:119`, `base: { fontWeight: 'title' }`). +Reproduced here, and on the real config it produces a base class carrying `size` +normal's `padding`/`fontSize`/`minInlineSize`/`blockSize` — harmless only because +all three size keys set the identical property set. + +Fix used here: select a key that cannot exist, so `variantFns[selected]` is +`undefined` and only `baseFn` contributes (`recipes.mjs`, `NO_SUCH_VARIANT`). + +### C4 — a second, independent defect in the same fold: `defineRecipe`'s single `base` is order-lossy + +Not a coding slip — a shape mismatch. `createButtonComponent` +(`packages/gamut/src/Button/shared/styles.ts:77-86`) composes five style layers in +order; several can carry a `base`. `defineRecipe` has one `base` slot, so folding is +forced, and a later layer's base ends up emitted before an earlier layer's variant +class — inverting the winner. + +Proven, not argued: `FOLD_VARIANT_BASE=1 node recipes.mjs && FOLD_VARIANT_BASE=1 +node verify-recipes.mjs` exits 1 with 4 divergences on `gmt-fold-hazard` +(`font-weight: runtime '400' vs emitted '700'`). Masked on the real Buttons because +`fontWeight` is the only property `sizeVariants.base` declares and no colour variant +sets it. + +This belongs beside A22 in the rewrite as a **second** silent-regression risk in the +recipe tier — bringing the count `MAP.md:103` tracks ("four instances found so far") +to six with C3. + +### C5 — S19's determinism claim is even weaker than the sweep says + +`panda-rationale-sweep.md:85` already notes the determinism "comes from +`${prop}_${value}` naming that **Gamut supplies**". Strengthening it with this +spike's evidence: the class names, the token variable names, the rule order, the +`@layer` structure and the media-query wrapping are **all** reproducible without +Panda, byte for byte. So on the atomics tier Panda contributes *no* naming or +determinism property that Gamut does not already supply or cannot trivially supply. +The one property that is genuinely Panda's is the **178-entry longhand ordering +table**, and that is a vendorable CSS fact rather than a tool capability. + +--- + +## Revised framing — 2026-08-11, after review + +The verdicts above stand as measurements. **The framing around them was too strong**, and +this section is the honest version. Added rather than rewritten so the original reasoning +stays auditable. + +### "Panda earns nothing" overstates it + +Byte-identity proves we **can** replicate the output. It does not prove replacing is +**worth it** — you only gain from replacing a generator if the generator is a problem, and +in `gamut-atomics-poc` Panda is already ejected, `presets: []`, extractor off, transforms +hand-written, theme replaced. It is barely Panda. But *barely used* also means *cheap to +keep*. + +**The more useful reading of byte-identity is that it makes the decision reversible.** Same +707,557 bytes either way, so picking wrong costs a generator swap, not a migration. + +### Three arguments against this spike's own conclusion + +1. **The 178-entry longhand table was extracted from Panda** — see `longhands.mjs`'s + provenance header (`@pandacss/shared` 0.53.7). So "zero `@pandacss/*` dependency" is + true of the build graph and **false of the data**. We vendor their table frozen in + time; if CSS gains shorthands, ours goes stale **silently** while theirs is updated + upstream. +2. **The recipe order-loss may have an untested Panda-side workaround.** + `FOLD_VARIANT_BASE=1` proves that *folding five layers into one `base` slot* inverts the + winner. It does **not** prove `defineRecipe` cannot express Gamut's composition another + way — and 9 of its 11 features are unused here, **including compound variants**. Nobody + tried the alternatives. A proven defect in one mapping was presented as a property of + the tool. +3. **"Panda's contribution was rule order derived from an accident of Gamut's naming"** is + a good line and a weak argument. If that order is what Gamut ships today, dropping Panda + means **owning the accident explicitly** in a hand-maintained table. Panda encoding it + for us is arguably a service. + +### What survives, and it is modest + +- **No codegen step** in the consumer or library build. +- **Force-emission by construction** — no extractor to disable, versus Panda's three + config-level defences that each fail silently. This one matters, because silent failure + is this migration's whole risk class. +- The recipe tier remains **weaker evidence than the atomics tier**: no Panda recipe + artifact exists to diff against, so it rests on runtime agreement rather than bytes. + +### The accurate verdict + +**A coin-flip weighted slightly toward a Gamut-owned emitter, on a decision that is cheap +to reverse.** The real question is not technical: *who maintains ~214 lines forever, and is +that their day job?* A permanently under-funded internal generator is a worse bet than a +small externally-maintained one — and Panda being a three-project team is a risk that +applies to both sides of that comparison. + +**This should not consume much decision-making energy.** Phase 3 (deleting Emotion) is +where the stakes are. diff --git a/spikes/gamut-emitter-poc/build-recipe-source.sh b/spikes/gamut-emitter-poc/build-recipe-source.sh new file mode 100755 index 0000000000..cee92449a2 --- /dev/null +++ b/spikes/gamut-emitter-poc/build-recipe-source.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Bundle the real variance + real Gamut prop config + real Core theme into one +# self-contained ESM file, so `recipes.mjs` runs under plain Node with no +# node_modules at all. Mirrors gamut-atomics-poc/dist/gamut-source.bundle.mjs. +set -e +node_modules/.bin/esbuild recipe-source.mjs \ + --bundle --platform=node --format=esm --outfile=dist/recipe-source.bundle.mjs diff --git a/spikes/gamut-emitter-poc/emit.mjs b/spikes/gamut-emitter-poc/emit.mjs new file mode 100644 index 0000000000..3e1ed957d0 --- /dev/null +++ b/spikes/gamut-emitter-poc/emit.mjs @@ -0,0 +1,161 @@ +/* A Gamut-OWNED atomic CSS emitter. Zero `@pandacss/*` imports. + * + * Same inputs as `gamut-atomics-poc`: the real Gamut prop config and the real + * Core theme, read through that spike's own bundled source module so the two + * generators cannot drift on their inputs. + * + * Output target: byte-for-byte equality with `gamut-atomics-poc/dist/atomics-base.css` + * and `.../atomics.css`. `verify.mjs` is the loud check. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import { + GAMUT_BASE_KEY, + SCALE_TO_PANDA_CATEGORY, + closedProps, + scaleValues, + viewportBreakpoints, +} from '../gamut-atomics-poc/dist/gamut-source.bundle.mjs'; +import { LONGHAND_PROPS } from './longhands.mjs'; + +/* Gamut defaults to logical properties (`variance/src/core.ts:150` reads + * `useLogicalProperties ?? true`), so this is the set that matches what `css()` + * actually emits today. `EMITTER_MODE=physical` emits the other set. */ +const MODE = process.env.EMITTER_MODE === 'physical' ? 'physical' : 'logical'; +const RESPONSIVE = process.env.EMITTER_RESPONSIVE !== '0'; + +const kebab = (s) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +/* ── 1. token layer ──────────────────────────────────────────────────────────── + * One CSS variable per (Gamut scale → token category) × token name. Token names + * are kebab-cased (`spacedTitle` → `--line-heights-spaced-title`) but the class + * name keeps the raw key (`.lineHeight_spacedTitle`) — those two differ, which is + * exactly the kind of asymmetry a hand-rolled generator gets wrong once. */ +const varFor = (scale, name) => + `--${kebab(SCALE_TO_PANDA_CATEGORY[scale])}-${kebab(name)}`; + +const usedScales = []; +for (const { scale } of closedProps) + if (!usedScales.includes(scale)) usedScales.push(scale); + +const tokenLines = []; +for (const scale of usedScales) + for (const [name, value] of Object.entries(scaleValues(scale))) + tokenLines.push(`${varFor(scale, name)}: ${String(value)};`); + +/* Breakpoint-derived tokens. These are not Gamut's — they exist because a + * generator that owns the breakpoint list may as well publish it, and because + * the oracle publishes them. Two categories, same five values. */ +for (const [key, width] of Object.entries(viewportBreakpoints)) + tokenLines.push(`--breakpoints-${key}: ${width};`); +for (const [key, width] of Object.entries(viewportBreakpoints)) + tokenLines.push(`--sizes-breakpoint-${key}: ${width};`); + +/* ── 2. the atomic rules ────────────────────────────────────────────────────── + * Gamut's prop config has THREE property shapes; shape 3 (`property` is an + * object keyed by mode) is the one that cost the oracle 720 silent classes. */ +const cssPropertiesFor = ({ property, properties }) => { + if (properties?.[MODE]?.length) return properties[MODE]; + if (typeof property === 'object' && property !== null) return [property[MODE]]; + return [property]; +}; + +/* Panda sorts atomic rules by `getPropertyPriority(entry.prop)` — the *utility + * key*, i.e. Gamut's prop name, not the CSS property it maps to. Priority is 0 + * for `all`, 2 if the key is a longhand of some CSS shorthand, else 1; the sort + * is stable, so config order survives inside each bucket. + * + * Consequence, and it is pure accident: Gamut's abbreviated props (`mx`, `bg`) + * are not CSS names, so they score 1, while the seven props whose names happen + * to BE CSS longhands — fontFamily/fontWeight/fontSize/lineHeight (longhands of + * `font`), rowGap/columnGap (of `gap`), borderColor (of `border`) — score 2 and + * are moved to the end of the layer. `borderColorLeft` is a longhand in CSS but + * not under that name, so it scores 1 and stays put. + * + * The table lives in `longhands.mjs` — 178 entries, vendored with provenance. */ + +export { LONGHAND_PROPS }; + +export const propertyPriority = (prop) => { + if (prop === 'all') return 0; + return LONGHAND_PROPS.has(prop) ? 2 : 1; +}; + +/** [{ prop, value, decls: [[cssProp, cssValue]] }], in emission order. */ +export const baseRules = closedProps + .map((config, index) => ({ config, index })) + .sort( + (a, b) => + propertyPriority(a.config.prop) - propertyPriority(b.config.prop) || + a.index - b.index + ) + .flatMap(({ config }) => { + const cssProperties = cssPropertiesFor(config); + return Object.keys(scaleValues(config.scale)).map((value) => ({ + prop: config.prop, + value, + decls: cssProperties.map((cssProp) => [ + kebab(cssProp), + `var(${varFor(config.scale, value)})`, + ]), + })); + }); + +/* ── 3. serialize ───────────────────────────────────────────────────────────── + * The oracle's formatting is PostCSS's stringifier under Panda's raws: selector + * at depth×2 spaces, declarations one level deeper, and the closing brace at + * column 0 regardless of depth. Base-layer children carry a leading blank line; + * rules nested inside a media query do not. No trailing newline at EOF. */ +const rule = (selector, decls, indent) => + `${' '.repeat(indent)}${selector} {\n` + + decls.map(([p, v]) => `${' '.repeat(indent + 2)}${p}: ${v};\n`).join('') + + `}\n`; + +/** px → rem at a 16px root, matching what a breakpoint list should publish. */ +const toRem = (px) => `${parseFloat(px) / 16}rem`; + +const escapeSelector = (className) => `.${className.replace(/:/g, '\\:')}`; + +export const emit = ({ responsive = RESPONSIVE } = {}) => { + let css = '@layer reset, base, tokens, recipes, utilities;\n'; + css += '\n@layer base{\n' + rule(':root', [['--made-with-panda', "'🐼'"]], 2) + '}\n'; + css += + '\n@layer tokens{\n' + + ' :where(:root, :host) {\n' + + tokenLines.map((line) => ` ${line}\n`).join('') + + '}\n}\n'; + + css += '\n@layer utilities{\n'; + for (const { prop, value, decls } of baseRules) + css += '\n' + rule(escapeSelector(`${prop}_${value}`), decls, 2); + + if (responsive) + for (const [key, width] of Object.entries(viewportBreakpoints)) { + css += `\n @media screen and (min-width: ${toRem(width)}) {\n`; + for (const { prop, value, decls } of baseRules) + css += rule(escapeSelector(`${key}:${prop}_${value}`), decls, 4); + css += '}\n'; + } + + css += '}'; + return css; +}; + +export const BASE_KEY = GAMUT_BASE_KEY; + +// pathToFileURL, not string concat: base camp's path contains a space +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + mkdirSync('dist', { recursive: true }); + const base = emit({ responsive: false }); + const full = emit({ responsive: true }); + writeFileSync('dist/atomics-base.css', base); + writeFileSync('dist/atomics.css', full); + const rules = (s) => (s.match(/\{/g) || []).length; + console.log( + `emitted dist/atomics-base.css ${base.length} bytes, ${baseRules.length} atomic rules\n` + + `emitted dist/atomics.css ${full.length} bytes, ${ + baseRules.length * (1 + Object.keys(viewportBreakpoints).length) + } atomic rules (${rules(full)} blocks)` + ); +} diff --git a/spikes/gamut-emitter-poc/longhands.mjs b/spikes/gamut-emitter-poc/longhands.mjs new file mode 100644 index 0000000000..93cbbb3cf0 --- /dev/null +++ b/spikes/gamut-emitter-poc/longhands.mjs @@ -0,0 +1,197 @@ +/* The shorthand→longhand table that decides atomic rule ORDER. + * + * A generator that emits `border-color` before `border` in source order has + * written a stylesheet where the shorthand silently wins. So *some* table like + * this is unavoidable machinery — it is not Panda-specific, it is CSS-specific. + * What IS Panda-specific is the exact contents, and therefore the exact rule + * order, and therefore byte-identity. + * + * PROVENANCE: extracted from `@pandacss/shared` 0.53.7's `shorthandProperties` + * (59 shorthands) as the set of every value appearing in it — + * `node_modules/@pandacss/shared/dist/index.mjs:661-771`, reduced at :772-775. + * VENDORED, NOT IMPORTED: the emitter has no `@pandacss/*` dependency at all. + * `verify.mjs` assertion 3 checks this copy against Panda's own function for + * every one of Gamut's 126 props, so the copy cannot go stale silently. + * + * 178 entries. This is the concrete size of "the table Gamut would own". + */ +export const LONGHAND_PROPS = new Set([ + 'alignContent', + 'alignItems', + 'alignSelf', + 'animationDelay', + 'animationDirection', + 'animationDuration', + 'animationFillMode', + 'animationIterationCount', + 'animationName', + 'animationPlayState', + 'animationTimingFunction', + 'backgroundAttachment', + 'backgroundClip', + 'backgroundColor', + 'backgroundImage', + 'backgroundOrigin', + 'backgroundPosition', + 'backgroundPositionX', + 'backgroundPositionY', + 'backgroundRepeat', + 'backgroundSize', + 'borderBlockEndColor', + 'borderBlockEndStyle', + 'borderBlockEndWidth', + 'borderBlockStartColor', + 'borderBlockStartStyle', + 'borderBlockStartWidth', + 'borderBottomColor', + 'borderBottomLeftRadius', + 'borderBottomRightRadius', + 'borderBottomStyle', + 'borderBottomWidth', + 'borderColor', + 'borderImageOutset', + 'borderImageRepeat', + 'borderImageSlice', + 'borderImageSource', + 'borderImageWidth', + 'borderInlineEndColor', + 'borderInlineEndStyle', + 'borderInlineEndWidth', + 'borderInlineStartColor', + 'borderInlineStartStyle', + 'borderInlineStartWidth', + 'borderLeftColor', + 'borderLeftStyle', + 'borderLeftWidth', + 'borderRightColor', + 'borderRightStyle', + 'borderRightWidth', + 'borderStyle', + 'borderTopColor', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderTopStyle', + 'borderTopWidth', + 'borderWidth', + 'bottom', + 'columnCount', + 'columnGap', + 'columnRuleColor', + 'columnRuleStyle', + 'columnRuleWidth', + 'columnWidth', + 'contain', + 'containIntrinsicSizeBlock', + 'containIntrinsicSizeInline', + 'content', + 'cueAfter', + 'cueBefore', + 'flexBasis', + 'flexDirection', + 'flexGrow', + 'flexShrink', + 'flexWrap', + 'fontFamily', + 'fontSize', + 'fontStretch', + 'fontStyle', + 'fontSynthesisSmallCaps', + 'fontSynthesisStyle', + 'fontSynthesisWeight', + 'fontVariantCaps', + 'fontVariantEastAsian', + 'fontVariantLigatures', + 'fontVariantNumeric', + 'fontVariantPosition', + 'fontWeight', + 'gridAutoColumns', + 'gridAutoFlow', + 'gridAutoRows', + 'gridColumnEnd', + 'gridColumnGap', + 'gridColumnStart', + 'gridRowEnd', + 'gridRowGap', + 'gridRowStart', + 'gridTemplateAreas', + 'gridTemplateColumns', + 'gridTemplateRows', + 'justifyContent', + 'justifyItems', + 'justifySelf', + 'left', + 'lineHeight', + 'listStyleImage', + 'listStylePosition', + 'listStyleType', + 'marginBottom', + 'marginLeft', + 'marginRight', + 'marginTop', + 'maskBorderMode', + 'maskBorderOutset', + 'maskBorderRepeat', + 'maskBorderSlice', + 'maskBorderSource', + 'maskBorderWidth', + 'maskClip', + 'maskComposite', + 'maskImage', + 'maskMode', + 'maskOrigin', + 'maskPosition', + 'maskRepeat', + 'maskSize', + 'offsetAnchor', + 'offsetDistance', + 'offsetPath', + 'offsetPosition', + 'offsetRotate', + 'outlineColor', + 'outlineStyle', + 'outlineWidth', + 'overflowX', + 'overflowY', + 'paddingBottom', + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'pauseAfter', + 'pauseBefore', + 'restAfter', + 'restBefore', + 'right', + 'rowGap', + 'scrollMarginBottom', + 'scrollMarginLeft', + 'scrollMarginRight', + 'scrollMarginTop', + 'scrollPaddingBlockEnd', + 'scrollPaddingBlockStart', + 'scrollPaddingBottom', + 'scrollPaddingInlineEnd', + 'scrollPaddingInlineStart', + 'scrollPaddingLeft', + 'scrollPaddingRight', + 'scrollPaddingTop', + 'scrollSnapMarginBlockEnd', + 'scrollSnapMarginBlockStart', + 'scrollSnapMarginBottom', + 'scrollSnapMarginInlineEnd', + 'scrollSnapMarginInlineStart', + 'scrollSnapMarginLeft', + 'scrollSnapMarginRight', + 'scrollSnapMarginTop', + 'scrollTimelineOrientation', + 'scrollTimelineSource', + 'textDecorationColor', + 'textDecorationLine', + 'textDecorationStyle', + 'textEmphasisColor', + 'textEmphasisStyle', + 'top', + 'transitionDelay', + 'transitionDuration', + 'transitionProperty', + 'transitionTimingFunction', +]); diff --git a/spikes/gamut-emitter-poc/package.json b/spikes/gamut-emitter-poc/package.json new file mode 100644 index 0000000000..8ba7c50bb8 --- /dev/null +++ b/spikes/gamut-emitter-poc/package.json @@ -0,0 +1,14 @@ +{ + "name": "gamut-emitter-poc", + "description": "Ticket 011: a Gamut-owned atomic CSS emitter with no @pandacss/* dependency, diffed byte-for-byte against gamut-atomics-poc's Panda-generated oracle.", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "emit": "node emit.mjs", + "verify": "node verify.mjs", + "recipes": "node recipes.mjs", + "verify-recipes": "node verify-recipes.mjs", + "all": "node emit.mjs && node verify.mjs && node recipes.mjs && node verify-recipes.mjs" + } +} diff --git a/spikes/gamut-emitter-poc/recipe-source.mjs b/spikes/gamut-emitter-poc/recipe-source.mjs new file mode 100644 index 0000000000..cac43773a8 --- /dev/null +++ b/spikes/gamut-emitter-poc/recipe-source.mjs @@ -0,0 +1,30 @@ +/* The recipe tier's inputs: the REAL `variance` implementation of `variant()` / + * `states()` / `css()`, bound to the REAL Gamut prop config and Core theme. + * + * Not the spike engine's copy on `cass-GMT-1715` — the actual + * `packages/variance/dist/core.js`, so `createVariant`'s merge order and + * `createStates`'s declaration-order deep merge are the shipping ones. + * + * Two reasons this needs bundling rather than a plain import: + * 1. variance's dist uses extensionless `lodash/get` specifiers, which plain + * Node ESM will not resolve. + * 2. Both packages' `exports` maps have TIGHTENED since gamut-atomics-poc was + * built — `@codecademy/gamut-styles/dist/variance/config.js` is no longer + * reachable by specifier, and gamut-styles' dist now ships only `.d.ts` for + * it. So the prop config and theme come from the oracle's own prebuilt + * bundle instead; see the README's "What this cannot answer". + * + * Deep relative path, not a package specifier, deliberately: the exports map + * would reject the specifier and this is a spike input, not a consumer import. + */ +import { variance } from './node_modules/@codecademy/variance/dist/core.js'; + +import { + GAMUT_PROPS, + coreTheme, +} from '../gamut-atomics-poc/dist/gamut-source.bundle.mjs'; + +export const css = variance.createCss(GAMUT_PROPS); +export const variant = variance.createVariant(GAMUT_PROPS); +export const states = variance.createStates(GAMUT_PROPS); +export { GAMUT_PROPS, coreTheme }; diff --git a/spikes/gamut-emitter-poc/recipes.mjs b/spikes/gamut-emitter-poc/recipes.mjs new file mode 100644 index 0000000000..8e60f6740a --- /dev/null +++ b/spikes/gamut-emitter-poc/recipes.mjs @@ -0,0 +1,472 @@ +/* THE RECIPE TIER — a Gamut-owned replacement for Panda's `defineRecipe`, sized by + * building it rather than by argument. + * + * Three parts, each measurable: + * + * A. AUTHORING (unchanged, 0 lines of new code) — a faithful reproduction of + * `packages/gamut/src/Button/shared/{styles,variants}.ts`, executed against + * the REAL `variance` from `packages/variance/dist/core.js`. Reproduced + * rather than imported only because gamut-styles' dist ships no JS in this + * tree; every construct that defeats a static extractor is kept: + * `templateVariants` builds the config programmatically, selectors are + * computed enum keys, `transitionConcat()` is a call, and `textButton` has a + * ternary. Nothing here is parsed — it is EXECUTED. + * + * B. PRECOMPUTE (`precompute`) — enumerate each style function's domain and + * collapse it into `{ className, base, variants, defaultVariants }`. This is + * the structure `panda-styling-poc` already hands to `defineRecipe`, so it is + * machinery Gamut owns either way. Not a cost of dropping Panda. + * + * C. EMIT (`emitRecipeCss`) — the part Panda currently does. Flatten nested + * `&`-selectors, kebab-case, one class per variant key, force-emit the whole + * matrix. That is the honest size of the replacement. + * + * `verify-recipes.mjs` is the loud check: for every point in the variant matrix it + * compares the cascade of emitted classes against the runtime deep merge. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import { + coreTheme, + css, + states, + variant, +} from './dist/recipe-source.bundle.mjs'; + +/* ══ A. authoring, reproduced verbatim ═══════════════════════════════════════ */ + +/** `packages/gamut-styles/src/variables/timing.ts` — `fast: 150ms`. */ +const timing = { fast: '150ms', slow: '300ms' }; + +/** `packages/gamut-styles/src/styles/transitionConcat.ts`, verbatim. */ +const transitionConcat = (properties, transition, timingFn = 'linear') => { + const suffix = `${timing[transition]} ${timingFn}`; + return `${properties.join(` ${suffix},`)} ${suffix}`; +}; + +/** `packages/gamut/src/ButtonBase/ButtonBase.tsx` — computed selector keys. */ +const ButtonSelectors = { + HOVER: '&:hover', + ACTIVE: '&:active', + DISABLED: "[disabled], &:disabled, &[aria-disabled='true']", + FOCUS_VISIBLE: '&:focus-visible', + OUTLINE: '&:before', + OUTLINE_FOCUS_VISIBLE: '&:focus-visible:before', +}; + +const buttonVariants = ['primary', 'secondary', 'danger', 'interface']; + +/** `shared/styles.ts`, verbatim — builds the variant config PROGRAMMATICALLY. */ +const templateVariants = (variants, template) => { + const variantConfig = {}; + variants.forEach((key) => { + variantConfig[key] = template(key); + }); + return variant({ defaultVariant: variants[0], variants: variantConfig }); +}; + +const hoverBackgroundTransition = transitionConcat( + ['background-color', 'box-shadow'], + 'fast', + 'ease-in' +); + +const buttonStyles = css({ + position: 'relative', + whiteSpace: 'nowrap', + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + border: 2, + borderRadius: 'md', + borderColor: 'transparent', + transition: transitionConcat( + ['border-color', 'color', 'background-color', 'box-shadow'], + 'fast', + 'ease-in' + ), + [ButtonSelectors.DISABLED]: { cursor: 'not-allowed', userSelect: 'none' }, + [ButtonSelectors.OUTLINE]: { + content: '""', + transition: transitionConcat(['opacity'], 'fast'), + position: 'absolute', + borderRadius: 'lg', + border: 2, + inset: -5, + opacity: 0, + zIndex: 0, + }, + [ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1 }, +}); + +const fillButtonVariants = templateVariants(buttonVariants, (v) => ({ + bg: v, + color: 'background', + [ButtonSelectors.OUTLINE]: { borderColor: v }, + [ButtonSelectors.HOVER]: { + bg: `${v}-hover`, + color: 'background', + transition: hoverBackgroundTransition, + }, + [ButtonSelectors.ACTIVE]: { + borderColor: 'border-primary', + bg: v, + color: 'background', + }, + [ButtonSelectors.DISABLED]: { color: 'text-disabled', bg: 'background-disabled' }, +})); + +const textButtonVariants = templateVariants(buttonVariants, (v) => ({ + borderColor: 'transparent', + // the ternary a static extractor cannot fold + color: v === 'interface' ? 'text' : v, + [ButtonSelectors.HOVER]: { + color: v, + bg: 'background-hover', + transition: hoverBackgroundTransition, + }, + [ButtonSelectors.FOCUS_VISIBLE]: { color: v }, + [ButtonSelectors.OUTLINE]: { borderColor: v }, + [ButtonSelectors.ACTIVE]: { color: 'text' }, + [ButtonSelectors.DISABLED]: { color: 'text-disabled', bg: 'transparent' }, +})); + +const strokeButtonVariants = templateVariants(buttonVariants, (v) => ({ + borderColor: v, + bg: 'transparent', + color: v, + [ButtonSelectors.HOVER]: { bg: 'background-hover', transition: hoverBackgroundTransition }, + [ButtonSelectors.OUTLINE]: { borderColor: v }, + [ButtonSelectors.ACTIVE]: { bg: v, color: 'background' }, + [ButtonSelectors.DISABLED]: { + borderColor: 'background-disabled', + color: 'text-disabled', + bg: 'transparent', + }, +})); + +/* `variant({ base })` — the folding path. `shared/variants.ts` really does this. */ +const sizeVariants = variant({ + prop: 'size', + defaultVariant: 'normal', + base: { fontWeight: 'title' }, + variants: { + normal: { fontSize: 16, height: 40, minWidth: 40, py: 4, px: 16 }, + small: { fontSize: 14, height: 32, minWidth: 32, py: 4, px: 8 }, + large: { fontSize: 18, height: 56, minWidth: 40, py: 4, px: 16 }, + }, +}); + +const buttonStates = states({ fullWidth: { width: '100%' } }); + +/* The A22 fidelity probe: two states writing the SAME property. `states()` + * deep-merges in declaration order; an emitter that writes one independent class + * per active boolean lets STYLESHEET order decide. If those disagree it is a + * silent visual regression. mono has 62 `states()` sites. */ +const overlapStates = states({ + warning: { bg: 'yellow' }, + error: { bg: 'red' }, +}); + +/* ══ B. precompute ═══════════════════════════════════════════════════════════ */ + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const merge = (target, source) => { + Object.entries(source ?? {}).forEach(([key, value]) => { + target[key] = + isPlainObject(value) && isPlainObject(target[key]) + ? merge({ ...target[key] }, value) + : value; + }); + return target; +}; + +const theme = coreTheme; + +/* See the `variant({ base })` note in `precompute`. Default OFF: not folding is + * strictly more faithful to the runtime, because it preserves layer order. */ +export const FOLD_VARIANT_BASE = process.env.FOLD_VARIANT_BASE === '1'; + +/* `variantMeta`/`stateMeta` do NOT exist on shipping `variance` — the spike added + * them on `cass-GMT-1715`. Here the descriptor carries them instead, which is the + * same information from the other side. Either way it is ~6 lines in variance, and + * it is required by the Panda route too: `generate.ts` reads exactly this. */ +export const precompute = ({ className, layers }) => { + const base = {}; + const variants = {}; + const defaultVariants = {}; + + for (const layer of layers) { + if (layer.kind === 'variant') { + const { fn, prop, keys, defaultVariant, hasBase } = layer; + variants[prop] ??= {}; + if (defaultVariant) defaultVariants[prop] = defaultVariant; + + /* `variant({ base })` contributes to every key, so fold it into `base` once + * and subtract it from each key so it is not duplicated. + * + * DEFECT FOUND, and it is not hypothetical — `panda-styling-poc`'s + * `src/precompute/generate.ts` (`cass-GMT-1715`) reads the base + * contribution as `fn({ theme })`. But `createVariant` defaults the + * selection (`variance/src/core.ts:266`: `{ [prop]: selected = + * defaultVariant } = props`), so `fn({ theme })` returns + * base ∪ defaultVariant — NOT base. Folding that into `base` moves the + * default variant's declarations into the shared class and empties the + * default key. + * + * It is invisible on Gamut's real `sizeVariants` only because all three + * size keys set the identical property set, so the later class overwrites + * every leaked declaration. Any variant where one key sets a property + * another does not would leak that property onto every key. The spike never + * saw it because its copy of `sizeVariants` dropped the `base` key that the + * shipping one has (`packages/gamut/src/Button/shared/variants.ts:110`). + * + * Fix: select a key that cannot exist, so `variantFns[selected]` is + * undefined and only `baseFn` contributes. */ + const NO_SUCH_VARIANT = ' none'; + const baseOnly = hasBase ? fn({ [prop]: NO_SUCH_VARIANT, theme }) : {}; + if (hasBase && FOLD_VARIANT_BASE) merge(base, baseOnly); + + for (const key of keys) { + const withVariant = fn({ [prop]: key, theme }); + if (!FOLD_VARIANT_BASE) { + /* Don't fold at all. A Gamut component composes SEVERAL style layers + * (`createButtonComponent` = buttonStyles, colour variant, size variant, + * states), each of which may carry its own `base`. Panda's + * `defineRecipe` has exactly ONE `base` slot, so every layer's base must + * be flattened into it — and that loses layer order: a later layer's + * base is emitted BEFORE an earlier layer's variant class, inverting who + * wins. Keeping the base inside each key costs duplication and preserves + * order exactly. + * + * `FOLD_VARIANT_BASE=1` restores the folding, so `verify-recipes.mjs` + * measures the difference instead of asserting it. On Gamut's real + * Buttons the two agree — only because no colour variant sets + * `fontWeight`, the single property `sizeVariants.base` declares. Luck, + * not a guarantee. */ + variants[prop][key] = merge(variants[prop][key] ?? {}, withVariant); + continue; + } + const only = {}; + for (const [cssProp, value] of Object.entries(withVariant)) + if (JSON.stringify(baseOnly[cssProp]) !== JSON.stringify(value)) + only[cssProp] = value; + variants[prop][key] = merge(variants[prop][key] ?? {}, only); + } + continue; + } + + if (layer.kind === 'states') { + for (const key of layer.keys) { + variants[key] ??= {}; + variants[key].true = merge( + variants[key].true ?? {}, + layer.fn({ [key]: true, theme }) + ); + } + continue; + } + + merge(base, layer.fn({ theme })); + } + + return { className, base, variants, defaultVariants }; +}; + +const sizeLayer = { + kind: 'variant', + fn: sizeVariants, + prop: 'size', + keys: ['normal', 'small', 'large'], + defaultVariant: 'normal', + hasBase: true, +}; +const statesLayer = { kind: 'states', fn: buttonStates, keys: ['fullWidth'] }; +const colourLayer = (fn) => ({ + kind: 'variant', + fn, + prop: 'variant', + keys: buttonVariants, + defaultVariant: 'primary', + hasBase: false, +}); + +export const descriptors = [ + { + className: 'gmt-fill-button', + layers: [ + { kind: 'css', fn: buttonStyles }, + colourLayer(fillButtonVariants), + sizeLayer, + statesLayer, + ], + }, + { + className: 'gmt-text-button', + layers: [ + { kind: 'css', fn: buttonStyles }, + colourLayer(textButtonVariants), + sizeLayer, + statesLayer, + ], + }, + { + className: 'gmt-stroke-button', + layers: [ + { kind: 'css', fn: buttonStyles }, + colourLayer(strokeButtonVariants), + sizeLayer, + statesLayer, + ], + }, + { + className: 'gmt-overlap', + layers: [{ kind: 'states', fn: overlapStates, keys: ['warning', 'error'] }], + }, + /* ADVERSARIAL PROBE for the base-folding hazard. A colour variant that sets + * `fontWeight`, composed with `sizeVariants` whose `base` also sets it — the + * same shape as the real Buttons, minus the coincidence that saves them. + * + * Runtime order is colour-then-size, so size's base wins (`400`). Folding size's + * base into the recipe's single `base` slot emits it BEFORE the colour class, so + * the colour wins (`700`) — a different rendered weight, with no error. + * `FOLD_VARIANT_BASE=1` makes `verify-recipes.mjs` fail on exactly this. */ + { + className: 'gmt-fold-hazard', + layers: [ + { + ...colourLayer( + templateVariants(['primary', 'secondary'], (v) => ({ + bg: v, + fontWeight: 'title', + })) + ), + keys: ['primary', 'secondary'], + }, + { + kind: 'variant', + fn: variant({ + prop: 'size', + defaultVariant: 'normal', + base: { fontWeight: 400 }, + variants: { normal: { fontSize: 16 }, large: { fontSize: 18 } }, + }), + prop: 'size', + keys: ['normal', 'large'], + defaultVariant: 'normal', + hasBase: true, + }, + ], + }, +]; + +export const recipes = descriptors.map(precompute); + +/* ══ C. emit ═════════════════════════════════════════════════════════════════ */ + +const kebab = (prop) => + prop.startsWith('--') ? prop : prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +/* Selector nesting, stylis/Emotion semantics — and this is where a naive emitter + * gets it wrong. `&:hover` is self-attachment, but a comma-separated key like + * `[disabled], &:disabled, &[aria-disabled='true']` mixes BOTH forms: the first + * part has no `&`, so it is a DESCENDANT (`.cls [disabled]`), while the other two + * attach. Splitting on the comma per part is mandatory; treating the whole key as + * one selector, or prefixing `&` to it wholesale, silently changes which elements + * get styled. */ +export const resolveSelector = (parent, key) => + key + .split(',') + .map((part) => { + const trimmed = part.trim(); + return trimmed.includes('&') + ? trimmed.replace(/&/g, parent) + : `${parent} ${trimmed}`; + }) + .join(', '); + +/** A CSSObject → [[selector, [[cssProp, value]]]] in declaration order. */ +export const flatten = (selector, styles) => { + const own = []; + const nested = []; + for (const [key, value] of Object.entries(styles ?? {})) { + if (value === undefined || value === null) continue; + if (isPlainObject(value)) { + nested.push(...flatten(resolveSelector(selector, key), value)); + continue; + } + own.push([kebab(key), String(value)]); + } + return [...(own.length ? [[selector, own]] : []), ...nested]; +}; + +const block = (selector, decls) => + `${selector} {\n${decls.map(([p, v]) => ` ${p}: ${v};\n`).join('')}}\n`; + +/** The class a given variant key gets. Panda's shape: `cls--prop_key`. */ +export const variantClass = (className, prop, key) => `${className}--${prop}_${key}`; + +/* The RUNTIME half of the recipe tier: props in, class names out. This is what + * Panda's generated `recipe()` function does, and it is the only place + * `defaultVariants` matters — nothing about a default is emitted into CSS, so if + * the runtime forgets to apply `--size_normal` when `size` is omitted, the + * component renders unsized with no error. 12 lines. */ +export const recipeClasses = (recipe, props = {}) => { + const classNames = [recipe.className]; + for (const [prop, byKey] of Object.entries(recipe.variants)) { + const selected = props[prop] ?? recipe.defaultVariants[prop]; + if (selected === undefined || selected === false) continue; + const key = selected === true ? 'true' : String(selected); + if (byKey[key]) classNames.push(variantClass(recipe.className, prop, key)); + } + return classNames; +}; + +/* FORCE-EMISSION (U1). Every recipe emits its base class plus one class per + * variant key — unconditionally, from the descriptor, with no reference to what + * any app renders. That is what Module Federation requires: a host must style a + * variant it never renders. There is no extractor here to switch off, so this is + * satisfied by construction rather than by configuration. */ +export const emitRecipeCss = (recipeList = recipes) => { + let out = ''; + for (const { className, base, variants } of recipeList) { + for (const [selector, decls] of flatten(`.${className}`, base)) + out += block(selector, decls); + for (const [prop, byKey] of Object.entries(variants)) + for (const [key, styles] of Object.entries(byKey)) + for (const [selector, decls] of flatten( + `.${variantClass(className, prop, key)}`, + styles + )) + out += block(selector, decls); + } + return out; +}; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + mkdirSync('dist', { recursive: true }); + const out = emitRecipeCss(); + writeFileSync('dist/recipes.css', out); + console.log('\nrecipe tier — variance authoring → static CSS, no Panda\n'); + for (const { className, base, variants, defaultVariants } of recipes) { + const summary = Object.entries(variants) + .map(([prop, keys]) => `${prop}(${Object.keys(keys).join('|')})`) + .join(' '); + console.log( + ` .${className.padEnd(20)} base decls ${String( + Object.keys(base).length + ).padStart(2)} variants ${summary} defaults ${JSON.stringify( + defaultVariants + )}` + ); + } + console.log( + `\n wrote dist/recipes.css — ${(out.match(/\{/g) || []).length} rules, ${ + out.length + } bytes` + ); + console.log(` authoring changes required: NONE\n`); +} diff --git a/spikes/gamut-emitter-poc/verify-recipes.mjs b/spikes/gamut-emitter-poc/verify-recipes.mjs new file mode 100644 index 0000000000..afee5062fe --- /dev/null +++ b/spikes/gamut-emitter-poc/verify-recipes.mjs @@ -0,0 +1,226 @@ +/* Does the Gamut-owned recipe emitter produce what the runtime produces? + * + * The atomics tier had a Panda artifact to diff against. The recipe tier has none + * — `panda-styling-poc`'s `src/gamut-static.css` is gitignored and Panda is not + * installed in this tree — so the oracle here is the RUNTIME, which is the one + * that actually decides what a user sees. That is a stronger oracle than "matches + * Panda", and it is the oracle `panda-via-gamut-option-a.md`'s own fidelity probe + * used (`verify-fidelity.ts` compares the runtime merge to the stylesheet). + * + * Method, stated precisely because "byte-identical" would be wrong here: + * - RUNTIME side: deep-merge the component's style layers in `createButtonComponent` + * order for a given point in the matrix, then flatten to + * selector-context → { css-property: value }. + * - EMITTED side: parse `dist/recipes.css` in file order, keep the blocks whose + * class is active at that point, normalise each selector back to `&`-relative + * form, and let later rules win — i.e. simulate the cascade for single-class + * specificity. + * - Compare the two maps. Equal keys, equal values, no extras. + * So: NORMALISED DECLARATION MAPS PER SELECTOR CONTEXT, not bytes. + * + * Loud: `process.exitCode = 1` on any divergence. + */ +import { readFileSync } from 'node:fs'; + +import { + FOLD_VARIANT_BASE, + descriptors, + flatten, + precompute, + recipeClasses, + recipes, + variantClass, +} from './recipes.mjs'; +import { coreTheme } from './dist/recipe-source.bundle.mjs'; + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); +const merge = (target, source) => { + Object.entries(source ?? {}).forEach(([key, value]) => { + target[key] = + isPlainObject(value) && isPlainObject(target[key]) + ? merge({ ...target[key] }, value) + : value; + }); + return target; +}; + +/* ── parse the emitted sheet ────────────────────────────────────────────────── */ +const css = readFileSync('dist/recipes.css', 'utf8'); +const blocks = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map((match) => { + const decls = []; + for (const decl of match[2].split(';')) { + const idx = decl.indexOf(':'); + if (idx !== -1) decls.push([decl.slice(0, idx).trim(), decl.slice(idx + 1).trim()]); + } + return { selector: match[1].trim(), decls }; +}); + +/** Which class does this rule belong to? The first class token in the selector. */ +const classOf = (selector) => /\.([A-Za-z][\w-]*)/.exec(selector)?.[1]; + +/** `.gmt-fill-button--variant_primary:before` → `&:before`, for any active class. */ +const contextOf = (selector, activeClasses) => { + let out = selector; + /* LONGEST FIRST, and this bites: `.gmt-fill-button` is a strict prefix of + * `.gmt-fill-button--variant_primary`, so replacing the short one first leaves + * `&--variant_primary` and every variant rule silently stops matching. */ + for (const className of [...activeClasses].sort((a, b) => b.length - a.length)) + out = out.split(`.${className}`).join('&'); + return out; +}; + +/** Simulate the cascade for the classes active at one matrix point. */ +const cascade = (activeClasses) => { + const active = new Set(activeClasses); + const out = {}; + for (const { selector, decls } of blocks) { + const owner = classOf(selector); + if (!owner || !active.has(owner)) continue; + const context = contextOf(selector, activeClasses); + out[context] ??= {}; + for (const [property, value] of decls) out[context][property] = value; + } + return out; +}; + +/** The runtime CSSObject for one matrix point, flattened the same way. */ +const runtimeMap = (layers, props) => { + const merged = {}; + for (const layer of layers) merge(merged, layer.fn({ ...props, theme: coreTheme })); + const out = {}; + for (const [selector, decls] of flatten('&', merged)) { + out[selector] ??= {}; + for (const [property, value] of decls) out[selector][property] = value; + } + return out; +}; + +/* ── enumerate the matrix ───────────────────────────────────────────────────── */ +/** Every point: one value per variant prop × every boolean-state subset. */ +const matrixPoints = (recipe) => { + const variantProps = []; + const booleanProps = []; + for (const [prop, byKey] of Object.entries(recipe.variants)) + (Object.keys(byKey).length === 1 && byKey.true ? booleanProps : variantProps).push([ + prop, + Object.keys(byKey), + ]); + + let points = [{}]; + for (const [prop, keys] of variantProps) + points = points.flatMap((point) => keys.map((key) => ({ ...point, [prop]: key }))); + for (const [prop] of booleanProps) + points = points.flatMap((point) => [ + { ...point }, + { ...point, [prop]: true }, + ]); + /* And the point where EVERY variant prop is omitted, so `defaultVariants` has to + * supply them. The runtime side does the same by construction + * (`variance/src/core.ts:266`), so this arm tests that the two defaults agree. */ + points.push({}); + return points; +}; + +/* Go through the runtime resolver rather than constructing class names inline, so + * the resolver is under test too — including `defaultVariants`, which appears + * nowhere in the CSS and is therefore the one part a byte-diff could never catch. */ +const activeFor = (recipe, point) => recipeClasses(recipe, point); + +let checked = 0; +const failures = []; + +for (const [index, recipe] of recipes.entries()) { + const { layers } = descriptors[index]; + for (const point of matrixPoints(recipe)) { + checked += 1; + const expected = runtimeMap(layers, point); + const actual = cascade(activeFor(recipe, point)); + + const contexts = new Set([...Object.keys(expected), ...Object.keys(actual)]); + for (const context of contexts) { + const exp = expected[context] ?? {}; + const act = actual[context] ?? {}; + const properties = new Set([...Object.keys(exp), ...Object.keys(act)]); + for (const property of properties) + if (exp[property] !== act[property]) + failures.push( + `.${recipe.className} ${JSON.stringify(point)} @ "${context}" ` + + `${property}: runtime '${exp[property]}' vs emitted '${act[property]}'` + ); + } + } +} + +console.log( + `\nRECIPE TIER — emitted cascade vs runtime deep merge` + + ` (FOLD_VARIANT_BASE=${FOLD_VARIANT_BASE ? '1' : '0'})` +); +console.log( + ` method: normalised declaration maps per selector context, NOT bytes` +); +console.log( + ` ${recipes.length} recipes, ${checked} matrix points, ${blocks.length} emitted rules` +); +if (failures.length === 0) + console.log(` ✓ every matrix point matches the runtime, declaration for declaration`); +else { + console.log(` ✗ ${failures.length} divergences`); + failures.slice(0, 12).forEach((f) => console.log(` ${f}`)); + process.exitCode = 1; +} + +/* ── the A22 states-overlap probe, re-run against a Gamut-owned emitter ────── */ +const overlapLayers = descriptors.find((d) => d.className === 'gmt-overlap').layers; +const both = cascade([ + 'gmt-overlap', + 'gmt-overlap--warning_true', + 'gmt-overlap--error_true', +]); +const runtimeBoth = runtimeMap(overlapLayers, { warning: true, error: true }); +const emittedWinner = both['&']?.['background-color']; +const runtimeWinner = runtimeBoth['&']?.['background-color']; +console.log(`\n A22 states() overlap — two states, same property, both active`); +console.log(` runtime (declaration-order deep merge): ${runtimeWinner}`); +console.log(` emitted (stylesheet order): ${emittedWinner}`); +if (runtimeWinner && runtimeWinner === emittedWinner) + console.log( + ` ✓ agree. Discriminating: declaration order is warning→error while\n` + + ` ALPHABETICAL order is error→warning, so a sorted emitter would fail.\n` + + ` Here it holds because the emitter walks Object.keys of the states\n` + + ` config — a property of THIS emitter's own code, not of a vendor's.` + ); +else { + console.log(` ✗ disagree — silent visual regression`); + process.exitCode = 1; +} + +/* ── U1: force-emission, asserted rather than asserted-in-prose ─────────────── */ +const expectedClasses = new Set(); +for (const recipe of recipes) { + // a recipe with no base declarations legitimately emits no base rule + if (Object.keys(recipe.base).length) expectedClasses.add(recipe.className); + for (const [prop, byKey] of Object.entries(recipe.variants)) + for (const key of Object.keys(byKey)) + expectedClasses.add(variantClass(recipe.className, prop, key)); +} +const emittedClasses = new Set(blocks.map(({ selector }) => classOf(selector))); +const missing = [...expectedClasses].filter((c) => !emittedClasses.has(c)); +console.log(`\n U1 force-emission — the whole matrix is present, usage-independent`); +console.log( + ` ${expectedClasses.size} classes required by the matrix, ${ + emittedClasses.size + } emitted` +); +if (missing.length === 0) + console.log( + ` ✓ every variant class exists. Nothing renders in this process, so this\n` + + ` cannot be usage-driven: there is no extractor to disable.` + ); +else { + console.log(` ✗ ${missing.length} missing: ${missing.slice(0, 8).join(', ')}`); + process.exitCode = 1; +} + +console.log(`\n${process.exitCode ? '✗ FAIL' : '✓ PASS'} — exit ${process.exitCode ?? 0}\n`); +void precompute; diff --git a/spikes/gamut-emitter-poc/verify.mjs b/spikes/gamut-emitter-poc/verify.mjs new file mode 100644 index 0000000000..c3d2c0f05e --- /dev/null +++ b/spikes/gamut-emitter-poc/verify.mjs @@ -0,0 +1,174 @@ +/* Three assertions, all loud (`process.exitCode = 1`): + * + * 1. BYTES — the Gamut-owned emitter's output is byte-for-byte identical to + * `gamut-atomics-poc/dist/*.css`, the Panda-generated oracle. Not + * "equivalent after normalisation": `Buffer.equals`, plus a SHA-256 of both. + * 2. FIDELITY — every emitted rule matches what the real `css()` produces + * today. Independent of assertion 1: if the oracle itself were wrong, 1 + * would still pass and this would not. Declaration maps after kebab-casing + * and one `var()` deref — NOT a byte comparison, and it says so. + * 3. PRIORITY TABLE — the emitter hardcodes the subset of Panda's + * shorthand→longhand table that Gamut's props can reach, because that table + * is what determines rule ORDER. This asserts the subset agrees with + * Panda's real `getPropertyPriority` on all 126 Gamut props, so a new Gamut + * prop that lands in the table cannot silently reorder the sheet. + * Skipped (loudly, but not fatally) if no Panda install is reachable — + * Panda is not a dependency of the emitter, only of this assertion. + */ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; + +import { + GAMUT_PROPS, + closedProps, + coreTheme, + gamutCss, + scaleValues, +} from '../gamut-atomics-poc/dist/gamut-source.bundle.mjs'; +import { LONGHAND_PROPS, propertyPriority } from './emit.mjs'; + +const sha = (buf) => createHash('sha256').update(buf).digest('hex').slice(0, 16); + +/* ── 1. bytes ──────────────────────────────────────────────────────────────── */ +console.log('\n1. BYTES — Gamut-owned emitter vs the Panda oracle'); +let bytesOk = true; +for (const file of ['atomics-base.css', 'atomics.css']) { + const mine = readFileSync(`dist/${file}`); + const oracle = readFileSync(`../gamut-atomics-poc/dist/${file}`); + const equal = mine.equals(oracle); + if (!equal) bytesOk = false; + console.log( + ` ${equal ? '✓' : '✗'} ${file.padEnd(18)} mine ${String(mine.length).padStart( + 7 + )}B sha ${sha(mine)} oracle ${String(oracle.length).padStart(7)}B sha ${sha( + oracle + )}` + ); + if (!equal) { + // first differing byte, so the failure names a location rather than a fact + const n = Math.min(mine.length, oracle.length); + let i = 0; + while (i < n && mine[i] === oracle[i]) i += 1; + const line = mine.subarray(0, i).toString().split('\n').length; + console.log( + ` first divergence at byte ${i} (line ${line}):\n` + + ` mine: ${JSON.stringify(mine.subarray(i, i + 60).toString())}\n` + + ` oracle: ${JSON.stringify(oracle.subarray(i, i + 60).toString())}` + ); + } +} +if (!bytesOk) process.exitCode = 1; + +/* ── 2. fidelity against the real css() ────────────────────────────────────── */ +const css = readFileSync('dist/atomics.css', 'utf8'); + +const tokenValues = new Map( + [...css.matchAll(/(--[a-zA-Z]+-[A-Za-z0-9-]+):\s*([^;]+);/g)].map((m) => [ + m[1], + m[2].trim(), + ]) +); +const deref = (value) => { + const match = /^var\((--[^),]+)\)$/.exec(String(value).trim()); + if (!match) return String(value).trim(); + return (tokenValues.get(match[1]) ?? String(value).trim()).trim(); +}; + +const ruleFor = new Map(); +for (const match of css.matchAll(/\n\s*\.([A-Za-z][A-Za-z0-9]*_[^\s,{]+)\s*\{([^}]*)\}/g)) { + const className = match[1].replace(/\\/g, ''); + if (ruleFor.has(className)) continue; // base layer wins + const decls = {}; + for (const decl of match[2].split(';')) { + const idx = decl.indexOf(':'); + if (idx !== -1) decls[decl.slice(0, idx).trim()] = decl.slice(idx + 1).trim(); + } + ruleFor.set(className, decls); +} + +const kebab = (prop) => prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +let checked = 0; +const mismatches = []; +for (const { prop, scale } of closedProps) { + for (const value of Object.keys(scaleValues(scale))) { + checked += 1; + const expected = gamutCss({ [prop]: value })({ theme: coreTheme }); + const actual = ruleFor.get(`${prop}_${value}`); + if (!actual) { + mismatches.push(`${prop}=${value}: no rule for .${prop}_${value}`); + continue; + } + const expectedDecls = Object.entries(expected).map(([p, v]) => [kebab(p), deref(v)]); + const actualDecls = Object.fromEntries( + Object.entries(actual).map(([p, v]) => [p, deref(v)]) + ); + if (expectedDecls.length !== Object.keys(actualDecls).length) { + mismatches.push( + `${prop}=${value}: property count ${expectedDecls.length} vs ${ + Object.keys(actualDecls).length + }` + ); + continue; + } + for (const [property, expectedValue] of expectedDecls) + if (actualDecls[property] !== expectedValue) + mismatches.push( + `${prop}=${value}: ${property} '${expectedValue}' vs '${actualDecls[property]}'` + ); + } +} + +console.log( + `\n2. FIDELITY — emitted rules vs the real css() (normalised: kebab-case + one var() deref, NOT bytes)` +); +if (mismatches.length === 0) + console.log(` ✓ all ${checked} prop×value pairs agree, declaration for declaration`); +else { + console.log(` ✗ ${mismatches.length} of ${checked} mismatch`); + mismatches.slice(0, 10).forEach((m) => console.log(` ${m}`)); + process.exitCode = 1; +} + +/* ── 3. the priority table, cross-checked against Panda's own ──────────────── */ +console.log(`\n3. PRIORITY TABLE — hardcoded longhand subset vs Panda's getPropertyPriority`); +let pandaPriority; +try { + ({ getPropertyPriority: pandaPriority } = await import( + '../panda-consumer-poc/node_modules/@pandacss/shared/dist/index.mjs' + )); +} catch { + pandaPriority = undefined; +} +if (!pandaPriority) { + console.log( + ` ⚠ SKIPPED — no @pandacss/shared reachable. The emitter does not need it;\n` + + ` this assertion does. Rule ORDER is unverified against Panda in this run.` + ); +} else { + const allProps = Object.keys(GAMUT_PROPS); + const disagreements = allProps.filter( + (prop) => propertyPriority(prop) !== pandaPriority(prop) + ); + console.log( + ` compared ${allProps.length} Gamut props (${closedProps.length} closed)` + + `; local table has ${LONGHAND_PROPS.size} entries` + ); + if (disagreements.length === 0) + console.log(` ✓ identical priority for every Gamut prop`); + else { + console.log(` ✗ ${disagreements.length} disagree — rule order would diverge`); + disagreements + .slice(0, 20) + .forEach((prop) => + console.log( + ` ${prop}: local ${propertyPriority(prop)} vs panda ${pandaPriority(prop)}` + ) + ); + process.exitCode = 1; + } +} + +console.log( + `\n${process.exitCode ? '✗ FAIL' : '✓ PASS'} — exit ${process.exitCode ?? 0}\n` +);