From 540bb5969c399ec0b19188b38967a5fdbc463d6f Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 18 Jun 2026 16:36:14 -0400 Subject: [PATCH 1/8] chore: add design-sync config for Claude Design upload Adds .design-sync/ config set for syncing @codecademy/gamut to the [DRAFT] Gamut Design System project on claude.ai/design (71 components). - config.json: project ID, storybook shape, build cmd, provider config, card mode overrides, titleMap for docs-only stories - tsconfig.json: resolves ~styleguide webpack aliases for esbuild - overrides/bundle.mjs: enables minification (icons-as-code pushes unminified bundle to 5.2MB, over the 5MB upload limit) - previews/SkipToContent.tsx: custom preview for hidden skip-link component - NOTES.md: setup notes and re-apply instructions for dist patches - packages/gamut/package.json: add "types" field so design-sync converter can locate dist/index.d.ts in this NX monorepo (NX omits it by default) - .gitignore: exclude .ds-sync/, ds-bundle/, and ephemeral .design-sync/ subdirs Co-Authored-By: Claude Sonnet 4.6 --- .design-sync/NOTES.md | 50 +++ .design-sync/config.json | 68 ++++ .design-sync/overrides/bundle.mjs | 398 ++++++++++++++++++++++++ .design-sync/previews/SkipToContent.tsx | 34 ++ .design-sync/tsconfig.json | 13 + .gitignore | 8 + packages/gamut/package.json | 3 +- 7 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 .design-sync/NOTES.md create mode 100644 .design-sync/config.json create mode 100644 .design-sync/overrides/bundle.mjs create mode 100644 .design-sync/previews/SkipToContent.tsx create mode 100644 .design-sync/tsconfig.json diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md new file mode 100644 index 0000000000..fd1afebb11 --- /dev/null +++ b/.design-sync/NOTES.md @@ -0,0 +1,50 @@ +# Design Sync Notes — Gamut + +## Setup + +- Package manager: yarn (hoisted node_modules at repo root) +- Node version: v22 (.nvmrc: v22.13.1) +- Shape: storybook — `.storybook` at `packages/styleguide/.storybook` +- DS entry: `packages/gamut/dist/index.js` (already compiled — barrel re-exports) +- node_modules for converter: pass `--node-modules /Users/cass/code/cc/gamut/node_modules` (react/react-dom hoisted to root) + +## Storybook build — must use NX, not plain `npx storybook build` + +`main.ts` loads `require.resolve('@nx/react/plugins/storybook')` which requires NX project context. +Plain `npx storybook build` fails with: `TypeError: Cannot read properties of undefined (reading 'data')` in `@nx/react/plugins/storybook/index.js`. + +**Build command** (run from repo root): + +```bash +yarn nx run styleguide:build-storybook --outputDir="$(pwd)/.design-sync/sb-reference" +``` + +## Fonts — CDN-served, not local + +Fonts are served from `https://www.codecademy.com/gamut` (defined in `packages/gamut-styles/src/remoteAssets/fonts.ts`). +Fonts loaded dynamically via Emotion `@font-face` rules injected by `` inside `GamutProvider`. + +- Core theme: Apercu (regular, italic, bold, bold-italic) + Suisse (bold, regular) +- Other themes may have different font stacks + +Config key: `runtimeFontPrefixes: ["https://www.codecademy.com/gamut"]` + +## Dist patch — re-apply after `yarn build` + +`packages/gamut/dist/Video/lib/VidstackPlayer.js` line 4: import changed from `.scss` to `.css` for esbuild compatibility. +`packages/gamut/dist/Video/styles/vds_base_theme.css` (empty): created alongside the `.scss` so the import resolves. + +`Markdown` imports `Video` transitively, so both would fail without this patch. +After any `yarn build` re-run, re-apply: + +```bash +sed -i '' "s|import '../styles/vds_base_theme.scss'|import '../styles/vds_base_theme.css' // patched for design-sync: .scss unsupported by esbuild|" packages/gamut/dist/Video/lib/VidstackPlayer.js +printf '/* @ds-css-runtime: vds_base_theme.scss patched to .css stub for esbuild compatibility */\n' > packages/gamut/dist/Video/styles/vds_base_theme.css +``` + +The `@ds-css-runtime` marker in the CSS stub is important: esbuild extracts the stub into `_ds_bundle.css`, and the validator recognises the marker as "CSS-in-JS, no compiled stylesheet" instead of failing with `[CSS_PLACEHOLDER]`. + +## Re-sync risks + +- Font CDN (`https://www.codecademy.com/gamut`) must be reachable during compare runs — storybook and previews both load fonts from there +- Storybook webpack aliases point at package `src/` directly (e.g. `@codecademy/gamut → packages/gamut/src`) — storybook builds TS source, not dist; converter bundles dist diff --git a/.design-sync/config.json b/.design-sync/config.json new file mode 100644 index 0000000000..19ba53d7fa --- /dev/null +++ b/.design-sync/config.json @@ -0,0 +1,68 @@ +{ + "projectId": "266cff67-7436-40c1-9899-2f4830adbc65", + "pkg": "@codecademy/gamut", + "shape": "storybook", + "storybookConfigDir": "packages/styleguide/.storybook", + "storybookStatic": ".design-sync/sb-reference", + "buildCmd": "yarn nx run-many --target=build --projects=variance,gamut-styles,gamut-icons,gamut-patterns,gamut-illustrations,gamut && sed -i '' \"s|import '../styles/vds_base_theme.scss'|import '../styles/vds_base_theme.css' // patched for design-sync|\" packages/gamut/dist/Video/lib/VidstackPlayer.js && printf '/* @ds-css-runtime: vds_base_theme.scss patched to .css stub for esbuild compatibility */\\n' > packages/gamut/dist/Video/styles/vds_base_theme.css", + "extraEntries": ["@codecademy/gamut-styles"], + "runtimeFontPrefixes": ["https://www.codecademy.com/gamut"], + "tsconfig": "../../../.design-sync/tsconfig.json", + "libOverrides": { + "bundle.mjs": "enable minification — icons-as-code push unminified bundle to 5.2MB, over the 5MB upload limit" + }, + "titleMap": { + "Border": null, + "Color": null, + "Layout": null, + "Positioning": null, + "Space": null, + "Responsiveproperties": null, + "Button": null, + "Mini": null, + "Regular": null, + "Illustrations": null, + "Patterns": null + }, + "overrides": { + "Card": { "cardMode": "column" }, + "PopoverContainer": { "cardMode": "column" }, + "Tag": { "cardMode": "column" }, + "GridBox": { "cardMode": "column" }, + "ConnectedFormGroup": { "cardMode": "column" }, + "SubmitButton": { "cardMode": "column" }, + "FormGroup": { "cardMode": "column" }, + "Radio": { "cardMode": "column" }, + "Pagination": { "cardMode": "column" }, + "Tabs": { "cardMode": "column" }, + "InfoTip": { "cardMode": "column" }, + "PreviewTip": { "cardMode": "column" }, + "ToolTip": { "cardMode": "column" }, + "Anchor": { "cardMode": "column" }, + "Text": { "cardMode": "column" }, + "SelectDropdown": { "cardMode": "single", "primaryStory": "Default" }, + "List": { "cardMode": "single", "primaryStory": "Default" }, + "DataList": { "cardMode": "single", "primaryStory": "Default" }, + "DataTable": { "cardMode": "single", "primaryStory": "Default" }, + "Alert": { "cardMode": "single", "primaryStory": "Default" }, + "Popover": { "cardMode": "single", "primaryStory": "Default" }, + "Toaster": { "cardMode": "single", "primaryStory": "Default" }, + "BarChart": { "cardMode": "single", "primaryStory": "Default" }, + "DatePicker": { "cardMode": "single", "primaryStory": "Default" }, + "GridForm": { "cardMode": "single", "primaryStory": "Default" }, + "Video": { "cardMode": "column" }, + "Markdown": { "cardMode": "single", "primaryStory": "Default" } + }, + "provider": { + "component": "GamutProvider", + "props": { + "theme": { "$ref": "coreTheme" } + }, + "inner": { + "component": "ColorMode", + "props": { + "mode": "light" + } + } + } +} diff --git a/.design-sync/overrides/bundle.mjs b/.design-sync/overrides/bundle.mjs new file mode 100644 index 0000000000..bc7af8bf62 --- /dev/null +++ b/.design-sync/overrides/bundle.mjs @@ -0,0 +1,398 @@ +// esbuild bundling: dist entry → IIFE at window., plus the +// `/* @ds-bundle: {...} */` first-line header the claude.ai/design app's +// self-check parses. + +import { build } from 'esbuild'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +// inlined from common.mjs — override files can't do relative imports to the lib dir +const IIFE_IMPORT_META_DEFINE = { + 'import.meta.url': '"https://ds-preview.invalid/"', + 'import.meta.env': + '{"MODE":"development","DEV":true,"PROD":false,"SSR":false,"BASE_URL":"/"}', +}; + +// Resolve the package's browser entry. Prefer ESM (tree-shakes cleaner). +// `soft` → return null on miss instead of exiting (caller synthesizes from src/). +export function resolveDistEntry({ + pkgDir, + pkgJson, + override, + pkgName, + soft = false, +}) { + if (override) { + const p = resolve(override); + if (!existsSync(p)) { + console.error( + `[NO_DIST] --entry ${override} doesn't exist — run the DS's build.` + ); + if (soft) return null; + process.exit(1); + } + return p; + } + // exports conditions can nest ({types, default:{types, default}}) — flatten. + const str = (v) => + typeof v === 'string' ? v : v?.default ? str(v.default) : null; + const cand = [ + pkgJson.module, + str(pkgJson.exports?.['.']?.import), + str(pkgJson.exports?.['.']?.default), + str(pkgJson.exports?.['.']), + pkgJson.main, + ].filter((c) => typeof c === 'string'); + for (const c of cand) { + const p = join(pkgDir, c); + if (existsSync(p)) return p; + } + if (soft) return null; + console.error( + `[NO_DIST] ${pkgName} has no built entry (tried ${cand.join( + ', ' + )} under ${pkgDir}). ` + + `Run the DS's build script, or use 'npm install ${pkgName}@latest' in a scratch dir and pass --node-modules.` + ); + process.exit(1); +} + +// react/react-dom are externals → resolved to window.React / window.ReactDOM. +// Everything else is bundled from NODE_MODULES. +export const reactShim = { + name: 'react-global', + setup(b) { + b.onResolve( + { filter: /^react(\/(jsx-(dev-)?runtime|compiler-runtime))?$/ }, + () => ({ + path: 'react-shim', + namespace: 'shim', + }) + ); + b.onResolve({ filter: /^react-dom(\/client)?$/ }, () => ({ + path: 'react-dom-shim', + namespace: 'shim', + })); + // react-is must match window.React's $$typeof symbols. A bundled copy + // from node_modules can be a different major (e.g. react-is@19 checks + // for 'react.transitional.element' while react@18 emits 'react.element'), + // which makes isElement() always false and breaks components that + // branch on it (count badges, nav indicators, …). + b.onResolve({ filter: /^react-is$/ }, () => ({ + path: 'react-is-shim', + namespace: 'shim', + })); + // scheduler must be the same instance window.React uses internally; a + // second bundled copy breaks concurrent rendering. + b.onResolve({ filter: /^scheduler(\/|$)/ }, () => ({ + path: 'scheduler-shim', + namespace: 'shim', + })); + b.onLoad({ filter: /^react-shim$/, namespace: 'shim' }, () => ({ + // jsx(type, props, key) — key is the 3rd arg in the automatic runtime, + // NOT a child. createElement reads key from props, so merge it in. + contents: `var R=window.React; +function jsx(t,p,k){return R.createElement(t,k===void 0?p:Object.assign({key:k},p));} +module.exports=R; +module.exports.jsx=jsx;module.exports.jsxs=jsx;module.exports.jsxDEV=jsx; +module.exports.Fragment=R.Fragment;`, + loader: 'js', + })); + b.onLoad({ filter: /^react-dom-shim$/, namespace: 'shim' }, () => ({ + // preload/preinit/preconnect/prefetchDNS (React 18.3+/19 resource + // hints) must exist — some DSes call them at Provider mount. + contents: + 'var D=window.ReactDOM,n=function(){};' + + 'module.exports=Object.assign({preload:n,preinit:n,preconnect:n,prefetchDNS:n,preloadModule:n,preinitModule:n},D);', + loader: 'js', + })); + b.onLoad({ filter: /^react-is-shim$/, namespace: 'shim' }, () => ({ + contents: `var R=window.React; +var FWD=Symbol.for("react.forward_ref"),MEMO=Symbol.for("react.memo"),PORTAL=Symbol.for("react.portal"),LAZY=Symbol.for("react.lazy"); +function tt(o){return o!=null&&typeof o==="object"?(R.isValidElement(o)?(o.type&&o.type.$$typeof)||o.type:o.$$typeof):undefined} +exports.typeOf=tt; +exports.isElement=R.isValidElement; +exports.isValidElementType=function(t){return typeof t==="string"||typeof t==="function"||t===R.Fragment||t===R.Suspense||t===R.StrictMode||t===R.Profiler||(t!=null&&typeof t==="object"&&t.$$typeof!=null)}; +exports.isFragment=function(o){return R.isValidElement(o)&&o.type===R.Fragment}; +exports.isSuspense=function(o){return R.isValidElement(o)&&o.type===R.Suspense}; +exports.isPortal=function(o){return o!=null&&o.$$typeof===PORTAL}; +exports.isForwardRef=function(o){return tt(o)===FWD}; +exports.isMemo=function(o){return tt(o)===MEMO}; +exports.isLazy=function(o){return tt(o)===LAZY}; +exports.isContextProvider=exports.isContextConsumer=exports.isProfiler=exports.isStrictMode=function(){return false}; +exports.ForwardRef=FWD;exports.Memo=MEMO;exports.Portal=PORTAL;exports.Lazy=LAZY; +exports.Fragment=R.Fragment;exports.Suspense=R.Suspense;exports.StrictMode=R.StrictMode;exports.Profiler=R.Profiler;`, + loader: 'js', + })); + b.onLoad({ filter: /^scheduler-shim$/, namespace: 'shim' }, () => ({ + // A DS dist/ rarely imports scheduler directly — when it does, it + // means react-dom leaked into the dist. Surface it. + contents: `throw new Error("[SCHEDULER_MISSING] this DS's dist/ imports 'scheduler' directly — usually react-dom leaked into the dist. Check the DS build's externals.");`, + loader: 'js', + })); + }, +}; + +// Build a resolve plugin from tsconfig compilerOptions.paths. esbuild's +// built-in `tsconfig` option only applies paths to files covered by that +// tsconfig, which the synth entry (in OUT) isn't — so we resolve explicitly. +export function tsconfigPathsPlugin(tsconfigPath) { + let paths, baseUrl; + try { + // Strip // and /* */ comments — tsconfig.json permits them, JSON.parse doesn't. + const raw = readFileSync(tsconfigPath, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); + ({ paths, baseUrl = '.' } = JSON.parse(raw).compilerOptions ?? {}); + } catch { + return null; + } + if (!paths) return null; + const base = resolve(dirname(tsconfigPath), baseUrl); + const rules = Object.entries(paths).map(([k, v]) => ({ + prefix: k.replace(/\*$/, ''), + targets: (Array.isArray(v) ? v : [v]).map((t) => + resolve(base, t.replace(/\*$/, '')) + ), + wild: k.endsWith('*'), + })); + // Filter on the alias prefixes so the plugin only fires for @/-style paths, + // not every node_modules import. + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filter = new RegExp( + `^(?:${rules.map((r) => esc(r.prefix)).join('|')})` + ); + const exts = [ + '', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '/index.ts', + '/index.tsx', + '/index.js', + '/index.jsx', + ]; + return { + name: 'tsconfig-paths', + setup(b) { + b.onResolve({ filter }, (args) => { + for (const r of rules) { + if (r.wild ? !args.path.startsWith(r.prefix) : args.path !== r.prefix) + continue; + const tail = r.wild ? args.path.slice(r.prefix.length) : ''; + for (const t of r.targets) { + const stem = join(t, tail); + for (const ext of exts) { + if (existsSync(stem + ext)) return { path: stem + ext }; + } + } + } + return undefined; + }); + }, + }; +} + +// Bundle `entry` to a single IIFE at the project root. Returns paths + +// inlinedExternals (npm packages esbuild pulled in, derived from the +// metafile — react/react-dom/react-is are externalized so excluded). +// Options shared by the runtime bundle pass and the export-evidence pass — +// one source so the two resolutions can never drift: a loader or plugin +// present in one but not the other would either throw the evidence pass +// into its silent null-fallback or, worse, make the evidence diverge from +// what the runtime bundle actually contains. +function sharedBuildOptions({ nodePaths, tsconfig }) { + const pathsPlugin = tsconfig ? tsconfigPathsPlugin(tsconfig) : null; + const plugins = [reactShim]; + if (pathsPlugin) plugins.unshift(pathsPlugin); + return { + bundle: true, + platform: 'browser', + target: 'es2020', + nodePaths: [nodePaths], + plugins, + metafile: true, + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + '.woff': 'dataurl', + '.woff2': 'dataurl', + }, + // No '.css' loader override: some DSes ship scss already compiled to + // .css with css-modules hashes pre-baked, and esbuild's default 'css' + // loader (unlike 'local-css') preserves them. + minify: true, + define: { 'process.env.NODE_ENV': '"development"' }, + }; +} + +export async function bundleToIife({ + entry, + globalName, + nodePaths, + out, + tsconfig, +}) { + const bundleJs = join(out, '_ds_bundle.js'); + const bundleCss = join(out, '_ds_bundle.css'); + const shared = sharedBuildOptions({ nodePaths, tsconfig }); + let buildResult; + try { + buildResult = await build({ + ...shared, + entryPoints: [entry], + format: 'iife', + globalName, + // __dsMainNs (set by package-build when extraEntries are present) is + // the main package's runtime namespace — Object.assign it over the + // merged IIFE exports so main-package names win over icon collisions. + footer: { + js: `window.${globalName}=${globalName}.__dsMainNs?Object.assign({},${globalName},${globalName}.__dsMainNs,{__dsMainNs:undefined}):${globalName};`, + }, + outfile: bundleJs, + logLevel: 'warning', + // iife can't evaluate import.meta.url natively — define it here only. + // The esm evidence pass supports it natively, and a define is not + // resolution-affecting, so the two graphs still resolve identically. + // Merged over the shared define (a bare override would drop NODE_ENV). + define: { ...shared.define, ...IIFE_IMPORT_META_DEFINE }, + // Suppress CSS extraction: Emotion is CSS-in-JS so no CSS is shipped in + // _ds_bundle.css — treating CSS imports as empty lets package-build.mjs + // write the @ds-css-runtime stub that tells the validator this is intentional. + loader: { '.css': 'empty' }, + }); + } catch (e) { + // Tag unbuilt workspace siblings — package exists in node_modules but its + // entry points at a dist/ that hasn't been built. + const unresolved = [ + ...new Set( + (e.errors ?? []) + .map((er) => er.text.match(/Could not resolve "([^"]+)"/)?.[1]) + .filter(Boolean) + ), + ]; + const siblings = unresolved.filter((p) => { + const pj = join(nodePaths, p, 'package.json'); + if (!existsSync(pj)) return false; + try { + const j = JSON.parse(readFileSync(pj, 'utf8')); + const ent = j.module ?? j.main ?? 'index.js'; + return !existsSync(join(nodePaths, p, ent)); + } catch { + return false; + } + }); + if (siblings.length) { + console.error( + `[WORKSPACE_SIBLING] ${siblings.join( + ', ' + )} exist in node_modules but aren't built (no dist entry). ` + + `Run their build, or npm install the published versions.` + ); + } else if (unresolved.length) { + console.error( + `[UNRESOLVED_IMPORT] ${unresolved.join( + ', ' + )} — missing from node_modules.` + ); + } + throw e; + } + const REACT_PKGS = new Set(['react', 'react-dom', 'react-is']); + const inlinedExternals = [ + ...new Set( + Object.keys(buildResult?.metafile?.inputs ?? {}) + .map( + (p) => p.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//)?.[1] + ) + .filter((pkg) => pkg && !REACT_PKGS.has(pkg)) + ), + ].sort(); + console.error(` bundle: ${(statSync(bundleJs).size / 1024).toFixed(0)} KB`); + console.error(` inlined npm packages: ${inlinedExternals.length}`); + return { bundleJs, bundleCss, inlinedExternals }; +} + +// Evidence pass for the provider gate: rebuild the same entry as ESM +// (write:false, nothing touches disk) and read esbuild's own export list — +// the same resolution that produced the runtime bundle, so presence/absence +// is provable where a .d.ts scan is heuristic. One residual unknowable: +// `export * from ` isn't statically enumerable (esbuild emits a +// runtime __reExport and the names are missing from `exports`), and the +// metafile carries no signal for WHICH import is a star — so any bundled +// CJS input downgrades absence from provable to unverifiable (cjsPresent). +// That over-triggers for plain CJS imports (a bundled lodash softens the +// gate), which is the accepted price of never minting a false fatal. +// Returns null on ANY failure: the caller must fall back to scan evidence — +// this pass may only ever change a gate verdict, never fail a build the +// real bundle pass accepted. +export async function bundleExportEvidence({ entry, nodePaths, tsconfig }) { + try { + const r = await build({ + ...sharedBuildOptions({ nodePaths, tsconfig }), + entryPoints: [entry], + format: 'esm', + write: false, + outfile: '__ds_export_evidence.mjs', + logLevel: 'silent', + }); + const out = Object.values(r.metafile?.outputs ?? {})[0]; + const exports = new Set( + (out?.exports ?? []).filter((n) => n !== '__dsMainNs') + ); + // The react-family shims are authored as CJS and appear in every build's + // inputs under the 'shim:' namespace — they can't hide DS names, so + // only genuinely-bundled CJS counts toward the unverifiable signal. + const cjsPresent = Object.entries(r.metafile?.inputs ?? {}).some( + ([k, i]) => i.format === 'cjs' && !k.startsWith('shim:') + ); + return { exports, cjsPresent }; + } catch { + return null; + } +} + +// Prepend the `/* @ds-bundle: {…} */` first-line header. The +// claude.ai/design app reads this; format is load-bearing — namespace + +// components feed the consuming agent and the ds_manifest; +// sourceHashes + inlinedExternals drive the keep-vs-rebuild decision. +// `*/` inside the JSON is escaped so the comment can't terminate early. +export function stampHeader( + bundleJs, + { namespace, components, inlinedExternals } +) { + const body = readFileSync(bundleJs, 'utf8'); + const out = dirname(bundleJs); + // Keyed by per-component output paths — what decideBundleRebuild compares + // against. Includes .d.ts and .prompt.md so contract/doc-only edits also + // surface in the incremental-upload diff. + const sourceHashes = Object.fromEntries( + components.flatMap((c) => { + const base = `components/${c.group}/${c.name}/${c.name}`; + return ['.jsx', '.d.ts', '.prompt.md'] + .map((ext) => base + ext) + .filter((rel) => existsSync(join(out, rel))) + .map((rel) => [ + rel, + createHash('sha256') + .update(readFileSync(join(out, rel))) + .digest('hex') + .slice(0, 12), + ]); + }) + ); + const meta = { + namespace, + components: components.map((c) => ({ + name: c.name, + sourcePath: `components/${c.group}/${c.name}/${c.name}.jsx`, + })), + sourceHashes, + inlinedExternals, + builtBy: 'cc-design-sync', + }; + const headerJson = JSON.stringify(meta).replace(/\*\//g, '*\\/'); + writeFileSync(bundleJs, `/* @ds-bundle: ${headerJson} */\n` + body); +} diff --git a/.design-sync/previews/SkipToContent.tsx b/.design-sync/previews/SkipToContent.tsx new file mode 100644 index 0000000000..1b3addb574 --- /dev/null +++ b/.design-sync/previews/SkipToContent.tsx @@ -0,0 +1,34 @@ +// @dsCard +// SkipToContent is a focus-visible skip link (0px height until focused). +// This preview shows it in its visible/focused state. +import React from 'react'; +import { SkipToContent } from '@codecademy/gamut'; + +export function Preview() { + return ( +
+

+ Skip link (visible on keyboard focus): +

+ {/* Render with inline styles to simulate the focused/visible state */} + + Skip to main content + +

+ Hidden until focused via keyboard Tab navigation. +

+
+ ); +} diff --git a/.design-sync/tsconfig.json b/.design-sync/tsconfig.json new file mode 100644 index 0000000000..cf1eadce7d --- /dev/null +++ b/.design-sync/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~styleguide/argTypes": [ + "../packages/styleguide/.storybook/argTypes/index" + ], + "~styleguide/blocks": [ + "../packages/styleguide/.storybook/components/index" + ] + } + } +} diff --git a/.gitignore b/.gitignore index e877cbac30..3b1ee5bc98 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,11 @@ report*.json # Alpha Publish Artifacts alpha-publish-manifest.json alpha-publish-comment.md + +# design-sync artifacts +.design-sync/sb-reference/ +.design-sync/learnings/ +.design-sync/.cache/ +.design-sync/node_modules +.ds-sync/ +ds-bundle/ diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 1819205b9d..e580b4d67d 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -62,5 +62,6 @@ "**/*.scss", "dist/**/[A-Z]**/[A-Z]*.js", "dist/**/[A-Z]**/index.js" - ] + ], + "types": "./dist/index.d.ts" } From fc93bc5fb945b28ab85fd96080fc449fb24d5f91 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 26 Jun 2026 15:51:52 -0400 Subject: [PATCH 2/8] chore(design-sync): re-sync with all 5 themes exported + conventions header - Add @codecademy/gamut-styles/dist/themes to extraEntries so all 5 named themes (coreTheme, adminTheme, platformTheme, lxStudioTheme, percipioTheme) are available in the bundle for theme-swapping in Claude Design - Fix tsconfig path (../../ not ../../../ from PKG_DIR=packages/gamut) - Add .design-sync/conventions.md as readmeHeader: GamutProvider wrapping, theme table, props-only styling idiom, idiomatic build example - Update NOTES.md with tsconfig fix, grade edge cases, and theme export finding Co-Authored-By: Claude Sonnet 4.6 --- .design-sync/NOTES.md | 33 +++++++++++++ .design-sync/config.json | 8 +++- .design-sync/conventions.md | 95 +++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 .design-sync/conventions.md diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index fd1afebb11..6634d31d0f 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -48,3 +48,36 @@ The `@ds-css-runtime` marker in the CSS stub is important: esbuild extracts the - Font CDN (`https://www.codecademy.com/gamut`) must be reachable during compare runs — storybook and previews both load fonts from there - Storybook webpack aliases point at package `src/` directly (e.g. `@codecademy/gamut → packages/gamut/src`) — storybook builds TS source, not dist; converter bundles dist + +## Known grade notes (2026-06-26 sync) + +### tsconfig path + +`cfgPath()` resolves relative to `PKG_DIR = packages/gamut`, not repo root. +Correct value: `"../../.design-sync/tsconfig.json"` (two levels up reaches repo root). +Three levels (`../../../`) goes above the repo and fails silently with "not found". + +### ConnectedForm — duplicate story display name + +ConnectedForm has two stories both named "Default" (different story IDs: `connectedform--default` and `connectedforminputs--default`). +Grade file can only hold one `"Default"` key — the second overwrote the first. +Graded: `Default → close` (story 1: valid form render, viewport width difference), `Watched Fields → match`. +The second "Default" story is an sb-error (auto-handled by compare, no grade entry needed). + +### SkipToContent — blank-by-design + +Component is visually hidden until focused; storybook and preview both render blank at rest. +Graded `match` with note — not a render failure. + +### RadialProgress/Animating — animation capture artifact + +Storybook captured a black canvas frame mid-animation; preview rendered at rest (blank). +Not a real render difference — animation is non-deterministic at capture time. +Graded `close` with note. Default and Children stories confirm the component renders correctly. + +### Theme export — extraEntries fix + +`@codecademy/gamut-styles` dist/index.js exports only `coreTheme as theme` (not named exports). +Added `@codecademy/gamut-styles/dist/themes` as a second extraEntry to expose all 5 named themes: +`coreTheme`, `adminTheme`, `platformTheme`, `lxStudioTheme`, `percipioTheme`. +Without this, `$ref: "coreTheme"` in the provider config resolved to `undefined`. diff --git a/.design-sync/config.json b/.design-sync/config.json index 19ba53d7fa..96cb558f5f 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -5,9 +5,13 @@ "storybookConfigDir": "packages/styleguide/.storybook", "storybookStatic": ".design-sync/sb-reference", "buildCmd": "yarn nx run-many --target=build --projects=variance,gamut-styles,gamut-icons,gamut-patterns,gamut-illustrations,gamut && sed -i '' \"s|import '../styles/vds_base_theme.scss'|import '../styles/vds_base_theme.css' // patched for design-sync|\" packages/gamut/dist/Video/lib/VidstackPlayer.js && printf '/* @ds-css-runtime: vds_base_theme.scss patched to .css stub for esbuild compatibility */\\n' > packages/gamut/dist/Video/styles/vds_base_theme.css", - "extraEntries": ["@codecademy/gamut-styles"], + "extraEntries": [ + "@codecademy/gamut-styles", + "@codecademy/gamut-styles/dist/themes" + ], + "readmeHeader": ".design-sync/conventions.md", "runtimeFontPrefixes": ["https://www.codecademy.com/gamut"], - "tsconfig": "../../../.design-sync/tsconfig.json", + "tsconfig": "../../.design-sync/tsconfig.json", "libOverrides": { "bundle.mjs": "enable minification — icons-as-code push unminified bundle to 5.2MB, over the 5MB upload limit" }, diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md new file mode 100644 index 0000000000..16e4e5aea8 --- /dev/null +++ b/.design-sync/conventions.md @@ -0,0 +1,95 @@ +# Gamut Design System — Usage Conventions + +## Wrapping and setup + +Every Gamut component must be rendered inside `GamutProvider`. Without it, the theme tokens (spacing, typography, colors) are undefined and components render unstyled or throw. + +```jsx +import { GamutProvider, ColorMode, FillButton } from './CodecademyGamut'; + +function App() { + return ( + + + {/* components here */} + Click Me + + + ); +} +``` + +Five themes are available — choose one based on the target product context: + +| Theme export | Product context | +| --------------- | ------------------------------------------------ | +| `coreTheme` | Default / Codecademy web (Apercu + Suisse fonts) | +| `platformTheme` | Platform / learner experience | +| `adminTheme` | Admin and internal tooling | +| `lxStudioTheme` | LX Studio | +| `percipioTheme` | Percipio integration | + +Pass the theme object to `GamutProvider`'s `theme` prop: ``. The `ColorMode` component controls light/dark: `mode="light"` or `mode="dark"`. + +## Styling idiom + +Gamut is **props-only** — no CSS utility classes. Layout, spacing, and color all go through system props on components like `Box`, `FlexBox`, and `GridBox`. Do not write `className` strings or inline `style` for design-system layout. + +System props follow the `@codecademy/variance` convention: + +```jsx + + content + + + + Primary + Secondary + +``` + +For color values, use semantic theme keys (defined in the active theme) rather than hex. `ColorMode` injects the light or dark palette — components read from it automatically. + +## Where the truth lives + +- **Component API**: each component folder (`components///`) contains a `.d.ts` TypeScript declaration and a `.prompt.md` usage reference — read those for props and composition patterns before coding. +- **README**: `README.md` in the project root lists all available components grouped by category. +- **Bundle**: `_ds_bundle.js` — all components and themes are on `window.CodecademyGamut`. Import them destructured: `const { FillButton, GamutProvider, coreTheme } = window.CodecademyGamut`. + +## Idiomatic build example + +A page section using Gamut components — provider at the root, system props for layout: + +```jsx +const { + GamutProvider, + ColorMode, + FlexBox, + Box, + Text, + FillButton, + StrokeButton, + coreTheme, +} = window.CodecademyGamut; + +export default function HeroSection() { + return ( + + + + + Learn to code + + + Join millions of learners on Codecademy. + + + Get started + Sign in + + + + + ); +} +``` From 958aa4a50a2699750aee822c66d3d3012f8f5d65 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 9 Jul 2026 13:56:16 -0400 Subject: [PATCH 3/8] feat(design-sync): default to percipioTheme, add guidelines to Claude Design Switches the design-sync provider default from coreTheme to percipioTheme for the [DRAFT] Gamut Design System project, and mirrors the 13 relevant gamut-* skill docs plus a new gamut-code-style.md (a Claude-Design-oriented rewrite of gamut-review's build rules) into .design-sync/guidelines/ so future syncs stay in sync with what's already been pushed to the project. Co-Authored-By: Claude Sonnet 5 --- .design-sync/config.json | 2 +- .design-sync/conventions.md | 9 +- .../guidelines/gamut-accessibility.md | 214 +++++++++++++ .design-sync/guidelines/gamut-buttons.md | 100 +++++++ .design-sync/guidelines/gamut-code-style.md | 77 +++++ .design-sync/guidelines/gamut-color-mode.md | 170 +++++++++++ .design-sync/guidelines/gamut-datalist.md | 276 +++++++++++++++++ .design-sync/guidelines/gamut-datatable.md | 273 +++++++++++++++++ .design-sync/guidelines/gamut-forms.md | 84 ++++++ .design-sync/guidelines/gamut-layout.md | 109 +++++++ .design-sync/guidelines/gamut-list.md | 281 ++++++++++++++++++ .design-sync/guidelines/gamut-menu.md | 170 +++++++++++ .../guidelines/gamut-style-utilities.md | 101 +++++++ .design-sync/guidelines/gamut-system-props.md | 194 ++++++++++++ .design-sync/guidelines/gamut-theming.md | 108 +++++++ .design-sync/guidelines/gamut-typography.md | 98 ++++++ 16 files changed, 2261 insertions(+), 5 deletions(-) create mode 100644 .design-sync/guidelines/gamut-accessibility.md create mode 100644 .design-sync/guidelines/gamut-buttons.md create mode 100644 .design-sync/guidelines/gamut-code-style.md create mode 100644 .design-sync/guidelines/gamut-color-mode.md create mode 100644 .design-sync/guidelines/gamut-datalist.md create mode 100644 .design-sync/guidelines/gamut-datatable.md create mode 100644 .design-sync/guidelines/gamut-forms.md create mode 100644 .design-sync/guidelines/gamut-layout.md create mode 100644 .design-sync/guidelines/gamut-list.md create mode 100644 .design-sync/guidelines/gamut-menu.md create mode 100644 .design-sync/guidelines/gamut-style-utilities.md create mode 100644 .design-sync/guidelines/gamut-system-props.md create mode 100644 .design-sync/guidelines/gamut-theming.md create mode 100644 .design-sync/guidelines/gamut-typography.md diff --git a/.design-sync/config.json b/.design-sync/config.json index 96cb558f5f..35085f8364 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -60,7 +60,7 @@ "provider": { "component": "GamutProvider", "props": { - "theme": { "$ref": "coreTheme" } + "theme": { "$ref": "percipioTheme" } }, "inner": { "component": "ColorMode", diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md index 16e4e5aea8..340c4c0100 100644 --- a/.design-sync/conventions.md +++ b/.design-sync/conventions.md @@ -9,7 +9,7 @@ import { GamutProvider, ColorMode, FillButton } from './CodecademyGamut'; function App() { return ( - + {/* components here */} Click Me @@ -54,7 +54,8 @@ For color values, use semantic theme keys (defined in the active theme) rather t - **Component API**: each component folder (`components///`) contains a `.d.ts` TypeScript declaration and a `.prompt.md` usage reference — read those for props and composition patterns before coding. - **README**: `README.md` in the project root lists all available components grouped by category. -- **Bundle**: `_ds_bundle.js` — all components and themes are on `window.CodecademyGamut`. Import them destructured: `const { FillButton, GamutProvider, coreTheme } = window.CodecademyGamut`. +- **Bundle**: `_ds_bundle.js` — all components and themes are on `window.CodecademyGamut`. Import them destructured: `const { FillButton, GamutProvider, percipioTheme } = window.CodecademyGamut`. +- **Guidelines**: `guidelines/.md` files contain category-level usage rules (button variant selection, theming setup, a11y patterns, code-style rules, etc.) — read the relevant one before building with that component family. ## Idiomatic build example @@ -69,12 +70,12 @@ const { Text, FillButton, StrokeButton, - coreTheme, + percipioTheme, } = window.CodecademyGamut; export default function HeroSection() { return ( - + diff --git a/.design-sync/guidelines/gamut-accessibility.md b/.design-sync/guidelines/gamut-accessibility.md new file mode 100644 index 0000000000..b6db56191e --- /dev/null +++ b/.design-sync/guidelines/gamut-accessibility.md @@ -0,0 +1,214 @@ +--- +name: gamut-accessibility +description: Use this skill when implementing accessibility for a specific Gamut component, building a custom overlay or composite widget, or auditing component usage against WCAG — complements the always-loaded `accessibility.mdc` with Gamut component-specific patterns. Form wiring lives in `gamut-forms`. +--- + +# Gamut Accessibility + +Source: `@codecademy/gamut` — `react-aria-components` is used only in `packages/gamut/src/Tabs/` (`Tabs.tsx`, `TabList.tsx`, `Tab.tsx`, `TabPanel.tsx`). `react-focus-on` powers `FocusTrap` (`packages/gamut/src/FocusTrap/index.tsx`), used by overlays such as `Overlay` (`packages/gamut/src/Overlay/index.tsx`) and `Popover`. Other widgets (e.g. `Menu`, `DatePicker`) implement keyboard and ARIA in Gamut code. + +Product-oriented button variants and props: [`gamut-buttons`](../gamut-buttons/SKILL.md) + +--- + +## Universal rules + +Prefer native HTML, minimal ARIA, correct roles, visible names, focus visibility, semantic color / `ColorMode`, and Gamut primitives — see the always-loaded Gamut Accessibility Rules: [`accessibility.mdc`](../../rules/accessibility.mdc). This skill adds Gamut component behavior and audit detail below. + +--- + +## How Gamut handles accessibility + +Tabs use `react-aria-components` (see `packages/gamut/src/Tabs/*.tsx`) for roving tabindex and keyboard navigation. Overlays (e.g. `Overlay`, `Popover`) use `FocusTrap` → `react-focus-on` for focus containment and Escape/outside close. Other interactive components (`Menu`, `DatePicker`, `Modal`, `Dialog`, etc.) rely on in-repo implementations — supply accessible names, wire labels to controls, and avoid duplicating what a component already sets (`aria-live`, `aria-describedby`, tabindex, etc.); confirm in source when auditing. + +--- + +## Component reference (index) + +There is no exported `