poc: Panda under the hood, Gamut's styling API unchanged - #3405
poc: Panda under the hood, Gamut's styling API unchanged#3405dreamwasp wants to merge 5 commits into
Conversation
A minimal, self-contained Vite PoC (spikes/emotion-to-gamut-poc, 16 files,
~500 lines of engine code) showing that today's Emotion-authored Gamut API
works unchanged when `styled` is imported from Gamut instead of Emotion:
- import styled from '@emotion/styled';
+ import { styled } from '@codecademy/gamut-styles';
Nothing else about a call site moves. css/variant/states/styledOptions/Box/
ColorMode/Background were already imported from Gamut.
WHY IT WORKS: variance's css()/variant()/states() already return
(props) => CSSObject and already resolve at runtime. Emotion's only real job
was merging those results into a class. So only that step is replaced
(src/gamut/sheet.ts, ~100 lines); the real @codecademy/variance and the real
Gamut prop config are workspace deps, not forks or reimplementations.
DEMONSTRATED, using code copied VERBATIM out of mono:
1. variant({ prop, base, variants }) + StyleProps + styledOptions
— mono/libs/ui/brand/src/AppBar/AppBarSection.tsx
2. css() + states() composed, nested @media, responsive { _, xs } values,
withComponent
— mono/libs/ui/login-or-register/src/OAuthButtons/elements.tsx
3. system props on Box, through the real prop config and scales
4. ColorMode + Background, including nested light-inside-dark
5. styled.tag`...` template literals with ${props => ...} interpolation
VERIFIED: typecheck clean (and token safety intact — fontSize={12} is
rejected); vite build clean; the built bundle contains ZERO Emotion
(serializeStyles / insertStyles / createCache / @emotion all absent);
rendered in jsdom it produces 27 distinct classes and 3.8kB of CSS with
@media preserved, --color-* variables emitted, and state props kept off the
DOM.
Colour mode reassigns CSS variables rather than using selector-based
conditions, because a descendant selector gets light-inside-dark wrong — the
inner element matches both conditions and source order beats proximity.
ONE HONEST CAVEAT: src/gamut-theme.d.ts still augments @emotion/react. It is
TYPES-ONLY (the bundle check proves nothing runtime touches Emotion). variance
anchors its whole prop type system to Emotion's Theme at exactly two lines —
types/props.ts:1 and types/config.ts:31 — so without the augmentation `keyof
Theme` is never and every `scale: 'colors'` degrades. Adding that one file took
this PoC from 20+ type errors to 1. Every mono app already has this file (18,
plus 1 in platform), so it is not new work; a real migration repoints those two
variance lines at a Gamut-owned registry and the 19 sites change specifier only.
Out of scope on purpose, to keep it readable: static/zero-runtime CSS, SSR,
performance, Global/keyframes/the css prop.
Root package.json gains spikes/* in workspaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nged
Reframes the PoC around the actual question: can PANDA be used without
changing Gamut's external styling API? Yes, and the division of labour falls
out of what's knowable when.
Panda (build time) every design token as a CSS variable, for all 5 themes
x 2 colour modes, + static CSS for Gamut's own
components. Tokens and DS components are a CLOSED set
Gamut controls, so they can be enumerated ahead of time.
Gamut (runtime) css()/variant()/states()/system props at call sites.
Consumer values are OPEN (width="37.5%", an API colour,
a prop-driven ternary) so a build-time extractor cannot
see them.
~100 lines style object -> class name (src/gamut/sheet.ts). This is
all Emotion was doing; replacing only it is why nothing
else changes.
THEME MAPPING: Gamut's theme stores colours as CSS-variable REFERENCES —
coreTheme.colors.primary is literally 'var(--color-primary)'. variance never
handles a hex; it emits the reference and something must DEFINE it. A React
<Variables> component does today, at runtime; Panda now does at build time. A
theme is then just different alias assignments over the same palette:
core->hyper-500, admin->blue-500, lxStudio/percipio->sapphire. Switching theme
or mode is an attribute flip (data-theme / data-color-mode) — both live on the
page.
FIXES THE BLANK-PAGE BUG from the previous commit: the hand-rolled <Variables>
emitted only the semantic aliases, which point at palette variables it never
defined, so everything resolved to nothing. Panda emits both layers.
THREE THINGS FOUND THE HARD WAY, all documented in the README:
- Panda's extractor collides with Gamut's identically-named css(). Pointed at
app source it emitted nonsense (.bg_primary{background:primary},
.pos_left{position:left} from variant() KEYS, .__43{_:43px} from responsive
{ _: 43 }) and inflated the sheet 11kB -> 27kB. Panda now scans only its own
config; importMap is the real fix if a consumer wants extraction.
- Core's palette is NOT a superset. lxStudio/percipio add their own tokens
(--color-sapphire, --color-percipioTextPrimary, …), so emitting only Core's
left 33 variables dangling — which fails SILENTLY as unstyled output. Now
emits the union plus per-theme overrides.
- Dropping preset-panda (its rose/fuchsia/violet palette is dead weight here)
more than halved the output.
VERIFIED: typecheck clean with token safety intact (fontSize={12} rejected);
build clean at 30kB CSS / 3.46kB gzipped; in jsdom 85 variables referenced /
333 defined / 0 MISSING, and 32 classes in the DOM / 0 without a matching rule.
Section 2 of the demo is the proof in one component: StrokeButton's own CSS is
100% Panda static output, extended by the untouched styled(X)(css(…), states(…))
API copied verbatim from mono.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
📬 Published Alpha Packages:
|
|
🚀 Styleguide deploy preview ready! Preview URL: https://6a74e3f192fcd08fc215d3ca--gamut-preview.netlify.app |
…endency Answers "how do we get theme type safety without the Emotion declaration, and do variant()/states() still produce dependable types?" — yes to both, proven. CORRECTION: earlier notes (this repo's docs and PR #3405) said Emotion's type anchor was "exactly two lines." That was wrong. It was FOUR files, and one of them was variance augmenting Emotion itself: types/theme.ts:23 declare module '@emotion/react' { Theme extends BaseTheme } types/props.ts:1 import { Theme } from '@emotion/react' (used :26) types/config.ts:1 import { Theme } from '@emotion/react' (used :31,:64,:66,:79) utils/serializeTokens.ts:1 import { Theme } from '@emotion/react' THE INSIGHT: variance already defined its own theme types (BaseTheme, Breakpoints). It was borrowing Emotion's `Theme` interface purely as the MUTABLE GLOBAL REGISTRY that consumers augment — Emotion did nothing with it; it just happened to be the interface everyone reached for. So variance now owns the slot: export interface Theme extends BaseTheme {} // augment this `grep '@emotion' packages/variance/src` now returns nothing. Consumers change the specifier only — same declaration, different module: - declare module '@emotion/react' { export interface Theme extends CoreTheme {} } + declare module '@codecademy/variance' { export interface Theme extends CoreTheme {} } That is the whole migration for the 19 real augmentation sites (18 mono, 1 platform). TRANSITIONAL: gamut-styles/src/typings/theme.d.ts now declares BOTH registries. The Emotion one stays only because gamut-styles still uses Emotion's ThemeProvider/useTheme at runtime, and those are typed by Emotion's Theme. It is deletable the moment that provider is replaced — the PoC drops it entirely. Both point at the same CoreTheme so they cannot drift. TYPECHECK IMPACT: variance 0 errors, gamut-styles 0 errors, gamut 268 — and gamut is 268 on pristine main too, verified by stashing. The change is typecheck-neutral across the repo. DO variant()/states() STILL PRODUCE DEPENDABLE TYPES? Yes, and they are structurally immune to this swap: they derive prop types from the CONFIG OBJECT passed to them, not from the theme. `Theme` only appears in the `theme?:` member. StyleProps<typeof positionVariants> = VariantProps<"position", false|"left"|"right"|"center"> & { theme?: Theme } StyleProps<typeof toggleStates> = Partial<Record<"compact"|"isFancy", boolean>> & { theme?: Theme } Pinned by spikes/emotion-to-gamut-poc/src/type-safety.test-d.tsx: 9 negative cases where `yarn typecheck` IS the assertion. It fails in both directions — if safety silently regressed, tsc reports "Unused '@ts-expect-error' directive". Verified it really fails by deliberately breaking one case. Still rejected: css({ fontSize: 12 }), css({ p: 5 }), css({ bg: 'chartreuse' }), <Box p={5} />, { position: 'middle' }, { variant: 'left' }, { isFancy: 'yes' }, { hasBorder: true }. The PoC now has zero Emotion in runtime AND types; its bundle contains no serializeStyles / insertStyles / createCache / @emotion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run-many --target=build --all |
❌ Failed | 42s | View ↗ |
💡 Dealing with memory or CPU issues? See memory and CPU details with the resource usage add-on ↗.
☁️ Nx Cloud last updated this comment at 2026-08-07 20:16:44 UTC
Closes the proof that NOTHING in Gamut requires Emotion. Everything else was
already covered; these were the two remaining APIs with no demonstrated
replacement.
Both fall out of the serializer that already exists — they just skip the
class-scoping step:
injectGlobal(styles) top-level keys are REAL selectors instead of being
scoped to a generated class. Backs a <Global styles={…}>
component with the same call shape Gamut's own globals
(Reboot, Typography, Variables) already author against.
Replaces 10 references in packages/*.
keyframes(frames) serialises each step, hashes the body into a name, and
injects `@keyframes <name>{…}`. Returns the NAME, so it
drops into either shape Emotion supports:
`animationName: spin` or `animation: `${spin} 1s``.
Replaces 5 references in packages/*.
VERIFIED (jsdom): <Global> emits body{margin:0} UNSCOPED — no class prefix —
plus the box-sizing reset; keyframes() emits a complete
@Keyframes gmt-kf-10qu4pj{0%, 100%{opacity:1}50%{opacity:0.35}} and the
animating element references it by name. Still 0 missing CSS variables and 0
classes without a matching rule.
(My first assertion on the keyframe steps reported false — a lazy regex stopping
at the first `}}` and capturing only step one. The CSS was correct; the check
wasn't. Replaced with brace-matching.)
README now carries the full inventory: every Emotion API Gamut uses, its site
count, and its replacement. Two entries worth calling out:
- createCache / CacheProvider / Options / StylisPlugin (10 sites) are replaced
by NOTHING — they aren't needed. Class names are content-hashed and
deterministic, so there is no per-request cache to thread.
- @emotion/jest `matchers` (4 test sites) is the one genuinely unreplaced item;
it asserts on Emotion-generated CSS and needs an equivalent matcher against
the Gamut sheet. Straightforward, not built here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
…s quadratic
Appending a text node to a <style> element invalidates its parsed stylesheet,
so the browser reparses everything already in it. Inserting rule n therefore
cost O(n) and the whole sequence O(n²): measured in headless Chromium,
1,169ms for 2,000 rules versus 1.8ms with insertRule.
Two changes, both needed:
- One insertRule call per rule. cssText already built one rule per serialized
block, so ruleTexts() now returns them separately.
- Rejected rules are quarantined into their own <style> element. The old
`catch { appendChild(createTextNode(...)) }` put the shared sheet back on the
quadratic curve permanently and silently on a single throw, so fixing the
write while keeping that fallback would have left the landmine armed.
Deliberately no @layer wrap, unlike panda-styling-poc's engine. A layer block
makes a multi-rule payload legal in one insertRule call, but Emotion appends
unlayered CSS and unlayered rules beat every layered rule — so a layer would
silently change precedence against Panda's own @layer output. It also measured
slower on every metric (1.10x/1.92x/1.46x/1.24x vs 1.08x/1.51x/1.01x/0.99x)
and was the cause of a layout regression the harness had flagged.
Engine is now at parity with Emotion on mount (1.08x), style recalculation
(1.01x) and CSS size (0.99x). Re-render remains 1.51x, which is this branch
having no tier-2 memoisation.
Evidence: ~/code/base camp/reboot/injector-browser-poc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

What this proves
Panda CSS can generate Gamut's design tokens and its components' CSS, while every existing call site keeps working exactly as written — with Emotion gone entirely, runtime and types.
A consumer changes one import plus one type-augmentation specifier.
Not for merge as-is — it's a decision aid. But note it now does touch
packages/(see §8): 5 files invarianceand 1 ingamut-styles— types-only apart fromsheet.ts, which is spike code.1. What a call site changes
css,variant,states,styledOptions,Box,ColorMode,Backgroundwere already imported from Gamut.2. Who does what, and why
variance, runtime)css()/variant()/states()/ system props at call siteswidth="37.5%", a colour from an API, a prop-driven ternary. A build-time extractor can't see themsrc/gamut/sheet.ts)The split follows from what's knowable when.
varianceisn't forked or reimplemented — the real@codecademy/varianceand real Gamut prop config are workspace deps.3. Theme mapping
Gamut's theme stores colours as CSS-variable references —
coreTheme.colors.primaryis literally'var(--color-primary)'.variancenever touches a hex; it emits the reference and something must define it. A React<Variables>component does today at runtime; Panda now does at build time.A theme is then just different alias assignments over one palette:
--color-primaryvar(--color-hyper-500)var(--color-blue-500)var(--color-hyper-500)var(--color-sapphire)Switching theme or mode is an attribute flip (
data-theme/data-color-mode) — no rebuild, no style re-render. Both switchers are live on the page.Colour mode reassigns variables rather than using selector conditions, which is what makes nesting correct: it resolves from the nearest ancestor. A descendant selector (
[data-color-mode=dark] &) gets light-inside-dark wrong — the inner element matches both conditions and source order beats proximity.4. What's demonstrated
variant({ prop, base, variants })+StyleProps+styledOptionsmono/.../AppBar/AppBarSection.tsxStrokeButtonextended by the unchangedstyled(X)(css(…), states(…))APImono/.../OAuthButtons/elements.tsx<Box p={16} bg="primary" />ColorMode/Background, incl. nested light-inside-darkstyled.span`…`with${props => …}Section 2 is the proof in one component:
StrokeButton's CSS is 100% Panda static output (.gmt-stroke-button--variant_primary), extended by the untouched Emotion-era API.5. Verified mechanically
typecheckclean — includingsrc/type-safety.test-d.tsx(see §8)buildclean — 30kB CSS, 3.46kB gzippedstaticCssgrep '@emotion' packages/variance/src→ nothing. Bundle contains noserializeStyles/insertStyles/createCache/@emotion6. Three things found the hard way
Panda's extractor collides with Gamut's
css(). Both are namedcss. Pointed at app source, Panda "extracts" Gamut's calls into nonsense —.bg_primary { background: primary },.pos_left { position: left }(fromvariant()keys),.__43 { _: 43px }(from responsive{ _: 43 }). Renders fine, but silently inflated the sheet 11kB → 27kB. Panda now scans only its own config;importMapis the real fix if a consumer wants extraction.Core's palette is not a superset. lxStudio and percipio add their own tokens (
--color-sapphire,--color-percipioTextPrimary,--color-lxStudioSuccess). Emitting only Core's left 33 variables dangling — which fails silently as unstyled output. Fix: emit the union plus per-theme overrides.Dropping
preset-pandamore than halved the output. Its default rose/fuchsia/violet palette is dead weight for a design system with its own tokens.7. Theme type safety with no Emotion — including in the types
varianceaugmenting Emotion itself:The insight:
variancealready defined its own theme types (BaseTheme,Breakpoints). It was borrowing Emotion'sThemeinterface purely as the mutable global registry consumers augment — Emotion did nothing with it; it just happened to be the interface everyone reached for. So variance now owns the slot:Consumers change the specifier only:
That's the whole migration for the 19 real augmentation sites (18 mono, 1 platform).
Typecheck impact:
variance0 errors,gamut-styles0 errors,gamut268 — andgamutis 268 on pristinemaintoo (verified by stashing). Typecheck-neutral across the repo.One transitional wart:
gamut-styles/src/typings/theme.d.tsnow declares both registries. The Emotion one stays only becausegamut-stylesstill uses Emotion'sThemeProvider/useThemeat runtime, and those are typed by Emotion'sTheme. It's deletable the moment that provider is replaced — the PoC drops it entirely. Both point at the sameCoreTheme, so they can't drift.8. Do
variant()/states()still produce dependable types?Yes — and they're structurally immune to the swap. They derive prop types from the config object you pass them, not from the theme.
Themeonly appears in thetheme?:member:So the prop is named after
prop, its values are the exact literal union of declared keys, and states are exactly the declared booleans. Only scale-valued props (p,bg,fontSize) depend onkeyof Theme.Pinned by
src/type-safety.test-d.tsx— 9 negative cases whereyarn typecheckis the assertion. It fails in both directions: if safety silently regressed, tsc reportsUnused '@ts-expect-error' directive. I confirmed it really fails by deliberately breaking one case.All still rejected:
9. Measured in a real browser
Earlier revisions listed performance as out of scope. It is now measured in headless Chromium with CDP tracing (
~/code/base camp/reboot/injector-browser-poc, which imports this branch's engine by path rather than copying it). 200 cards ≈ 1,400 styled nodes, 7 runs/arm, medians, first discarded.Ratio to Emotion, high cardinality: 1.08x mount, 1.51x re-render, 1.01x recalc, 0.99x CSS bytes.
Parity on mount, style recalculation and CSS size. The remaining 1.51x on re-render is this branch having no tier-2 memoisation — it resolves every style function on every render, which is a separate optimisation and is deliberately out of scope here (§11).
Note the ratios are diluted by React: ~33ms of every mount number is rendering 1,400 nodes, which both arms pay. The styling delta is a larger fraction of the styling work than these ratios suggest.
The defect this found, and the fix
The first run measured 1.58x mount / 2.39x re-render. Cause:
sheet.tsappended a text node to a<style>element per rule.A
<style>element has two representations — its child DOM nodes, and the parsedCSSStyleSheetthe engine consults. Changing the children invalidates the parsed sheet, so the browser reparses the entire accumulated text. Appending rule n costs O(n); the sequence is O(n²).Seven insertion strategies, same rules, live CSSOM:
appendChild(createTextNode)— the old codeCSSStyleSheet.replaceSyncper rule<style>per rule (Emotion's dev path)insertRuleinsertRule+@layerinsertRuletextContent, batched oncereplaceSyncis the diagnostic: a constructableCSSStyleSheethas no<style>element and no document-level invalidation, yet per-rulereplaceSyncis equally quadratic. The cost is reparsing accumulated CSS text, not DOM mutation.Worth noting Emotion avoids this in both modes —
insertRulein production, and a fresh<style>per rule in development so each reparse covers one rule. The old code had neither escape: text nodes into a single shared sheet.Two changes, both required:
insertRulecall per rule.cssTextalready builds one rule per serialized block, soruleTexts()returns them separately and each goes in on its own call.catch { el.appendChild(document.createTextNode(payload)) }— so one throw (an unsupported at-rule, a rejected vendor prefix) put that sheet back on the quadratic curve permanently and silently. Fixing the write while keeping thatcatchwould have left the landmine armed. A rejected rule is now quarantined into its own<style>: it still applies, it can't slow anything else down, and in dev it warns.Deliberately no
@layerpanda-styling-poc's engine wraps each payload in@layer gamut.consumer{…}, which is what lets a multi-rule payload throughinsertRulein a single call. Not done here, for two reasons:@layeroutput.So
panda-styling-pochas the right primitive and the wrong wrapper.Still unmeasured
Hydration — the harness is client-side
createRootonly, and all six mono apps are pages-router SSR. Chromium only, one machine, synthetic tree.10. How much runtime CSS is actually needed
The runtime tier this PR introduces is smaller than the raw call-site counts suggest. Measured across mono (read-only AST scans,
codemod-poc/runtime-need*.mjs):styled()/css()interpolations94.5% of system-prop attributes pass a plain literal. The useful axis is closed-vs-open, not static-vs-dynamic: a computed value on a theme-scaled prop is still prebuildable, because runtime only needs to select a class rather than manufacture one. And 82.8% of style-function interpolations turn out to be
variant()/states()written as control flow — boolean guards, ternaries over theme reads,switchreturning fixed strings.This refines the "47 of 126 props have a closed value space" figure used in earlier docs. That describes the prop surface, not the call-site distribution, and it counted ~28 finite CSS-keyword props (
display,position,flexDirection,justifyContent, …) as open when their keyword sets are enumerable.The consequence for reviewing this PR: for ~99% of sites the injector is a CSS-size optimisation; for the ~1% it is a correctness requirement. Nothing else can produce
gridTemplateColumns={`repeat(${tickCount * 2}, 1fr)`}. Both justify the hybrid, but only the second can't be designed away — which is why the runtime tier stays.Numbers are upper bounds (syntactic, not type-checked). mono only;
frontis.jsxso the globs miss it.11. Out of scope
SSR, hydration measurement, migrating Gamut's ~109 internal
styledsites to recipes, replacing Emotion's runtimeThemeProvider/Global/keyframes/cssprop. Explored oncass-GMT-1715(GMT-1715). Performance is no longer out of scope — see §9.Reviewing this
Verification here is jsdom + CSS analysis, which is what caught both dangling-variable bugs, but it can't confirm it looks right —
yarn devcloses that gap. Browser performance is covered separately byinjector-browser-poc(§9); note that spike points at this branch's engine, so re-running it after thesheet.tsfix is the check that the fix worked.Please don't benchmark this branch as-is. The unfixed quadratic write makes the approach look ~1.6x worse than it is.
🤖 Generated with Claude Code