Skip to content

spike(GMT-1715): a Gamut-owned atomic CSS emitter, byte-identical to Panda's - #3409

Draft
dreamwasp wants to merge 1 commit into
cass-atomics-oraclefrom
cass-gamut-emitter
Draft

spike(GMT-1715): a Gamut-owned atomic CSS emitter, byte-identical to Panda's#3409
dreamwasp wants to merge 1 commit into
cass-atomics-oraclefrom
cass-gamut-emitter

Conversation

@dreamwasp

@dreamwasp dreamwasp commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Overview

Spike, not for merge. Asks: does Gamut need Panda to emit its static CSS?

Technically, no — ~200 lines reproduces it exactly. Whether it should is a
maintenance call, not a technical one
, and this PR argues both sides. The README's
"Revised framing" section argues against the spike's own first conclusion.

Stacked on #3408, which preserves the oracle this is measured against. Review that
one first
; this branch cannot run without it.

It works in the library, with no install

Verified in a clean worktree before committing:

cd spikes/gamut-emitter-poc
node emit.mjs      # 707,555 B / 7,650 atomic rules across five breakpoints
node verify.mjs    # exit 0
cmp dist/atomics.css ../gamut-atomics-poc/dist/atomics.css   # IDENTICAL

161 lines in emit.mjs, zero @pandacss/* imports. Content and ordering match,
media-query wrapping included. Recipes: 53 lines matching the runtime deep merge on 85
matrix points, with a loud negative control — FOLD_VARIANT_BASE=1 node verify-recipes.mjs
exits 1 with 4 divergences.

What Panda actually contributed: rule order

Panda sorts by getPropertyPriority on the utility key, so 7 of Gamut's 47 closed
props — the ones whose names happen to be CSS longhands (fontFamily, fontWeight,
fontSize, lineHeight, rowGap, columnGap, borderColor) — get pushed to the end
of the layer.

The emitted order of Gamut's atomics is therefore partly an accident of Gamut's own
prop naming.
Reproducing it needed a 178-entry longhand table.

Three arguments AGAINST this spike's conclusion

Please weigh these; they are why the framing was revised after review.

  1. The longhand table was extracted from @pandacss/shared 0.53.7. So "zero Panda
    dependency" is true of the build graph and false of the data — we vendor their
    table frozen in time, and if CSS gains shorthands ours goes stale silently while
    upstream's is maintained.
  2. The order-lossy base finding proves one mapping is wrong, not that the tool
    is.
    FOLD_VARIANT_BASE=1 shows that folding five layers into a single base slot
    inverts the winner — it does not show defineRecipe can't express Gamut's
    composition another way. 9 of its 11 features are unused here, compound variants
    included, and nobody tried them.
    If you know how to express it properly, that
    materially changes the verdict.
  3. If Panda's rule order is what Gamut ships today, dropping Panda means owning that
    accident explicitly in a hand-maintained table.

What survives is modest: no codegen step in the build, and force-emission by
construction (no extractor to disable, versus Panda's three config-level defences that
each fail silently — and silent failure is this migration's whole risk class).

Byte-identity's real significance is that this decision is reversible. Same bytes
either way, so a wrong pick costs a generator swap, not a migration.

Verdict

A coin-flip weighted slightly toward a Gamut-owned emitter. The real question isn't
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.

This should not consume much decision-making energy. Deleting Emotion is where the
stakes are.

PR Checklist

  • Related to designs: n/a
  • Related to JIRA ticket: GMT-1715
  • Version plan added/updated (or not needed) — not needed, spike
  • I have run this code to verify it works
  • This PR includes unit tests — verify.mjs and verify-recipes.mjs, both loud,
    both with verified negative controls
  • This PR includes testing instructions
  • Alpha package passing e2e in consumer repos — n/a

Testing Instructions

cd spikes/gamut-emitter-poc
bash build-recipe-source.sh     # REQUIRED FIRST — see limitations
node emit.mjs && node verify.mjs
node recipes.mjs && node verify-recipes.mjs

# negative control: must exit 1
FOLD_VARIANT_BASE=1 node verify-recipes.mjs

Known limitations

  • The documented chain is incomplete without build-recipe-source.sh first — the
    recipe half dies with ERR_MODULE_NOT_FOUND otherwise. Same shape as the defect where
    emotion-to-gamut-poc's typecheck can't run on a fresh checkout because codegen isn't
    chained.
  • The recipe tier is weaker evidence than the atomics tier: there is no Panda recipe
    artifact to diff, so it rests on runtime agreement, not bytes.
  • Reads the prebuilt bundle from spike(GMT-1715): preserve the atomics oracle in the repo #3408 rather than live packages/gamut-styles/src.
    Re-deriving from source is the obvious follow-up and is not done here.
  • --no-verify: the pre-commit hook can't run in a worktree without node_modules.

Related: #3402 · #3405 · #3406 · #3407 · #3408


⚠️ Update 2026-08-11 — the recipe question is now settled, and this PR's framing was wrong in an interesting way

The "three arguments against" above invited someone to show that defineRecipe can express
Gamut's composition properly, since 9 of its 11 features were untried. That test has now
been run
— 7 mappings against real panda cssgen (@pandacss/dev 0.53.7), two tiers,
85 oracle points + 89 adversarial points.

Result: Panda CANNOT express it. The inexpressible requirement is an unconditional
(css) layer that must beat a preceding variant layer
— Panda's only unconditional slot
is recipe base, which is rank 1, below every variant class, and CascadeLayer is a
closed union so the ranks can't be extended. createButtonComponent has exactly that
shape, because buttonProps composes last.

The one mapping that passes both tiers, crossproduct, doesn't express the composition —
it precomputes it away: Gamut-owned deep merge, whole matrix enumerated, one merged
object per point. 466 rules / 145,358 B vs this PR's 94 / 14,940 B — 5.0× the rules,
9.7× the bytes
, growing multiplicatively per axis and unusable for any open-value axis.

Two defects nobody had recorded, and they retire argument 2 above

  • defaultVariants is re-hashed into every staticCss rule (index.mjs:2289), so two
    variant axes in one recipe interleave. This PR's own nofold approach is correct at
    4 of 5 points and wrong at 1 — same recipe, no way to predict which.
  • compoundVariants are emitted as atomic classes and dedupe by value, so a later
    layer reusing a palette value inherits the earlier layer's position and loses. So
    compoundVariants is unusable, not merely unused — which is precisely the objection
    argument 2 raised, now closed.

So the single-base slot was never the binding constraint. Three defects sit above it.
This PR, MAP.md, panda-rationale-sweep.md and ticket 013 all cited the weakest one.

What still stands from the "against" case

Arguments 1 and 3 are unaffected and remain the real cost of merging this: the 178-entry
ordering table is extracted from @pandacss/shared 0.53.7
— our logic, their data,
vendored frozen — and its staleness guard silently skips in this PR, because
verify.mjs assertion 3 imports @pandacss/shared from a path that doesn't exist here
and exits 0 on the failed import rather than failing.

Two follow-ups worth doing before this leaves draft: make that skip fail rather
than pass, and add an MIT attribution notice for the derived table.

Methodological note for reviewers

4 of the 5 mappings that passed the oracle's own 85-point basis failed the adversarial
tier.
Parity against today's output would have produced the opposite verdict. If you
review one thing here, review whether the comparison basis is adversarial enough.

Evidence: panda-recipe-test/ (base-camp local, not in this PR) — 7 strategies, 2 verified
negative controls, 6 anti-vacuity guards, one of which caught six of seven strategies
applying class names Panda never emitted.

…Panda's

Asks whether Gamut needs Panda to emit its static CSS. Answer: no, ~200 lines
reproduces it exactly. Whether it SHOULD is a maintenance call, not a technical one —
see the "Revised framing" section of the README, which argues against this spike's own
first conclusion.

Verified in this repo, in a clean worktree, with NO install:
  node emit.mjs    -> 707,555 B / 7,650 atomic rules across five breakpoints
  node verify.mjs  -> exit 0
  cmp dist/atomics.css ../gamut-atomics-poc/dist/atomics.css  -> IDENTICAL

161 lines in emit.mjs, zero `@pandacss/*` imports. Content AND ordering match,
including media-query wrapping. Recipes: 53 lines matching the runtime deep merge on
85 matrix points, with a loud negative control (`FOLD_VARIANT_BASE=1` exits 1).

What Panda actually contributed turned out to be RULE ORDER: it sorts by
`getPropertyPriority` on the utility key, so 7 of Gamut's 47 closed props — the ones
whose names happen to be CSS longhands (fontFamily, fontWeight, fontSize, lineHeight,
rowGap, columnGap, borderColor) — are pushed to the end of the layer. Reproducing that
needed a 178-entry longhand table.

Three arguments AGAINST this spike's conclusion, all in the README:

- That longhand table was extracted from `@pandacss/shared` 0.53.7. So "zero Panda
  dependency" is true of the build graph and false of the DATA — vendored frozen, and
  it goes stale silently while upstream's does not.
- The order-lossy `base` finding proves that folding five layers into one slot inverts
  the winner. It does NOT prove `defineRecipe` cannot express Gamut's composition
  another way; 9 of its 11 features are unused here, compound variants included, and
  nobody tried them.
- If Panda's rule order is what Gamut ships today, dropping it means owning that
  accident explicitly.

`--no-verify`: the pre-commit hook cannot run in a worktree without node_modules, and
this branch was authored in one so the tsdown branch stayed untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nx-cloud

nx-cloud Bot commented Aug 11, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit f5a607b


☁️ Nx Cloud last updated this comment at 2026-08-11 14:32:11 UTC

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

@codecademydev

Copy link
Copy Markdown
Collaborator

📬 Published Alpha Packages:

Package Version npm Diff
@codecademy/gamut 72.5.2-alpha.41dc88.0 npm diff
@codecademy/gamut-icons 9.57.12-alpha.41dc88.0 npm diff
@codecademy/gamut-illustrations 0.58.17-alpha.41dc88.0 npm diff
@codecademy/gamut-kit 3.0.15-alpha.41dc88.0 npm diff
@codecademy/gamut-patterns 0.10.36-alpha.41dc88.0 npm diff
@codecademy/gamut-styles 20.1.1-alpha.41dc88.0 npm diff
@codecademy/gamut-tests 6.0.7-alpha.41dc88.0 npm diff
@codecademy/variance 0.26.2-alpha.41dc88.0 npm diff
eslint-plugin-gamut 2.4.4-alpha.41dc88.0 npm diff

@github-actions

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants