spike(GMT-1715): Panda CSS styling proof of concept - #3402
Conversation
…me/colorMode switching Self-contained spike (spikes/panda-styling-poc, isolated from workspaces) exploring Panda CSS as an Emotion replacement. Grounded in the real Button + Storybook switchers. Findings (see spike README): - Type-safe tokens: native + stronger; bad token/variant/size fail tsc. - styled JSX factory works and is re-exported from the Gamut facade (not Emotion). - GamutProvider/ColorMode/Background port and mostly shrink (no cache/ThemeProvider/Global). - Runtime theme (core/admin) + colorMode (light/dark) switching works via data-panda-theme + data-color-mode attribute flips — matches the Storybook switchers; combined theme×mode combos pre-generated statically (zero runtime). - Consumer surface beyond `styled`: css-prop, JS-resolved colors (charts), theme customization, test matchers — all codemoddable/documentable. Not proven: Rspack + Module Federation (spike #2), runtime-dynamic non-token styles, chart color resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
View your CI Pipeline Execution ↗ for commit 4b570a6 ☁️ Nx Cloud last updated this comment at |
|
|
🚀 Styleguide deploy preview ready! Preview URL: https://6a6ba2c1ecaaba73a0436560--gamut-preview.netlify.app |
…page; Background static color-mode context - Register spikes/* as a yarn workspace; add nx project.json (codegen/cssgen/typecheck/build/dev). Run via `yarn nx run panda-styling-poc:*`. - Add prop-based authoring (Box + system props + css prop) so no `className` is needed; add authoring-comparison.tsx mapping today's css/variant/states idioms. - Fix Background to own STATIC color-mode context (contrast-selected mode + background-current) per the ColorMode/theming skills; ColorMode = ambient mode. - Add an example page (src/App.tsx): Button recipe variant×size, variant()/states() demos, theme+colorMode switchers, static <Background bg="navy">. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…yledDynamic) Makes the external styling API compatible under zero-runtime by covering the two residuals the mono/platform usage survey found: - getColorValue(alias, mode, theme): reads a resolved color VALUE in JS for non-CSS consumers (charts/canvas). Derived from a new single source of truth (src/tokens.source.ts) that ALSO builds the Panda CSS vars, so it can't drift. - styledDynamic(Tag)(props => styles): keeps the styled(fn) authoring shape for genuinely dynamic values; applies computed styles inline (that component only), strips $-transient props from the DOM. panda.config now derives tokens/themes/recipe from tokens.source. Example page adds a prop-driven <Meter> (styledDynamic) and an SVG chart (getColorValue), both theme+mode aware. typecheck + build green via nx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt runtime-style cases
- staticCss now emits all button variants (recipes.button ['*']) so dynamically-
selected variants (variant={x} in a map) ship — a design-system requirement
Panda's usage scanner otherwise tree-shakes away.
- README: table of when runtime styles are genuinely needed (per Panda docs) and
the sanctioned mechanism for each (inline CSS var + token(), staticCss,
getColorValue, variant/states, injectTheme), plus the CSP caveat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…line CSS var)
Adds a swatch demo: a static class reads `background: var(--swatch)`; only the
single `--swatch` custom property is set inline, pointed at a TOKEN var via
token.var(`colors.${alias}`). Result stays theme/mode-aware (recolors on
switcher flip), unlike getColorValue's raw hex. Comment notes the CSP trade-off
(inline attr still hits style-src; finite sets should use variant()).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panda tokens are now derived directly from @codecademy/gamut-styles (workspace dep) instead of hand-made values: - colors from corePalette; semantic light/dark from coreTheme/adminTheme .modes - real spacing / fontSize / fontFamily / fontWeight / lineHeight / borderRadii scales - getColorValue reads the same coreTheme/adminTheme .modes + corePalette (no drift) - real Apercu/Suisse web fonts via src/fonts.css (Codecademy CDN); page uses fontFamily base - Background bg now takes real palette tokens (navy-800, etc.) - removed the hand-made src/tokens.source.ts Generated CSS carries production values (hyper-500 #3A10E5, navy-800 #10162F, text-accent/primary-inverse/border-tertiary aliases). typecheck + build green via nx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… invented sizes scale Button height/minWidth aren't Gamut design tokens — author them as raw values with Panda's `[value]` escape hatch (e.g. height: '[40px]') inside the recipe, instead of inventing a sizes token scale. strictTokens still guards colors/ spacing/fontSize (which ARE tokens). Also fixes swatch minWidth '56' (which had started resolving to Panda's default --sizes-56 = 14rem) to '[56px]'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vider/ColorMode Prove the external component API doesn't change: - Add FillButton/StrokeButton/TextButton/CTAButton/IconButton with the real prop surface (variant, size, icon + iconPosition, href, disabled, system props), backed by per-atom Panda recipes + a polymorphic ButtonBase (href → <a>). IconButton keeps icon/aria-label/tip (ToolTip wrapper omitted in spike). - Separate provider vs mode per mono's idiom: GamutProvider selects the theme (data-panda-theme), a single <ColorMode> sets ambient mode, <Background> handles static themed surfaces. ColorMode used once. - Example page: Button-atoms showcase (incl. icon + anchor href) + FillButton grid. typecheck + build green; all five gmt-*-button recipes emit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Instead of importing the real Emotion ToolTip / @Codecademy/gamut, rebuild it in Panda (src/gamut/ToolTip.tsx) — same API (info/placement/alignment/closeOnClick/ id + trigger child), authored with Panda css, hover/focus + aria-describedby + role=tooltip. IconButton now wraps it and takes tip + tipProps, matching gamut's prop surface. Keeps the spike self-contained and Emotion-free. Simplified to top-center placement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…de examples Add spikes/panda-styling-poc/BREAKING-CHANGES.md: 8 consumer-facing breaking changes (styled import, styled(css/variant/states) → recipe/props, string token values + sizes gotcha, css prop, JS color-value reads, provider + static sheet, theme customization, test matchers) with before/after examples, severity, codemod notes, and mono/platform impact — plus what does NOT break (~95%). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ng detail Add §2a — Gamut's OWN styling migration, from a survey of packages/gamut/src (109 styled, 69 variant, 42 states, 87 css, 97 system.*, 110 StyleProps, 7 template-literal styled, 3 keyframes, 0 css-prop). Per-pattern Panda mapping + effort table with real file examples, and before/after for the hard cases: variant (Badge), keyframes (Shimmer), and the dynamic prop-interpolation + useCurrentMode gradient (Shimmer). Net: ~90% mechanical; the 🟥 work is the template-literal styled components + dynamic/useCurrentMode styles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aries Minimal MF host+remote (both using a Panda-built Gamut Button) validating whether Module Federation affects the styling engine. Host renders variant=primary; remote exposes a Widget using variant=danger (host never renders it). Both build via rspack ModuleFederationPlugin. Finding: MF doesn't block a Panda swap. It adds two satisfiable requirements — (1) Gamut ships a COMPLETE static sheet (staticCss all variants), imported once by the host; proven: host CSS emits gmt-button--variant_danger though host only uses primary. (2) Gamut a shared singleton across host+remotes (version skew is the risk). Panda's deterministic content-based class names make it SAFER across MF than per-build-hashed CSS Modules. Emotion (today) self-styles at runtime but risks multiple caches across remotes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GMT-1715 spike summary — can Gamut move Emotion → Panda for 1.0?Short answer: yes, and no blocker was found. Every make-or-break question is validated below. The remaining input is a decision (consumers' RSC roadmap + appetite to migrate in the 1.0 window), not an unknown. Two spikes on this branch (isolated yarn/nx workspaces under
What's validated ✅
The real costs (documented, not blockers)
|
|
📬 Published Alpha Packages:
|
The 7 gaps found auditing the spike's Fill/Stroke/Text/Icon/CTA buttons against the real Gamut source, per reboot/button-api-parity.md: - focus-ring `::before` pseudo-element, colored per variant, mirroring ButtonSelectors.OUTLINE / OUTLINE_FOCUS_VISIBLE - `aria-disabled` now styled via an overridden `disabled` condition - CTAButton's hard drop-shadow treatment (grows on hover, flattens on active/disabled) plus its `variant` prop - `aria-hidden` on decorative icons - corrected IconButton `large` glyph size - narrowed the `size` prop so it stops leaking Panda's responsive type Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rvives
Answers the gap reboot/panda-via-gamut-option-b.md left open: whether
existing Emotion-authored call sites can keep working while Emotion leaves
the tree entirely. They can.
`styled(C)(css(...), variant(...), states(...))` is preserved exactly,
because `variance` already resolves styles at runtime and Emotion's only
real job was merging the results and injecting a class. `variance` is
reused UNTOUCHED; only that last layer is replaced.
src/gamut/engine/
- props.ts css/variant/states/system.* rebuilt on the REAL Gamut prop
config, zero Emotion imports. Also attaches `propNames` so
`styled` filters state props off the DOM automatically —
`styledOptions([...])` lists no longer need porting.
- sheet.ts the injector: deterministic FNV-1a hashing (SSR can't
mismatch), @layer gamut.consumer for override precedence, the
focusVisible stylis-plugin behaviour reimplemented, CSP nonce
support, extractStyles() replacing extractCriticalToChunks.
- styled.tsx composed shape, withComponent (379 uses in mono), and a small
CSS parser so styled.tag`...` template literals work too.
- theme.tsx augmentable theme registry replacing the
`declare module '@emotion/react'` augmentation.
src/proof/
- parity.tsx 21 checks against a call site copied VERBATIM from mono's
OAuthButtons/elements.tsx, plus every case static extraction
provably cannot reach (prop functions, theme.x access,
computed enum keys, ternaries, template literals). 21/21 pass.
The "zero Emotion" check reads its own bundle rather than
asserting.
- measure.ts prebuilt atomics vs runtime injector, sized off the real prop
config: only 47 of 126 props have a closed value space, so the
hybrid is forced, not chosen. Base atomics 9.2kB gzip;
+breakpoints 58.4kB; injector ~86B/class, crossing over at
~6,000 distinct combinations.
Headline: codemod-feasibility.md's MANUAL (420) and UNSUPPORTED (234)
buckets are artifacts of static extraction, not of the call sites. A
runtime engine evaluates all of them, taking mono from ~654 hand-ported
sites to 0.
Findings: ~/code/base camp/reboot/panda-via-gamut-option-a.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ute PoC
Three additions, closing three open items from
reboot/panda-via-gamut-option-a.md.
1. spikes/styling-perf-poc — APPROXIMATE perf comparison (labelled as such
throughout). Both arms share the SAME real variance style functions, so
the delta isolates the injection layer. Emotion vs the engine:
SSR within 1-4% in both cold and warm phases, across a realistic
low-cardinality tree AND an adversarial high-cardinality one; the engine
emits 19-29% LESS CSS because it flattens composed styles into one class
per component instead of several.
Two fairness bugs found and fixed while building it: the engine arm was
doing less work than Emotion (real gamut-styles states/variant don't
expose propNames, so nothing was filtered off the DOM), and the first
high-cardinality tree used strides that made all values a function of
index % 11 — 11 distinct styles wearing a 200-style costume.
Emotion's SSR CSS is NOT in cache.inserted (values are `true`) — it is
inlined as <style data-emotion> tags in the markup. Harness extracts
from there and strips the tags before comparing tree sizes.
2. spikes/panda-mf-poc — extended from the static path to the RUNTIME
engine. src/federation/verify.cjs loads two independently bundled engine
copies with one shared React, modelling MF that shares react but not
gamut (front's real config). 8/8 checks:
- deterministic hashing => identical class names across copies, so
duplicated CSS is idempotent, never conflicting
- React context does NOT cross the boundary: a remote with its own copy
cannot see the host's ThemeProvider and silently falls back
=> Gamut MUST be a shared singleton. Requirement, not nicety.
- in a real DOM the copies converge on one <style data-gamut> tag
- jsdom rejects @layer, so insertRule throws and the appendChild
fallback runs — which is the path mono's jest suite will take
The rspack build now shares '@gamut-engine' as a singleton and renders
engine components in both host and remote.
3. spikes/panda-styling-poc/src/precompute — the answer to "can internal
authoring stay as-is while Panda runs underneath". It can, by EXECUTING
the authoring rather than parsing it. authoring.ts keeps every construct
that defeats a parse-based codemod (templateVariants, computed enum keys,
transitionConcat() calls, ternaries); generate.ts runs it, enumerates the
variant/state space, and emits defineRecipe config that Panda turns into
real static CSS. Verified: .gmt-gen-fill-button--variant_primary and
friends appear in gamut-static.css.
Enumeration is only possible because variance closes over its config, so
props.ts now exposes variantMeta/stateMeta. declValue is shared with the
runtime injector so the static and runtime paths cannot drift.
Internal authoring changes required: NONE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "can system props stay runtime while consumers still get a
zero-runtime path?" The useful distinction turned out not to be runtime vs
zero-runtime but PER-INSTANCE vs PER-DEFINITION:
tier 1 inline system props resolve per render, per instance
tier 2 styled(...) at module scope resolve ONCE per theme+variant combo
tier 3 tier 2 + Panda in the app resolve at build time, zero JS
Tiers 1 and 2 need zero build configuration; tier 3 is opt-in per app via
Option B. Worked examples of when to reach for each are in the spike README.
Implementation:
- css() is now marked `staticStyle` (reads only the theme), so `styled` can
tell which style fns are predictable. Every fn predictable => the class is
a pure function of (theme, declared prop values) and gets memoised. A
hand-written (props) => ({...}) opts the component out, since it may read
anything.
- Memo key is built from the props actually present, not by walking the
126-entry consumed set.
Measured (spikes/styling-perf-poc, still an APPROXIMATION): warm median
0.82x low-cardinality / 0.87x high-cardinality vs Emotion, with identical
class counts. Not a "faster than Emotion" claim — see the spike caveats.
FIXED A REAL SSR BUG this exposed: the memo skips inject(), so the second
SSR response shipped markup whose class names had no CSS behind them. Split
class RESOLUTION (memoisable, process-wide) from rule REGISTRATION (per
response): sheet.ts now tracks `rules` / `inSheet` / `emitted` separately and
styled calls register() on a cache hit. This also corrects the earlier claim
that GamutProvider's `cache` prop becomes a plain no-op — the per-request
concern is real, Gamut just handles it internally now instead of making apps
thread a cache through _document.tsx.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… README Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the missing half of the three-tier docs: WHEN you legitimately need a
runtime value, and what to do about it.
Corrects a bad example I had written. `<Box width={`${pct}%`} />` was listed
as GOOD; it is not. The injector hashes the resolved style object, so a
continuous value emits a NEW CLASS PER VALUE — animating 0->100 leaves 100
rules that are never reused. This is the only way a consumer can make the
runtime path grow without bound, and it is invisible until someone profiles
CSS size.
New measure.ts section C proves the fix: 100 distinct widths ->
baked into the style object: 100 classes
via a CSS custom property: 1 class
So the guidance is one pattern for every legitimate runtime case
(state-driven, DOM-measured, data-supplied, animated, arbitrary calc):
STATIC CLASS, DYNAMIC VALUE — the class stays a tier-2 constant and the value
rides an inline `style` custom property. Also SSR-clean, since the class name
is constant and the varying part is a plain HTML attribute.
Also: tier 3 now has real code (the ~12-line panda.config.ts + prebuild
script) rather than only prose, and the two tier-numbering axes in the README
are disambiguated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I flagged this risk and then asked whether to test it instead of testing it.
Testing it.
`states()` deep-merges every active state in DECLARATION order. Panda emits
one INDEPENDENT class per active boolean variant, so the winner is decided by
STYLESHEET order. A disagreement is a silent visual regression — no error, no
type failure, just a different colour. mono has 62 states() sites.
src/precompute/verify-fidelity.ts authors two states on the same property and
activates both:
states({ warning: { bg: 'yellow' }, error: { bg: 'red' } })
PASS — both paths resolve to `red`. Panda emits boolean variants in
declaration order, so stylesheet order and merge order coincide.
The test discriminates rather than passing by luck: declaration order is
warning->error while ALPHABETICAL order is error->warning, so had Panda sorted
the variants, `warning` would have landed last and won, giving `yellow` and
failing. Verified via the emitted stylesheet indices (55182 vs 55263), not by
reasoning about it.
This is a property of Panda's emission order, not a guarantee in the authoring
model — hence a regression test rather than an assumption.
Also confirms the variant/states -> defineRecipe mapping through to CSS:
states({ fullWidth: { width: '100%' } }) becomes
.gmt-gen-fill-button--fullWidth_true { inline-size: 100% }
Still untested: three-way overlaps, and overlaps between a variant() and a
states() on the same property. Recorded in the writeup's open items.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GMT-1715 — Panda CSS styling spike
Self-contained proof of concept exploring Panda CSS as an Emotion replacement for the 1.0 reboot. Lives in
spikes/panda-styling-poc/and is isolated from the yarn workspaces (its ownpackage.json/node_modules; nothing inpackages/*changes). Grounded in the realpackages/gamut/src/Buttonand thepackages/styleguide/.storybooktheme/colorMode switchers.Not intended to merge — a spike to de-risk the styling-engine decision. See
spikes/panda-styling-poc/README.mdfor the full findings log.Questions this answers
variance-style helpers (css/variant/states/system props) + type-safe theme tokens survive on Panda?styledfactory,GamutProvider,ColorMode, andBackgroundstill work?styledcoming from Gamut?Findings
tsc(strictTokens).styledfactory — works, re-exported from the Gamut facade (never@emotion/styled).ThemeProvider/<Global>injection; consumers import one static stylesheet).data-panda-theme+data-color-modeattribute flips; every theme×mode combo is pre-generated statically (zero runtime), matching the Storybook switchers.tsc,panda cssgen, andvite buildall pass.Consumer surface (beyond
styledfrom Gamut)Concentrated and codemoddable/documentable: the
cssprop, JS-resolved color values (charts/canvas,_getColorValue), theme customization, SSR wiring (removed), and@emotion/jesttest matchers.Still NOT proven
Run it
🤖 Generated with Claude Code