diff --git a/package.json b/package.json index c09a7f053d9..30a4df45757 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,8 @@ }, "workspaces": { "packages": [ - "packages/*" + "packages/*", + "spikes/*" ] } } diff --git a/packages/gamut-styles/src/typings/theme.d.ts b/packages/gamut-styles/src/typings/theme.d.ts index 6e5c8f5e571..4d5d134d06a 100644 --- a/packages/gamut-styles/src/typings/theme.d.ts +++ b/packages/gamut-styles/src/typings/theme.d.ts @@ -1,6 +1,31 @@ +import '@codecademy/variance'; import '@emotion/react'; + import { CoreTheme } from '../themes'; +/* Registers Gamut's theme so `scale: 'colors'` typechecks and token names + * autocomplete. + * + * WHY TWO DECLARATIONS (transitional): + * + * `variance` needs one mutable, global type slot to learn what the theme contains. + * That slot used to be Emotion's `Theme` interface — Emotion did nothing with it; + * it just happened to be the interface everyone augmented. `variance` now owns the + * slot itself (`variance/src/types/theme.ts`), so its type system no longer + * depends on Emotion at all. + * + * The Emotion declaration stays only because `gamut-styles` still uses Emotion's + * `ThemeProvider` / `useTheme` at RUNTIME, and those are typed by Emotion's + * `Theme`. It becomes deletable the moment that provider is replaced — see + * `spikes/emotion-to-gamut-poc`, which drops it entirely. + * + * Both point at the same `CoreTheme`, so they cannot drift. */ +declare module '@codecademy/variance' { + export interface Theme extends CoreTheme { + useLogicalProperties?: boolean; + } +} + declare module '@emotion/react' { export interface Theme extends CoreTheme { useLogicalProperties?: boolean; diff --git a/packages/variance/src/index.ts b/packages/variance/src/index.ts index 6c1279e8061..8bf694d4be5 100644 --- a/packages/variance/src/index.ts +++ b/packages/variance/src/index.ts @@ -1,6 +1,7 @@ export { variance } from './core'; export * from './createTheme'; export * from './types/props'; +export * from './types/theme'; export * from './transforms'; export * from './scales/createScale'; export * from './getPropertyMode'; diff --git a/packages/variance/src/types/config.ts b/packages/variance/src/types/config.ts index c4dc0be88eb..c334f6bfac5 100644 --- a/packages/variance/src/types/config.ts +++ b/packages/variance/src/types/config.ts @@ -1,4 +1,4 @@ -import { Theme } from '@emotion/react'; +import { Theme } from './theme'; import { DefaultCSSPropertyValue, diff --git a/packages/variance/src/types/props.ts b/packages/variance/src/types/props.ts index 8bf0ed6799c..f15fc49016e 100644 --- a/packages/variance/src/types/props.ts +++ b/packages/variance/src/types/props.ts @@ -1,6 +1,5 @@ -import { Theme } from '@emotion/react'; - import { AbstractParser, Scale } from './config'; +import { Theme } from './theme'; import { CSSPropertyTypes } from './properties'; export type AbstractProps = ThemeProps>; diff --git a/packages/variance/src/types/theme.ts b/packages/variance/src/types/theme.ts index d46548bf7bd..379da0ae957 100644 --- a/packages/variance/src/types/theme.ts +++ b/packages/variance/src/types/theme.ts @@ -20,6 +20,24 @@ export interface AbstractTheme extends BaseTheme { readonly [key: string]: any; } -declare module '@emotion/react' { - export interface Theme extends BaseTheme {} -} +/** + * The augmentable theme registry. + * + * `variance` needs one mutable, global type slot that an app can extend with its + * real theme, so that `scale: 'colors'` typechecks and token names autocomplete. + * That slot used to be Emotion's `Theme` interface — Emotion was never doing + * anything with it, it just happened to be the interface everyone augmented. + * Owning it here removes variance's only dependency on Emotion. + * + * Consumers augment it exactly as they augmented Emotion's: + * + * ```ts + * import type { CoreTheme } from '@codecademy/gamut-styles'; + * + * declare module '@codecademy/variance' { + * export interface Theme extends CoreTheme {} + * } + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Theme extends BaseTheme {} diff --git a/packages/variance/src/utils/serializeTokens.ts b/packages/variance/src/utils/serializeTokens.ts index beb9cb557b7..fd5cdf05ef1 100644 --- a/packages/variance/src/utils/serializeTokens.ts +++ b/packages/variance/src/utils/serializeTokens.ts @@ -1,8 +1,8 @@ -import { Theme } from '@emotion/react'; import isObject from 'lodash/isObject'; import merge from 'lodash/merge'; import { CSSObject } from '../types/props'; +import { Theme } from '../types/theme'; /** * Returns an type of any object with { key: 'var(--key) } diff --git a/spikes/emotion-to-gamut-poc/.gitignore b/spikes/emotion-to-gamut-poc/.gitignore new file mode 100644 index 00000000000..ce0232826f5 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.check/ +styled-system/ +src/gamut-panda.css diff --git a/spikes/emotion-to-gamut-poc/README.md b/spikes/emotion-to-gamut-poc/README.md new file mode 100644 index 00000000000..a45ed110e2d --- /dev/null +++ b/spikes/emotion-to-gamut-poc/README.md @@ -0,0 +1,257 @@ +# Panda under the hood, Gamut's styling API unchanged + +**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. + +```bash +yarn install # once, from the repo root +yarn nx run emotion-to-gamut-poc:dev # → http://localhost:5174 +``` + +Or from this folder: `yarn dev` / `yarn build` / `yarn typecheck`. + +--- + +## 1. The only line that changes + +```diff +- import styled from '@emotion/styled'; ++ import { styled } from '@codecademy/gamut-styles'; +``` + +`css`, `variant`, `states`, `styledOptions`, `Box`, `ColorMode`, `Background` were +**already** imported from Gamut, so those lines don't move either. + +## 2. Who does what, and why + +| | does what | why | +| --- | --- | --- | +| **Panda** (build time) | every design token as a CSS variable, for all 5 themes × 2 colour modes; static CSS for Gamut's own components | tokens and design-system components are a **closed set** Gamut controls, so they can be enumerated and emitted ahead of time — no JS needed to style them | +| **Gamut** (`variance`, runtime) | resolves `css()` / `variant()` / `states()` / system props at call sites | consumer values are **open** — `width="37.5%"`, a colour from an API, a prop-driven ternary. A build-time extractor can't see them, so something must resolve them at runtime | +| **This PoC's ~100 lines** (`src/gamut/sheet.ts`) | turns a resolved style object into a class name | this is *all* Emotion was doing for Gamut. Replacing only this is why nothing else has to change | + +The split follows from what's knowable when. That's the whole design. + +## 3. Why the API survives untouched + +`css()`, `variant()` and `states()` come from `@codecademy/variance`. They already +return `(props) => CSSObject`, and they already resolve at runtime. + +Emotion's only real job was **merging those results and hashing them into a +class.** So swapping Emotion out is a one-layer change, and `variance` isn't +forked, wrapped, or reimplemented — the real `@codecademy/variance` and the real +Gamut prop config are workspace dependencies here. + +## 4. How theme mapping works + +Gamut's theme object stores colours as CSS-variable **references**: + +```ts +coreTheme.colors.primary === 'var(--color-primary)' +``` + +So `variance` never touches a hex value — it emits that reference, and something +must **define** it. Today a React `` component does, at runtime. Here +Panda does, at build time. + +A theme is then just a different set of alias assignments over the same palette: + +| theme | light `--color-primary` | +| --- | --- | +| core | `var(--color-hyper-500)` | +| admin | `var(--color-blue-500)` | +| platform | `var(--color-hyper-500)` | +| lxStudio | `var(--color-sapphire)` | +| percipio | `var(--color-sapphire)` | + +Switching theme or colour mode is therefore an **attribute flip** — `data-theme` +and `data-color-mode`. No rebuild, no re-render of styles, because only variable +*assignments* change. Both switchers are live on the page. + +Colour mode **reassigns** variables rather than using selector conditions, which is +what makes nesting correct: a `[data-color-mode]` resolves from the *nearest* +ancestor. A descendant-selector approach (`[data-color-mode=dark] &`) gets +light-inside-dark wrong, because the inner element matches both conditions and +source order beats proximity. Section 4 on the page nests white-inside-navy to show +it. + +## 5. What's demonstrated + +Two sections are copied **verbatim** out of mono. + +| # | Pattern | Source | +| --- | --- | --- | +| 1 | `variant({ prop, base, variants })` + `StyleProps` + `styledOptions` | **verbatim** `mono/libs/ui/brand/src/AppBar/AppBarSection.tsx` | +| 2 | A **Panda-backed** `StrokeButton` extended by the unchanged `styled(X)(css(…), states(…))` API — nested `@media`, responsive `{ _, xs }`, `withComponent` | **verbatim** `mono/libs/ui/login-or-register/src/OAuthButtons/elements.tsx` | +| 3 | System props — `` | real Gamut prop config + scales | +| 4 | `` / ``, incl. nested light-inside-dark | — | +| 5 | `` styled.span`…` `` with `${props => …}` interpolation | mono has 234 of these | +| 6 | `` and `keyframes()` — the last two Emotion APIs Gamut used | 10 + 5 references in `packages/*` | + +**Section 2 is the actual proof.** `StrokeButton`'s own CSS is 100% Panda static +output (`.gmt-stroke-button--variant_primary`), and a consumer extends it with the +untouched Emotion-era API. Panda underneath, API unchanged, in one component. + +### Every Emotion API Gamut uses, and its replacement + +The point of §6 on the page: **nothing is left over.** Measured across +`packages/*/src`: + +| Emotion API | sites | replaced by | +| --- | --- | --- | +| `styled` | 111 | `src/gamut/styled.tsx` — same composed call shape | +| `css` | 17 | Gamut's own `css()` (already `variance`), or `injectGlobal` for globals | +| `Theme` / `useTheme` / `ThemeProvider` / `ThemeContext` | 17 | `src/gamut/theme.tsx` + variance's registry (§8) | +| `Global` | 10 | `` — same call shape, plain style object | +| `keyframes` | 5 | `keyframes()` → returns the generated animation name | +| `isPropValid` | 4 | the prop config is already the source of truth | +| `createCache` / `CacheProvider` / `Options` / `StylisPlugin` | 10 | **nothing — not needed.** Class names are content-hashed and deterministic, so there's no per-request cache to thread and no stylis plugin chain to configure | +| `SerializedStyles` / `CSSObject` | 4 | `CSSObject` from `@codecademy/variance` | + +The one genuinely unreplaced item is `@emotion/jest`'s `matchers` (4 test sites), +which assert on Emotion-generated CSS. Those need an equivalent matcher against +the Gamut stylesheet — straightforward, but not built here. + +## 6. Verified mechanically + +- `yarn typecheck` clean — including `src/type-safety.test-d.tsx`, 9 negative + cases that must each error (see §8). Token safety intact: `fontSize={12}` is + **rejected**. +- `yarn build` clean. 30kB of CSS, **3.46kB gzipped** (it compresses hard: mostly + repeated variable declarations). +- Rendered in jsdom: **85 CSS variables referenced, 333 defined, 0 missing**; + **32 classes in the DOM, 0 without a matching rule**. +- Panda emits all 5 themes × 2 modes, and all 4 `strokeButton` variant classes via + `staticCss`. +- `` emits `body{margin:0}` **unscoped** (no class prefix), and + `keyframes()` emits a complete `@keyframes gmt-kf-…{0%, 100%{opacity:1}50%{opacity:0.35}}` + that the animating element references by name. +- `grep '@emotion' packages/variance/src` returns nothing; the built bundle + contains no `serializeStyles` / `insertStyles` / `createCache` / `@emotion`. + +## 7. Three things worth knowing (all found the hard way) + +**Panda's extractor collides with Gamut's `css()`.** Both are called `css`. Point +Panda's `include` at files using Gamut's and it "extracts" them into nonsense — +`.bg_primary { background: primary }`, `.pos_left { position: left }` (from +`variant()` *keys*), `.__43 { _: 43px }` (from responsive `{ _: 43 }`). Nothing +references those classes so it renders fine; it just silently inflated the +stylesheet from 11kB to 27kB. Fix: Panda scans only its own config here. For a +consumer who *does* want Panda extracting their call sites, `importMap` is the +supported way to disambiguate. + +**Core's palette is not a superset.** lxStudio and percipio add their own tokens +(`--color-sapphire`, `--color-percipioTextPrimary`, `--color-lxStudioSuccess`, plus +code-editor colours). Emitting only Core's palette left **33 variables dangling** — +and dangling variables fail silently, rendering unstyled. Fix: emit the union, plus +per-theme overrides. + +**Dropping `preset-panda` halves the output.** Panda's default palette +(rose/fuchsia/violet/…) is dead weight for a design system with its own tokens. +Keeping `preset-base` for utilities and conditions is enough. + +## 8. Theme type safety with no Emotion at all — including in the types + +**Emotion is now gone from this PoC completely, types included.** The bundle check +above covers runtime; this section covers types. + +### Why Emotion was in the types at all + +`variance` needs **one mutable, global type slot** to learn what your theme +contains, so `scale: 'colors'` typechecks and token names autocomplete. That slot +used to be Emotion's `Theme` interface — and Emotion did nothing with it. It just +happened to be the interface everyone augmented. + +`variance` already defined its own theme types (`BaseTheme`, `Breakpoints` in +`types/theme.ts`); it was *borrowing* Emotion's interface purely as the registry. +So it now owns the slot: + +```ts +// packages/variance/src/types/theme.ts +export interface Theme extends BaseTheme {} // ← augment this +``` + +Four files changed, all types-only: `types/theme.ts` (owns the registry and drops +`declare module '@emotion/react'`), plus `types/props.ts`, `types/config.ts` and +`utils/serializeTokens.ts` importing `Theme` from `./theme` instead. **`grep +'@emotion' packages/variance/src` now returns nothing.** + +> Correction: earlier notes on this said "exactly two lines." That was wrong — +> it's four files, and one of them was variance augmenting Emotion itself. + +### What a consumer changes + +Same shape, different module: + +```diff +- declare module '@emotion/react' { export interface Theme extends CoreTheme {} } ++ declare module '@codecademy/variance' { export interface Theme extends CoreTheme {} } +``` + +That's the whole migration for the 19 real augmentation sites (18 in mono, 1 in +`platform/src/themes/platform.d.ts`). See `src/gamut-theme.d.ts`. + +### Proof that nothing was lost: `src/type-safety.test-d.tsx` + +`yarn typecheck` **is** the assertion — there's no runtime in that file. It pins +both halves of the question, and it fails in both directions: if a negative case +silently started compiling, TypeScript reports `Unused '@ts-expect-error' +directive`. (Verified by deliberately breaking one case.) + +**Scale-valued props still validate against the theme** — these all error: + +```ts +css({ fontSize: 12 }); // not a fontSize token +css({ p: 5 }); // not a spacing token +css({ bg: 'chartreuse' }); // not a colour token or alias +; // still rejected as a JSX prop +``` + +**`variant()` and `states()` produce dependable types.** Resolved shapes: + +```ts +StyleProps +// = VariantProps<"position", false | "left" | "right" | "center"> & { theme?: Theme } +StyleProps +// = Partial> & { theme?: Theme } +``` + +So the prop is named after `prop`, its values are the exact literal union of the +declared keys, and states are exactly the declared booleans. These all error: + +```ts +{ position: 'middle' } // not a declared variant +{ variant: 'left' } // the prop is `position` +{ isFancy: 'yes' } // states are booleans +{ hasBorder: true } // never declared +``` + +**Worth knowing why this holds:** `variant()` and `states()` derive their prop +types from the **config object you pass them**, not from the theme. `Theme` only +appears in the `theme?:` member. So the registry swap can't affect them — only +scale-valued props (`p`, `bg`, `fontSize`) depend on `keyof Theme`, and those are +verified above. + +## 9. Scope + +``` +panda.config.ts Panda: tokens for 5 themes x 2 modes + a component recipe +src/ + App.tsx the demo — real mono call sites, only the import changed + gamut-panda.css generated by `panda cssgen` (gitignored) + gamut-theme.d.ts the theme registry augmentation (§8) + type-safety.test-d.tsx compile-time proof that token safety survived (§8) + gamut/ stands in for @codecademy/gamut-styles + index.ts the public surface — the "swap target" + props.ts css / variant / states / systemProps on the REAL config + sheet.ts style object → class name → + + +
+ + + diff --git a/spikes/emotion-to-gamut-poc/package.json b/spikes/emotion-to-gamut-poc/package.json new file mode 100644 index 00000000000..1119899c3e7 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/package.json @@ -0,0 +1,27 @@ +{ + "name": "emotion-to-gamut-poc", + "description": "Minimal PoC: today's Emotion-authored Gamut API works unchanged with `styled` imported from Gamut (not published)", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "panda cssgen --outfile src/gamut-panda.css && vite", + "build": "panda cssgen --outfile src/gamut-panda.css && vite build", + "typecheck": "tsc --noEmit", + "tokens": "panda cssgen --outfile src/gamut-panda.css" + }, + "dependencies": { + "@codecademy/gamut-styles": "workspace:*", + "@codecademy/variance": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@pandacss/dev": "^0.53.0", + "@types/react": "18.3.27", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "5.9.3", + "vite": "^5.4.11" + } +} diff --git a/spikes/emotion-to-gamut-poc/panda.config.ts b/spikes/emotion-to-gamut-poc/panda.config.ts new file mode 100644 index 00000000000..8685bdf74b7 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/panda.config.ts @@ -0,0 +1,219 @@ +import { adminTheme } from '@codecademy/gamut-styles/dist/themes/admin'; +import { coreTheme } from '@codecademy/gamut-styles/dist/themes/core'; +import { lxStudioTheme } from '@codecademy/gamut-styles/dist/themes/lxStudio'; +import { percipioTheme } from '@codecademy/gamut-styles/dist/themes/percipio'; +import { platformTheme } from '@codecademy/gamut-styles/dist/themes/platform'; +import { defineConfig, defineRecipe } from '@pandacss/dev'; + +/* ════════════════════════════════════════════════════════════════════════════ + * PANDA'S TWO JOBS HERE + * + * 1. Emit every Gamut design token as a CSS variable — for all five themes, + * in both colour modes. + * 2. Emit static, zero-runtime CSS for Gamut's own components (recipes). + * + * Both are INTERNAL. Nothing in this file is visible to a consumer, which is the + * whole point: Panda does real work without the external styling API changing. + * + * Every value is read from the REAL Gamut themes, so these are production values. + * ════════════════════════════════════════════════════════════════════════════ */ + +type GamutTheme = { + modes: Record<'light' | 'dark', Record>; + _variables: { root: Record }; + spacing: Record; + borderRadii: Record; + fontSize: Record; +}; + +const THEMES = { + core: coreTheme, + admin: adminTheme, + platform: platformTheme, + lxStudio: lxStudioTheme, + percipio: percipioTheme, +} as unknown as Record; + +const core = THEMES.core; + +/* ── Reading the palette out of a theme ────────────────────────────────────── + * `_variables.root` is a mixed bag, not just colours: it also holds element + * variables (`--elements-headerHeight`) and even responsive blocks keyed by a + * media query whose value is an OBJECT. Take only flat declarations. */ +const paletteOf = (theme: GamutTheme) => + Object.fromEntries( + Object.entries(theme._variables.root).filter( + ([, value]) => typeof value === 'string' || typeof value === 'number' + ) + ) as Record; + +/* Core's palette is NOT a superset of the others. lxStudio and percipio add their + * own tokens (`--color-percipioTextPrimary`, `--color-lxStudioSuccess`, + * `--color-sapphire`, plus code-editor colours) and their alias blocks reference + * them — emitting only Core's left 33 variables dangling and silently unstyled. + * + * So: emit the UNION at `:root`, then per-theme overrides for any token whose + * value actually differs. Core is merged last so it wins ties. */ +const unionPalette: Record = Object.assign( + {}, + ...Object.keys(THEMES) + .filter((name) => name !== 'core') + .map((name) => paletteOf(THEMES[name])), + paletteOf(core) +); + +const paletteOverrides = (name: string) => + Object.fromEntries( + Object.entries(paletteOf(THEMES[name])).filter( + ([token, value]) => unionPalette[token] !== value + ) + ); + +/* ── Mapping Gamut's themes ────────────────────────────────────────────────── + * Gamut's theme object stores colours as CSS-variable REFERENCES: + * `coreTheme.colors.primary` is literally the string `var(--color-primary)`. So + * `variance` never handles a hex value — it emits that reference, and something + * has to DEFINE it. Today a React `` component does, at runtime. + * Here Panda does, at build time. + * + * A theme is just a different set of ALIAS ASSIGNMENTS over the same palette: + * Core's light `primary` is `hyper-500`, Admin's is `blue-500`. So switching + * theme or colour mode is an attribute flip — no restyle, no rebuild. */ +const aliases = (name: string, mode: 'light' | 'dark') => + Object.fromEntries( + Object.entries(THEMES[name].modes[mode]).map(([alias, token]) => [ + `--color-${alias}`, + `var(--color-${token})`, + ]) + ); + +/* `data-theme` sits on an outer element while `data-color-mode` can be on a + * NESTED one, so the two can't be a single compound selector. Each block matches + * both the descendant case and the same-element case. */ +const themeBlocks = Object.keys(THEMES).reduce>( + (blocks, name) => { + const overrides = paletteOverrides(name); + if (Object.keys(overrides).length) blocks[`[data-theme=${name}]`] = overrides; + + (['light', 'dark'] as const).forEach((mode) => { + blocks[ + `[data-theme=${name}] [data-color-mode=${mode}],` + + `[data-theme=${name}][data-color-mode=${mode}]` + ] = aliases(name, mode); + }); + + return blocks; + }, + {} +); + +/* ── Tokens Panda owns as real tokens ───────────────────────────────────────── */ + +// String() because some scales hold numbers (`spacing[0]` is literally `0`) +const asTokens = (obj: Record) => + Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, { value: String(v) }]) + ); + +// Panda wants token names (`navy-800`); the variables are keyed `--color-navy-800` +const paletteTokens = Object.fromEntries( + Object.entries(unionPalette) + .filter(([name]) => name.startsWith('--color-')) + .map(([name, value]) => [name.replace(/^--color-/, ''), value]) +); + +/* ── A Gamut component as a Panda recipe ───────────────────────────────────── + * The half a consumer never sees, and the half that becomes zero-runtime static + * CSS. `staticCss` force-emits every variant so the classes exist regardless of + * what any given build happens to render — which is what makes this safe across + * lazy-loaded and federated boundaries. */ +const strokeButton = defineRecipe({ + className: 'gmt-stroke-button', + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderWidth: '[2px]', + borderStyle: 'solid', + borderRadius: '[var(--radius-md)]', + cursor: 'pointer', + background: '[transparent]', + font: 'inherit', + }, + variants: { + variant: { + primary: { + borderColor: '[var(--color-primary)]', + color: '[var(--color-primary)]', + _hover: { background: '[var(--color-background-hover)]' }, + }, + danger: { + borderColor: '[var(--color-danger)]', + color: '[var(--color-danger)]', + _hover: { background: '[var(--color-background-hover)]' }, + }, + }, + size: { + small: { padding: '[4px 8px]', fontSize: '[0.875rem]' }, + normal: { padding: '[8px 16px]', fontSize: '[1rem]' }, + }, + }, + defaultVariants: { variant: 'primary', size: 'normal' }, +}); + +export default defineConfig({ + preflight: false, + + /* Keep preset-base (utilities + conditions) but DROP preset-panda, whose default + * palette (rose/fuchsia/violet/…) Gamut never uses. It accounted for more than + * half the emitted CSS. Gamut's tokens are the design system here. */ + presets: ['@pandacss/preset-base'], + + outdir: 'styled-system', + jsxFramework: 'react', + + /* IMPORTANT — Panda must NOT scan the app source. + * + * Gamut's `css()` and Panda's `css()` are different functions with the same + * name. Point Panda's extractor at files using Gamut's and it cheerfully + * "extracts" them, emitting nonsense: `.bg_primary { background: primary }`, + * `.pos_left { position: left }` (from `variant()` KEYS), `.__43 { _: 43px }` + * (from responsive `{ _: 43 }` values). Nothing references those classes, so it + * renders fine — it just silently inflated the stylesheet from 11kB to 27kB. + * + * Panda needs to scan nothing here: recipes are declared in this config and + * `staticCss` force-emits them. If a consumer ever DOES want Panda to extract + * their own call sites, `importMap` is the supported way to tell the two + * `css` functions apart. */ + include: ['./panda.config.ts'], + + staticCss: { recipes: { strokeButton: ['*'] } }, + + theme: { + extend: { + tokens: { + colors: asTokens(paletteTokens), + spacing: asTokens(core.spacing), + radii: asTokens(core.borderRadii), + fontSizes: asTokens(core.fontSize), + }, + recipes: { strokeButton }, + }, + }, + + /* Cast because these blocks are built dynamically from the themes — Panda types + * globalCss for hand-written style objects, not generated maps of custom + * properties. The values are plain CSS variable declarations. */ + globalCss: { + // the palette union, once + ':root': { + ...unionPalette, + '--radius-md': core.borderRadii.md, + } as never, + // Core light as the default, so an app with no `data-theme` still works + ':root, [data-color-mode=light]': aliases('core', 'light') as never, + '[data-color-mode=dark]': aliases('core', 'dark') as never, + // then every theme (+ its palette overrides) x colour mode + ...(themeBlocks as Record), + }, +}); diff --git a/spikes/emotion-to-gamut-poc/project.json b/spikes/emotion-to-gamut-poc/project.json new file mode 100644 index 00000000000..dfb48763884 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/project.json @@ -0,0 +1,30 @@ +{ + "name": "emotion-to-gamut-poc", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "spikes/emotion-to-gamut-poc/src", + "projectType": "application", + "tags": [], + "targets": { + "dev": { + "executor": "nx:run-commands", + "options": { + "cwd": "spikes/emotion-to-gamut-poc", + "command": "yarn dev" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "spikes/emotion-to-gamut-poc", + "command": "yarn build" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "cwd": "spikes/emotion-to-gamut-poc", + "command": "yarn typecheck" + } + } + } +} diff --git a/spikes/emotion-to-gamut-poc/src/App.tsx b/spikes/emotion-to-gamut-poc/src/App.tsx new file mode 100644 index 00000000000..882e6c3388b --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/App.tsx @@ -0,0 +1,308 @@ +import type { StyleProps } from '@codecademy/variance'; +import { useState } from 'react'; + +/* ───────────────────────────────────────────────────────────────────────────── + * THE ONLY LINE THAT CHANGES IN A MIGRATION: + * + * - import styled from '@emotion/styled'; + * + import { styled } from '@codecademy/gamut-styles'; + * + * `css`, `states`, `variant`, `styledOptions`, `Box`, `ColorMode`, `Background` + * were already imported from Gamut, so those import lines don't move at all. + * ──────────────────────────────────────────────────────────────────────────── */ +import { + allRules, + Background, + Box, + ColorMode, + css, + FlexBox, + GamutProvider, + Global, + states, + StrokeButton, + styled, + styledOptions, + keyframes, + Text, + themes, + variant, +} from './gamut'; + +/* ════════════════════════════════════════════════════════════════════════════ + * 1. variant() — copied VERBATIM from + * mono/libs/ui/brand/src/AppBar/AppBarSection.tsx + * ══════════════════════════════════════════════════════════════════════════ */ + +const positionVariants = variant({ + prop: 'position', + base: { + display: 'flex', + alignItems: 'center', + height: '100%', + zIndex: 1, + }, + variants: { + left: { flex: 1 }, + right: { flex: 1, justifyContent: 'flex-end' }, + center: { flex: 2, justifyContent: 'center', textAlign: 'center' }, + }, +}); + +interface AppBarSectionProps extends StyleProps { + children?: React.ReactNode; +} + +const StyledSection = styled( + 'div', + styledOptions +)(positionVariants); + +/* ════════════════════════════════════════════════════════════════════════════ + * 2. css() + states() + the composed shape + withComponent — copied VERBATIM + * from mono/libs/ui/login-or-register/src/OAuthButtons/elements.tsx + * ══════════════════════════════════════════════════════════════════════════ */ + +const StyledGridBox = styled(Box.withComponent('ul'))( + css({ + display: 'grid', + gridAutoFlow: 'column', + columnGap: 12, + '@media (max-width: 375px)': { columnGap: 8 }, + '@media (max-width: 280px)': { gridAutoFlow: 'row' }, + listStyle: 'none', + pl: 0, + mb: 24, + }) +); + +const StrokeButtonBaseStyles = css({ + '@media (max-width: 385px)': { px: 4 }, + px: 16, + py: 4, + mb: 8, + mr: 8, + height: { _: 43, xs: 53 }, + width: { _: 50, xs: 65 }, + backgroundColor: 'white', +}); + +const StrokeButtonStateStyles = states({ + isFancy: { + p: 0, + height: { _: 48, xs: 48 }, + width: { _: '100%', xs: '100%' }, + }, +}); + +const StyledStrokeButton = styled(StrokeButton)< + StyleProps +>(StrokeButtonBaseStyles, StrokeButtonStateStyles); + +/* ════════════════════════════════════════════════════════════════════════════ + * 3. styled.tag`…` template literals — mono has 234 of these + * ══════════════════════════════════════════════════════════════════════════ */ + +// withComponent again — same styles, different element +const Pre = Box.withComponent('pre'); + +const Pill = styled.span<{ $tone: string }>` + padding: 4px 12px; + border-radius: 999px; + font-size: 14px; + color: white; + background: ${(props: { $tone: string }) => props.$tone}; +`; + +/* ════════════════════════════════════════════════════════════════════════════ + * 6. The last two Emotion APIs: and keyframes() + * ══════════════════════════════════════════════════════════════════════════ */ + +// replaces Emotion's `keyframes` (5 references in packages/*) +const pulse = keyframes({ + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.35 }, +}); + +const Pulsing = styled(Box)( + css({ animation: `${pulse} 1.4s ease-in-out infinite` } as never) +); + +/* ════════════════════════════════════════════════════════════════════════════ + * The page + * ══════════════════════════════════════════════════════════════════════════ */ + +const Section = ({ + title, + note, + children, +}: { + title: string; + note: string; + children?: React.ReactNode; +}) => ( + + + {title} + + + {note} + + {children} + +); + +export const App = () => { + const [mode, setMode] = useState<'light' | 'dark'>('light'); + const [themeName, setThemeName] = useState('core'); + + return ( + + {/* replaces Emotion's (10 references in packages/*) */} + + + + + + Emotion → Gamut: same API, one import changed + + + {Object.keys(themes).map((name) => ( + setThemeName(name)} + > + {name} + + ))} + setMode(mode === 'light' ? 'dark' : 'light')} + > + {mode} mode + + + + +
+ + left + center + right + +
+ +
+ +
  • + normal +
  • +
  • + isFancy +
  • +
    +
    + +
    + + + p=16 bg=primary + + + bg=secondary + + + px=24 py=8 + + +
    + +
    + + + navy surface → dark mode + + nested white surface → back to light + + + + + explicit ColorMode dark + + + +
    + +
    + + purple + teal + +
    + +
    + + keyframes() → {pulse} + +
    + +
    +
    +            {allRules()
    +              .map(([, text]) => text)
    +              .join('\n')}
    +          
    +
    +
    +
    +
    + ); +}; diff --git a/spikes/emotion-to-gamut-poc/src/gamut-theme.d.ts b/spikes/emotion-to-gamut-poc/src/gamut-theme.d.ts new file mode 100644 index 00000000000..7ded9862a9d --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut-theme.d.ts @@ -0,0 +1,20 @@ +import type { CoreTheme } from './gamut/theme'; + +/* THEME TYPE SAFETY — with no Emotion anywhere, at runtime OR in types. + * + * `variance` needs one mutable, global type slot to learn what your theme + * contains, so that `scale: 'colors'` typechecks and token names autocomplete. + * That slot used to be Emotion's `Theme` interface — Emotion did nothing with it; + * it just happened to be the interface everyone augmented. `variance` now owns + * the slot itself (packages/variance/src/types/theme.ts), so this augmentation + * is the same shape against a 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 in mono, + * 1 in platform/src/themes/platform.d.ts). */ +declare module '@codecademy/variance' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface Theme extends CoreTheme {} +} diff --git a/spikes/emotion-to-gamut-poc/src/gamut/components.tsx b/spikes/emotion-to-gamut-poc/src/gamut/components.tsx new file mode 100644 index 00000000000..aee0beb3fdc --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/components.tsx @@ -0,0 +1,98 @@ +import type { StyleProps } from '@codecademy/variance'; +import { type ComponentPropsWithoutRef, type ReactNode, forwardRef } from 'react'; +import { strokeButton } from 'styled-system/recipes'; + +import { css, systemProps } from './props'; +import { injectGlobal } from './sheet'; +import { styled } from './styled'; + +/* The handful of Gamut components the demo needs, built on the new engine. + * Their public props are unchanged — that's the point. */ + +export type BoxProps = StyleProps; + +/** `` — every system prop, same as today. */ +export const Box = styled('div')(systemProps); + +export const FlexBox = styled('div')( + css({ display: 'flex' }), + systemProps +); + +export const Text = styled('span')(systemProps); + +/* ── A Gamut component backed ENTIRELY by Panda ────────────────────────────── + * Its CSS is static, generated at build time from the recipe in panda.config.ts. + * Zero runtime style work: this just picks class names. + * + * `className` is merged in so a consumer can still extend it with the unchanged + * `styled(StrokeButton)(css(…), states(…))` API — see App.tsx section 2. That + * combination is the whole proof: Panda underneath, API untouched on top. */ +export type StrokeButtonProps = ComponentPropsWithoutRef<'button'> & { + variant?: 'primary' | 'danger'; + size?: 'small' | 'normal'; +}; + +export const StrokeButton = forwardRef( + ({ variant, size, className, ...rest }, ref) => ( +