# design-sync notes — ciphercell-homepage ## What this repo is, for sync purposes - **The site itself is not a component library.** It is Astro: `.astro` files compile to HTML at build time and have no runtime form, so they cannot be bundled or shipped to a design agent. The first sync therefore went out tokens-only, and the Claude Design component pane was empty — correct output, but useless to design with. - **What ships instead is `.design-sync/ds-src/`**: fourteen React primitives that emit exactly the classes `src/styles/base.css` already defines, and no CSS of their own. That is the point — the visual truth stays in the site's stylesheet, so the design system cannot drift from the site even as `base.css` changes, and each component's markup ports back to `.astro` one to one (every `.md` states the Astro equivalent). - **Do not port the page-level `.astro` components** (Hero, Stages, HowItWorks, GridObject). Their styles are scoped inside the `.astro` files, so a React copy would duplicate CSS and drift. The line is: global primitive classes are fair game, component-scoped styles are not. - `Stack` is the one component with no class behind it. Nothing in this system carries its own margins, so without it the design agent hand-rolls pixel gaps, which the brief forbids. It emits `display: grid; gap: var(--space-N)` and nothing else. - Ownership: `ds-src/*.tsx` + `ds-src/*.md` are the source; `ds-types/` is generated by `tsc -p .design-sync/ds-src/tsconfig.json`; `previews/*.tsx` are the authored preview cards. Regenerate the types after ANY edit to a component's props, or the design agent gets a contract that does not match the code. ## Repo-specific gotchas - **React and its types come from `.ds-sync/node_modules` by symlink**, so the site's `package.json` keeps no React dependency: `ln -sfn ../.ds-sync/node_modules/react node_modules/react` (same for `react-dom`), plus `ln -sfn ../../.ds-sync/node_modules/@types/react node_modules/@types/react` — that last one is what lets `tsc` resolve `ReactNode` when emitting `ds-types/`, and what keeps the converter from collapsing inherited props to `any`. **Recreate all three links after any `npm install`** in the repo root — installs prune them. - **`package.json` carries `"types": ".design-sync/ds-types/index.d.ts"`.** That is how the converter discovers the components and their props (`findTypesRoot` → `exportedNames`). It is inert for Astro: the package is private and never published. Remove it and the sync silently falls back to tokens-only with an empty component pane. - **`--node-modules` must be `./node_modules`**, not `.ds-sync/node_modules`. The font containment bound is the git repo enclosing `dirname(--node-modules)`, and **this repo is not a git repo**, so the bound collapses to that directory itself. Pointing at `.ds-sync` puts `public/fonts/` outside the bound and the fonts are silently refused. - **`cssEntry` is derived, not the site's own file.** Run `node .design-sync/make-ds-styles.mjs` before the converter. It rewrites exactly two things in `src/styles/base.css`: - `@import './tokens.css'` → the token file's **contents, inlined**. The converter copies the CSS entry verbatim, so an `@import` pointing outside the bundle dangles and every `var(--token)` resolves to nothing. This is silent: the build and validate both pass, the fonts look right, and the whole palette is simply missing. Check `grep -- '--paper:' ds-bundle/_ds_bundle.css` after any build. - `url('/fonts/…')` → `url('../public/fonts/…')`. The site serves fonts from the web root, so the URLs are absolute; `lib/css.mjs` resolves them with `resolve(srcDir, url)`, an absolute path resolves to `/fonts/…` on disk, fails, and the rule is emitted **verbatim** — leaving `fonts/fonts.css` pointing at a path that does not exist. Every design then renders in a fallback font with nothing flagged. - **The brief cannot ship under its own name.** `claude.ai/design` refuses any uploaded path whose basename is `CLAUDE.md` (and anything under `.claude/`) with `permission_denied: … 1 path(s) are reserved`, and the converter names each guideline file after its source (`lib/docs.mjs:emitGuidelines` uses the source's package-relative path). So `guidelinesGlob` points at **`docs/design-brief.md`**, a verbatim copy of `CLAUDE.md` that `make-ds-styles.mjs` regenerates, and it uploads as `guidelines/docs/design-brief.md`. Edit `CLAUDE.md`; never edit the copy. The refusal is a hard API rule, not a warning, and it rejects the whole `write_files` call without writing any of its other paths. - **`cfg.tokensGlob` does not work here.** `lib/css.mjs:copyTokens` resolves it inside `node_modules` for a tokens *package*; these tokens live in the repo. `tokens/` is therefore empty by design and the definitions ship inside `_ds_bundle.css`, which is in the `styles.css` import closure — which is what designs actually receive. Do not chase the empty directory. - **`.design-sync/overrides/css.mjs` fork**: `cpSync` of the font files fails with `EACCES` on this machine's virtiofs mount (`/home/ciphercell/projects`), creating a 0-byte, write-only file and aborting the build. The fork copies with `readFileSync`/`writeFileSync` instead, which works. Reproducible, not transient. Declared in `cfg.libOverrides`. - **This repo is not under git**, so none of the durable files can be committed. If it is ever initialised, commit `.design-sync/{config.json,NOTES.md,conventions.md,make-ds-styles.mjs, ds-src/,ds-types/,previews/,overrides/}` plus the generated `docs/design-brief.md` and the `types` field in `package.json` (the sync needs both present), and keep `.ds-sync/`, `ds-bundle/` and `.design-sync/.cache/` ignored (already in `.gitignore`). ## Known render warns None outstanding. Two `[GRID_OVERFLOW]` warns fired once on `Grid` and `Display`, whose stories are wider than a grid cell; both are resolved by `cfg.overrides.*.cardMode: "column"` and cannot re-fire by construction. All 14 cards render clean, 30 cells graded `good`. The card scaffold sets `body{background:#fff}` AFTER the stylesheets, so a preview that does not wrap in `Surface` renders on white instead of paper. Every authored preview wraps in `Surface` for that reason. Keep it that way. ## Re-sync risks — what can go stale - **`.design-sync/ds-styles.css` is generated and gitignored-adjacent.** It is a copy of `base.css` at sync time. If `src/styles/base.css` or `tokens.css` change and nobody re-runs `make-ds-styles.mjs`, the sync ships the old look with no warning. Always regenerate first. The script throws if `base.css` stops `@import`ing `./tokens.css`, which is the one structural change that would break it silently otherwise. - **`conventions.md` enumerates real token and class names.** It was validated against the build at sync time (30 tokens, 25 classes, all present). Renaming or removing a token or primitive class in `src/styles/` makes the header lie to the design agent, which will then write vocabulary that does not resolve. Re-validate on every sync. - **The brief in `guidelines/docs/design-brief.md`** is a generated copy of the repo's own `CLAUDE.md`, which mixes the design brief with project instructions. It is shipped whole on purpose — it is the system's rulebook — but if it ever grows non-design content, narrow what `make-ds-styles.mjs` copies. The copy is generated, so a `CLAUDE.md` edit that nobody regenerates ships the old brief silently: always run the script before the converter. - **The React symlinks are machine state**, not committed. A fresh clone needs them recreated before the converter will run. - **`conventions.md` was rewritten** when the components landed; the tokens-only version that said `window.CipherCell` is empty is gone. If a future sync ever drops back to tokens-only, that file has to change with it — a header that promises components which do not exist is worse than no header. - **Preview cards are verified by rendering**: `package-validate.mjs` screenshots all 14, and `package-capture.mjs` captures each of the 30 cells for grading. Grades live in the gitignored `.cache/review/`; what makes them durable is the uploaded `_ds_sync.json`. - **`_screenshots/` must never be uploaded.** It is local review state and is not in the approved write paths; including it fails the whole `write_files` call.