From 23618fa6eebb78caa4ed5f0a1857a6aaea05639d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 6 Aug 2026 14:12:02 -0400 Subject: [PATCH 1/5] =?UTF-8?q?poc:=20prove=20the=20Gamut=20styling=20API?= =?UTF-8?q?=20survives=20an=20Emotion=E2=86=92Gamut=20import=20swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 3 +- spikes/emotion-to-gamut-poc/.gitignore | 3 + spikes/emotion-to-gamut-poc/README.md | 134 ++++ spikes/emotion-to-gamut-poc/index.html | 19 + spikes/emotion-to-gamut-poc/package.json | 25 + spikes/emotion-to-gamut-poc/project.json | 30 + spikes/emotion-to-gamut-poc/src/App.tsx | 263 ++++++++ .../emotion-to-gamut-poc/src/gamut-theme.d.ts | 29 + .../src/gamut/components.tsx | 63 ++ .../emotion-to-gamut-poc/src/gamut/index.ts | 23 + .../emotion-to-gamut-poc/src/gamut/props.ts | 54 ++ .../emotion-to-gamut-poc/src/gamut/sheet.ts | 126 ++++ .../emotion-to-gamut-poc/src/gamut/styled.tsx | 211 ++++++ .../emotion-to-gamut-poc/src/gamut/theme.tsx | 55 ++ spikes/emotion-to-gamut-poc/src/main.tsx | 12 + spikes/emotion-to-gamut-poc/tsconfig.json | 13 + spikes/emotion-to-gamut-poc/vite.config.ts | 7 + yarn.lock | 618 +++++++++++++++--- 18 files changed, 1579 insertions(+), 109 deletions(-) create mode 100644 spikes/emotion-to-gamut-poc/.gitignore create mode 100644 spikes/emotion-to-gamut-poc/README.md create mode 100644 spikes/emotion-to-gamut-poc/index.html create mode 100644 spikes/emotion-to-gamut-poc/package.json create mode 100644 spikes/emotion-to-gamut-poc/project.json create mode 100644 spikes/emotion-to-gamut-poc/src/App.tsx create mode 100644 spikes/emotion-to-gamut-poc/src/gamut-theme.d.ts create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/components.tsx create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/index.ts create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/props.ts create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/sheet.ts create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/styled.tsx create mode 100644 spikes/emotion-to-gamut-poc/src/gamut/theme.tsx create mode 100644 spikes/emotion-to-gamut-poc/src/main.tsx create mode 100644 spikes/emotion-to-gamut-poc/tsconfig.json create mode 100644 spikes/emotion-to-gamut-poc/vite.config.ts diff --git a/package.json b/package.json index c09a7f053d..30a4df4575 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,8 @@ }, "workspaces": { "packages": [ - "packages/*" + "packages/*", + "spikes/*" ] } } diff --git a/spikes/emotion-to-gamut-poc/.gitignore b/spikes/emotion-to-gamut-poc/.gitignore new file mode 100644 index 0000000000..848b9ec1fe --- /dev/null +++ b/spikes/emotion-to-gamut-poc/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.check/ diff --git a/spikes/emotion-to-gamut-poc/README.md b/spikes/emotion-to-gamut-poc/README.md new file mode 100644 index 0000000000..3b53ca3777 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/README.md @@ -0,0 +1,134 @@ +# Emotion → Gamut: same API, one import changed + +A minimal proof that **today's Gamut styling API keeps working exactly as written** +when `styled` comes from Gamut instead of Emotion. + +```bash +yarn install # from the repo root, once +yarn nx run emotion-to-gamut-poc:dev # → http://localhost:5174 +``` + +Or from this folder: `yarn dev` / `yarn build` / `yarn typecheck`. + +--- + +## The claim + +This is the entire migration for a call site: + +```diff +- import styled from '@emotion/styled'; ++ import { styled } from '@codecademy/gamut-styles'; +``` + +Everything else stays byte-identical. `css`, `variant`, `states`, `styledOptions`, +`Box`, `ColorMode`, `Background` were **already** imported from Gamut, so those +import lines don't move either. + +## Why it works + +`css()`, `variant()` and `states()` come from `@codecademy/variance`. They already +return `(props) => CSSObject` and already resolve **at runtime**. Emotion's only +real job was merging those results and turning them into a class. + +So this PoC replaces that one step — `src/gamut/sheet.ts`, about 100 lines — and +reuses the rest untouched. `variance` is not forked, wrapped, or reimplemented; the +real `@codecademy/variance` and the real Gamut prop config are workspace +dependencies here. + +That's why the API survives _exactly_ rather than approximately. + +## What's proven, and where to look + +Open the page — each numbered section renders live, and the last one dumps the CSS +the engine generated. + +| # | Pattern | Source | +| --- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 1 | `variant({ prop, base, variants })` + `StyleProps` + `styledOptions` | **verbatim** from `mono/libs/ui/brand/src/AppBar/AppBarSection.tsx` | +| 2 | `css()` + `states()` composed, nested `@media`, responsive `{ _, xs }` values, `withComponent` | **verbatim** from `mono/libs/ui/login-or-register/src/OAuthButtons/elements.tsx` | +| 3 | System props — `` | real Gamut prop config + spacing/colour scales | +| 4 | `` and ``, including nested light-inside-dark | same contract as real Gamut | +| 5 | `` styled.span`…` `` template literals with `${props => …}` interpolation | mono has 234 of these | + +Verified mechanically: + +- `yarn typecheck` — clean. Token typesafety is intact: `fontSize={12}` is + **rejected** because 12 isn't a `fontSize` token. +- `yarn build` — clean, and the output bundle contains **zero** Emotion: + `serializeStyles`, `insertStyles`, `createCache`, `@emotion` all absent. +- Rendered in jsdom: 27 distinct generated classes, 3.8kB of CSS, `@media` rules + preserved, `--color-*` variables emitted, and state props like `isFancy` stay + **off** the DOM. + +## How the pieces fit + +``` +src/ + App.tsx the demo — real mono call sites, only the import changed + main.tsx wrapper + gamut-theme.d.ts the one remaining Emotion touchpoint (types only — see below) + gamut/ stands in for @codecademy/gamut-styles + index.ts the public surface (the "swap target") + props.ts css / variant / states / systemProps on the REAL Gamut 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 0000000000..d8a38de6fd --- /dev/null +++ b/spikes/emotion-to-gamut-poc/package.json @@ -0,0 +1,25 @@ +{ + "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": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codecademy/gamut-styles": "workspace:*", + "@codecademy/variance": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@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/project.json b/spikes/emotion-to-gamut-poc/project.json new file mode 100644 index 0000000000..dfb4876388 --- /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 0000000000..0c956cd8e2 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/App.tsx @@ -0,0 +1,263 @@ +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, + states, + styled, + styledOptions, + Text, + 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 StrokeButton = styled('button')( + css({ + border: 2, + borderColor: 'primary', + color: 'primary', + bg: 'transparent', + borderRadius: 'md', + cursor: 'pointer', + }) +); + +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}; +`; + +/* ════════════════════════════════════════════════════════════════════════════ + * 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'); + + return ( + + + + + Emotion → Gamut: same API, one import changed + + 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 + +
    + +
    +
    +            {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 0000000000..a406450c19 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut-theme.d.ts @@ -0,0 +1,29 @@ +import type { CoreTheme } from './gamut/theme'; + +/* The ONE remaining Emotion touchpoint, and it is types-only. + * + * `@codecademy/variance` anchors its whole prop type system to Emotion's `Theme` + * at exactly two lines: + * + * packages/variance/src/types/props.ts:1 import { Theme } from '@emotion/react'; + * packages/variance/src/types/config.ts:31 scale?: keyof Theme | MapScale | ArrayScale; + * + * Without this augmentation `Theme` is `{}`, so `keyof Theme` is `never` and every + * `scale: 'colors'` in the prop config degrades — you get cascading nonsense errors + * on `css()` calls and on component children. + * + * Every mono app already has a file exactly like this (18 of them, plus 1 in + * platform), so this is NOT extra migration work — it's what exists today. + * + * A real migration repoints those two variance lines at a Gamut-owned registry, + * after which this file becomes: + * + * declare module '@codecademy/gamut-styles' { + * export interface GamutTheme extends CoreTheme {} + * } + * + * Nothing at runtime imports Emotion — see src/gamut/, which is Emotion-free. */ +declare module '@emotion/react' { + // 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 0000000000..b069f306c0 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/components.tsx @@ -0,0 +1,63 @@ +import type { StyleProps } from '@codecademy/variance'; +import type { ReactNode } from 'react'; + +import { css, systemProps } from './props'; +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); + +export type ColorModeName = 'light' | 'dark'; + +/* `` — sets `data-color-mode`, which REASSIGNS the + * `--color-*` variables for the subtree. Nested modes therefore resolve from the + * nearest ancestor, which is what makes light-inside-dark work. */ +export const ColorMode = ({ + mode, + children, +}: { + mode: ColorModeName; + children?: ReactNode; +}) => ( + + {children} + +); + +/* Palette tokens that read as "dark", so `` can pick the mode giving + * the best contrast with body text — same contract as real Gamut. */ +const DARK_SURFACES = new Set([ + 'navy', + 'navy-800', + 'hyper', + 'hyper-500', + 'black', +]); + +/** `` — a fixed-palette surface that sets its own mode. */ +export const Background = ({ + bg, + children, + ...rest +}: BoxProps & { bg: string; children?: ReactNode }) => ( + + {children} + +); diff --git a/spikes/emotion-to-gamut-poc/src/gamut/index.ts b/spikes/emotion-to-gamut-poc/src/gamut/index.ts new file mode 100644 index 0000000000..bd24d516cd --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/index.ts @@ -0,0 +1,23 @@ +/* Stands in for `@codecademy/gamut-styles`. + * + * Everything a consumer needs comes from here — including `styled`, which today + * comes from `@emotion/styled`. That single import move is the entire migration: + * + * - import styled from '@emotion/styled'; + * + import { styled } from '@codecademy/gamut-styles'; + * + * Nothing else about a call site changes. Nothing here imports Emotion. */ + +export { styled } from './styled'; +export type { StyleFn, StyledComponent, StyledOptions } from './styled'; + +export { css, states, styledOptions, systemProps, variant } from './props'; + +export { GamutProvider, useTheme } from './theme'; +export type { CoreTheme } from './theme'; + +export { Background, Box, ColorMode, FlexBox, Text } from './components'; +export type { BoxProps, ColorModeName } from './components'; + +// used by the demo page to display the CSS the engine generated +export { allRules } from './sheet'; diff --git a/spikes/emotion-to-gamut-poc/src/gamut/props.ts b/spikes/emotion-to-gamut-poc/src/gamut/props.ts new file mode 100644 index 0000000000..fc10538606 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/props.ts @@ -0,0 +1,54 @@ +import { all } from '@codecademy/gamut-styles/dist/variance/config'; +import { variance } from '@codecademy/variance'; + +/* `css`, `variant` and `states` built on the REAL Gamut prop config + * (packages/gamut-styles/src/variance/config.ts) with the REAL `variance` — the + * same factories every mono call site already imports. Zero Emotion. + * + * This is the crux of the proof: these are not reimplementations. They already + * return `(props) => CSSObject` and already resolve at runtime, so functions, + * ternaries, `theme.x` access and computed keys all keep working. */ + +export const css = variance.createCss(all); + +const baseVariant = variance.createVariant(all); +const baseStates = variance.createStates(all); + +/* Wrapped only to record which props each one reads. Emotion made call sites + * hand-maintain that list via `styledOptions(['isFancy'])`; here `styled` can work + * it out, so those lists don't need porting. */ +export const variant = ((config: Parameters[0]) => + Object.assign(baseVariant(config), { + propNames: [config.prop ?? 'variant'], + })) as typeof baseVariant; + +export const states = ((config: Parameters[0]) => + Object.assign(baseStates(config), { + propNames: Object.keys(config), + })) as typeof baseStates; + +/** Every system prop at once — what `Box` and friends apply. */ +export const systemProps = variance.create(all); + +/** Prop names to keep off the DOM. The prop config is already the source of truth, + * so `@emotion/is-prop-valid` isn't needed. */ +export const systemPropNames = new Set([ + ...Object.keys(all), + 'theme', + 'variant', + 'mode', +]); + +/* Compatibility shim so `styled('div', styledOptions)` and `styledOptions(['size'])` + * call sites compile unchanged. Deliberately provides NO `shouldForwardProp` — if + * it did, it would override the engine's own filtering and leak system props onto + * the DOM. `styled` derives the filter from the style functions instead, so this + * has genuinely nothing left to do. */ +type StyledOptionsShim = { shouldForwardProp?: (prop: string) => boolean } & (< + El = unknown, + Additional extends string = never +>( + additional?: readonly Additional[] +) => { shouldForwardProp?: (prop: string) => boolean }); + +export const styledOptions = (() => ({})) as StyledOptionsShim; diff --git a/spikes/emotion-to-gamut-poc/src/gamut/sheet.ts b/spikes/emotion-to-gamut-poc/src/gamut/sheet.ts new file mode 100644 index 0000000000..aaa9d4b9cd --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/sheet.ts @@ -0,0 +1,126 @@ +import type { CSSObject } from '@codecademy/variance'; + +/* The ONLY piece that replaces Emotion: turn a resolved style object into a class + * name and get the rule into the page. Everything else in the pipeline — + * `css()`, `variant()`, `states()`, system props — is the real `variance` code, + * reused untouched. That is why call sites don't have to change. */ + +// FNV-1a. Stable across server and client, so class names can't mismatch. +const hash = (input: string): string => { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +}; + +const kebab = (prop: string) => + prop.startsWith('--') + ? prop + : prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +// properties that take a bare number; everything else gets `px`, as Emotion does +const UNITLESS = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'columnCount', + 'flex', + 'flexGrow', + 'flexShrink', + 'fontWeight', + 'gridColumn', + 'gridRow', + 'lineHeight', + 'opacity', + 'order', + 'zIndex', + 'zoom', +]); + +const value = (prop: string, raw: string | number) => + typeof raw === 'number' && raw !== 0 && !UNITLESS.has(prop) + ? `${raw}px` + : String(raw); + +type Block = { at: string[]; selector: string; decls: string[] }; + +// `&:hover` keeps its anchor; a bare `> li` nests as a descendant, as stylis does +const nest = (key: string, parent: string) => + key.includes('&') ? key.replace(/&/g, parent) : `${parent} ${key}`; + +/* Flattens a nested CSSObject into ordered blocks. Nested objects are either + * at-rules (`@media`) or selectors — the only two shapes `variance` emits. */ +const serialize = ( + styles: CSSObject, + selector = '&', + at: string[] = [], + out: Block[] = [] +): Block[] => { + const decls: string[] = []; + + Object.entries(styles).forEach(([key, raw]) => { + if (raw === undefined || raw === null || raw === '') return; + + if (typeof raw === 'object') { + if (Array.isArray(raw)) return; + if (key.startsWith('@')) + serialize(raw as CSSObject, selector, [...at, key], out); + else serialize(raw as CSSObject, nest(key, selector), at, out); + return; + } + + decls.push(`${kebab(key)}:${value(key, raw as string | number)}`); + }); + + if (decls.length) out.push({ at, selector, decls }); + return out; +}; + +const cssText = (blocks: Block[], className: string) => + blocks + .map(({ at, selector, decls }) => { + const body = `${selector.replace(/&/g, `.${className}`)}{${decls.join( + ';' + )}}`; + return at.reduceRight((inner, rule) => `${rule}{${inner}}`, body); + }) + .join(''); + +const rules = new Map(); +const inSheet = new Set(); +let sheetEl: HTMLStyleElement | undefined; + +const element = () => { + if (sheetEl) return sheetEl; + sheetEl = document.createElement('style'); + sheetEl.setAttribute('data-gamut', ''); + document.head.appendChild(sheetEl); + return sheetEl; +}; + +/** Resolved styles in, class name out. Each unique rule is inserted exactly once. */ +export const inject = (styles: CSSObject): string => { + const blocks = serialize(styles); + if (!blocks.length) return ''; + + // hash the declarations, so structurally identical styles share one class + const canonical = blocks + .map( + ({ at, selector, decls }) => + `${at.join('')}${selector}{${decls.join(';')}}` + ) + .join(''); + const className = `gmt-${hash(canonical)}`; + + if (!rules.has(className)) rules.set(className, cssText(blocks, className)); + if (!inSheet.has(className)) { + inSheet.add(className); + element().appendChild(document.createTextNode(rules.get(className)!)); + } + + return className; +}; + +/** Everything emitted so far — used by the page to show the generated CSS. */ +export const allRules = () => [...rules.entries()]; diff --git a/spikes/emotion-to-gamut-poc/src/gamut/styled.tsx b/spikes/emotion-to-gamut-poc/src/gamut/styled.tsx new file mode 100644 index 0000000000..4e8a25ca1f --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/styled.tsx @@ -0,0 +1,211 @@ +import type { CSSObject, ThemeProps } from '@codecademy/variance'; +import { + type ComponentPropsWithoutRef, + type ElementType, + type ForwardRefExoticComponent, + type RefAttributes, + createElement, + forwardRef, +} from 'react'; + +import { systemPropNames } from './props'; +import { inject } from './sheet'; +import { useTheme } from './theme'; + +/* `styled` with Emotion's composed call shape — `styled(C)(a, b, c)` — where each + * argument is a `(props) => CSSObject` from css()/variant()/states() or written by + * hand. Identical signature, so call sites change only their import. */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type StyleFn = (props: any) => CSSObject; + +export type StyledComponent< + T extends ElementType, + P +> = ForwardRefExoticComponent< + Omit, keyof P> & P & RefAttributes +> & { + /** Emotion's `withComponent` — same styles, different element. */ + withComponent: (next: N) => StyledComponent; +}; + +const isPlainObject = (v: unknown): v is CSSObject => + typeof v === 'object' && v !== null && !Array.isArray(v); + +// deep merge, later wins — the precedence Emotion gives composed arguments +const merge = (target: CSSObject, source: CSSObject): CSSObject => { + Object.entries(source).forEach(([key, val]) => { + const existing = (target as Record)[key]; + (target as Record)[key] = + isPlainObject(val) && isPlainObject(existing) + ? merge({ ...existing }, val) + : val; + }); + return target; +}; + +const STYLES = Symbol.for('gamut.styles'); +const TARGET = Symbol.for('gamut.target'); +type Meta = { [STYLES]?: StyleFn[]; [TARGET]?: ElementType }; + +export type StyledOptions = { shouldForwardProp?: (prop: string) => boolean }; + +/* Which props the style functions read — variance parsers and our css/variant/ + * states wrappers all report this, so state props are filtered automatically. */ +const consumedProps = (fns: StyleFn[]) => { + const consumed = new Set(); + fns.forEach((fn) => + (fn as { propNames?: string[] }).propNames?.forEach((n) => consumed.add(n)) + ); + return consumed; +}; + +/* Emotion's rule (string tags filter style props, component targets forward + * everything) plus: `$`-prefixed transient props and any consumed prop never + * reach the DOM. */ +const defaultForward = + (target: ElementType, consumed: Set) => (prop: string) => { + if (prop.startsWith('$') || consumed.has(prop)) return false; + return typeof target === 'string' ? !systemPropNames.has(prop) : true; + }; + +const styledFactory = + (Component: T, options: StyledOptions = {}) => +

    >( + ...styleFns: StyleFn[] + ): StyledComponent => { + const meta = Component as unknown as Meta; + + /* Extending an already-styled component flattens into ONE class rather than + * relying on stylesheet order — more deterministic than Emotion. */ + const target = (meta[TARGET] ?? Component) as ElementType; + const fns = [...(meta[STYLES] ?? []), ...styleFns]; + const consumed = consumedProps(fns); + const shouldForward = + options.shouldForwardProp ?? defaultForward(target, consumed); + + const Styled = forwardRef>( + (props, ref) => { + const theme = useTheme(); + + const resolved = fns.reduce( + (acc, fn) => merge(acc, fn({ ...props, theme } as ThemeProps)), + {} + ); + + const className = [inject(resolved), props.className] + .filter(Boolean) + .join(' '); + + const domProps: Record = { ref, className }; + Object.entries(props).forEach(([key, val]) => { + if (key !== 'className' && shouldForward(key)) domProps[key] = val; + }); + + return createElement(target, domProps); + } + ); + + Styled.displayName = `styled(${ + typeof target === 'string' ? target : 'Component' + })`; + + const withMeta = Styled as unknown as Meta & { + withComponent: (next: N) => StyledComponent; + }; + withMeta[STYLES] = fns; + withMeta[TARGET] = target; + withMeta.withComponent = (next: N) => + styledFactory(next)

    (...fns); + + return Styled as unknown as StyledComponent; + }; + +/* ---- `styled.div\`…\`` template literals ------------------------------------- + * mono has 234 of these. A small CSS-block parser covers them, including + * `${props => …}` interpolation. Not a full CSS grammar; no comments or at-rules. + * -------------------------------------------------------------------------- */ + +const camel = (p: string) => + p.startsWith('--') + ? p + : p.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + +const addDecl = (out: Record, chunk: string) => { + const i = chunk.indexOf(':'); + if (i === -1) return; + const prop = chunk.slice(0, i).trim(); + const val = chunk.slice(i + 1).trim(); + if (prop && val) out[camel(prop)] = val; +}; + +const parseCss = (input: string): CSSObject => { + const out: Record = {}; + let buffer = ''; + let selector = ''; + let depth = 0; + + for (const char of input) { + if (char === '{') { + if (depth === 0) { + selector = buffer.trim(); + buffer = ''; + } else buffer += char; + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + out[selector] = parseCss(buffer); + buffer = ''; + } else buffer += char; + } else if (char === ';' && depth === 0) { + addDecl(out, buffer); + buffer = ''; + } else buffer += char; + } + + if (depth === 0 && buffer.trim()) addDecl(out, buffer); + return out as CSSObject; +}; + +export type Interpolation = + | string + | number + | null + | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | ((props: any) => unknown); + +type TemplateTag = < + P extends object = Record +>( + strings: TemplateStringsArray, + ...interpolations: Interpolation[] +) => StyledComponent; + +const templateFactory = + (target: T): TemplateTag => +

    >( + strings: TemplateStringsArray, + ...interpolations: Interpolation[] + ) => + styledFactory(target)

    ((props) => { + let source = strings[0]; + interpolations.forEach((raw, i) => { + const val = typeof raw === 'function' ? raw(props) : raw; + source += `${val ?? ''}${strings[i + 1]}`; + }); + return parseCss(source); + }); + +type StyledFactory = typeof styledFactory & { + [Tag in keyof JSX.IntrinsicElements]: TemplateTag; +}; + +/** `styled(Component)(…)` and `styled.div\`…\`` from one export, as Emotion. */ +export const styled = new Proxy(styledFactory, { + get: (base, tag: string, receiver) => + Reflect.has(base, tag) + ? Reflect.get(base, tag, receiver) + : templateFactory(tag as keyof JSX.IntrinsicElements), +}) as StyledFactory; diff --git a/spikes/emotion-to-gamut-poc/src/gamut/theme.tsx b/spikes/emotion-to-gamut-poc/src/gamut/theme.tsx new file mode 100644 index 0000000000..9632bc2773 --- /dev/null +++ b/spikes/emotion-to-gamut-poc/src/gamut/theme.tsx @@ -0,0 +1,55 @@ +import { coreTheme } from '@codecademy/gamut-styles/dist/themes/core'; +import { type ReactNode, createContext, useContext } from 'react'; + +/* Replaces Emotion's `ThemeProvider` + the + * `declare module '@emotion/react' { interface Theme … }` augmentation. */ + +export type CoreTheme = typeof coreTheme; + +const ThemeContext = createContext(coreTheme); +ThemeContext.displayName = 'GamutTheme'; + +export const useTheme = () => useContext(ThemeContext); + +/** Stand-in for `GamutProvider`. Supplies the theme and Gamut's CSS variables. */ +export const GamutProvider = ({ + theme = coreTheme, + children, +}: { + theme?: CoreTheme; + children?: ReactNode; +}) => ( + + + {children} + +); + +/* Emits `--color-*` for every semantic alias in each mode. Colour mode then works + * by REASSIGNING these variables on a wrapper, which is how nested modes resolve + * from the nearest ancestor. (Selector-based conditions can't do that: an element + * inside light-inside-dark matches both, and source order wins over proximity.) */ +const Variables = ({ theme }: { theme: CoreTheme }) => { + const { modes, colors } = theme as unknown as { + modes: Record<'light' | 'dark', Record>; + colors: Record; + }; + + const block = (mode: 'light' | 'dark') => + Object.entries(modes[mode]) + .map(([alias, token]) => `--color-${alias}:${colors[token] ?? token};`) + .join(''); + + return ( +