How to change something in this repository without leaving a silent copy behind.
That is the whole difficulty here. Almost nothing in this repo fails loudly when it goes stale — a derived file that is a week behind its origin compiles, loads, renders, and quietly documents a design system that no longer exists. Most of the rules below exist because that already happened.
New to the repo? Read README.md first for what it is. This file is what to do once you want to change it.
docs/ is the origin. Edit there.
Everything else in this repository is downstream of it: the npm package, the
synced design bundle, the compiled storybook. If you find yourself editing a
token in packages/ or ds-bundle/, stop — your change will be silently
overwritten by the next build, and until it is, you have two copies of the truth,
which is the exact failure this repo is organised to prevent.
There are two things under docs/ that are not editable either. See
Files you must never hand-edit.
The action purple #5F20FE appears in 71 tracked files. Only two of them are
places you change it. Knowing which category a file is in is most of the job:
| Category | Files | What to do |
|---|---|---|
| Origin | src/styles/colors_and_type.css, src/tokens/*.ts |
Edit these. Both, together — the .ts is a hand-maintained mirror, not a compilation. |
| Component sources | src/components/** |
Edit these. This is where a component changes. |
| Provenance captures | src/captured/** |
Never edit. These are copies of files another repository owns. |
| Generated | packages/brand/dist/, the derived half of ds-bundle/ (including its Components cards), docs/storybook/compiled/, docs/storybook/tw-bridge.css |
Never edit. Rebuild. All gitignored. |
| Artwork | SVGs under src/logos/ (masters), docs/logos/, docs/favicons/, docs/assets/, docs/imagery/ |
Edit the hex literally — SVG has no cascade to inherit a token from. |
| Swatch captions | docs/index.html, docs/index.html, docs/pages/preview/colors-*.astro |
Edit the literal. Here the hex is the content — a var() would render nothing. On the ported page the literals are a data array at the top of the file; that is still a literal. |
docs/examples/raw/email.html, newsletter.html |
Edit the literal. Email clients do not support custom properties; this is not a shortcut. | |
| Prose | DESIGN.md, README.md, docs/README.md, docs/SKILL.md |
Update the ones that state the value. DESIGN.md is the rulebook and always states it. |
Everything else consumes var(--color-*) and needs no edit at all — which is the
point of the token layer. Never hard-code a hex in a file that could use a
var().
These are copies of files this repository does not own — what another repository actually ships, at a moment in time. They exist to be evidence. Hand-editing one to match what you wish the original did destroys the only thing it is for, and it does it invisibly: a doctored capture looks exactly like a real one.
Eight files are left, and none of them is a component:
styles/global.css— codecave.pro's stylesheet.build-storybook.mjsparses it for the:rootand@themeblocks it scopes into the demo canvases, and throws if the:rootis missing.assets/images/logo.svg— the wordmark, rendered by a kitchen-sink page.brand-repo-styles.cssandbrand-repo-tokens/*.css— snapshots of this repository's own earlier token CSS, kept so the token pages can show what changed.build.mjsskips them by name; nothing ships from them.
Components are not here any more. They moved to src/components/ on
2026-08-25, because 37 of the 45 files in this directory had no upstream left
and the directory name had quietly become false. What was a rule nothing could
enforce — check:captures was deleted the same week — is now a rule the layout
states: the directory you may not edit contains nothing anyone would want to.
This is where components live, and editing one is the ordinary way to change a component. The package is built from these files, the storybook compiles them, and codecave.pro installs the result at a version it pins — so a change here reaches the site when someone bumps it, not before.
build.mjs fails the build if the same path exists under both roots. One
shipped file cannot have two origins, and the directory name is the claim.
ds-bundle/ used to be an upload staging directory holding 46 gitignored
copies of files that already existed under src/ and docs/. It is not one
any more, and nothing replaced the copies: tools/design-sync-map.mjs pairs
each project path with the authored file that answers it, and DesignSync
uploads from there. What is left is two halves:
- Authored and tracked —
README.md,styles.css,guidelines/brand.md, and the Foundations cards. These have no upstream indocs/. Edit them here. - Generated and gitignored — the Components cards, written by
node tools/build-ds-components.mjsfrom theSTORIEStable inside it. That table is the tracked source; the cards are output, likedist/.
The two card directories look alike and are opposite. A Foundations card is a drawing — a swatch grid, a type ramp — with nothing upstream to derive it from, so it is authored and tracked. A Components card draws nothing: it mounts the same compiled bundle the storybook mounts, through an import map, so what renders is the component codecave.pro ships rather than a picture of it. That is the whole reason components could join the bundle at all — hand-written HTML that looked like Button would be wrong the first time Button changed, and nothing would say so.
There is no longer an order to get right. The cards are built as the last step
of npm run build:storybook, because they mount its bundles and cannot exist
without them — the same chained prerequisite that makes build:storybook
build the package first. npm run check:design-sync then asks the question the
copy step never could: does every reference a card makes resolve to a path the
push actually writes? It found one that did not. The checkbox tick has been
404 in the Design project because the old copy placed it one directory above
where any card could ask for it.
One exception worth knowing: ds-bundle/README.md is tracked and authored, but
its header is a verbatim copy of .design-sync/conventions.md. Change the
header in conventions.md and copy it across; they are checked by eye, so it is
on you.
- Edit
src/styles/colors_and_type.css. This is the real source of truth — if it and anything else disagree, the CSS wins and the other thing is the bug. - Mirror it in
src/tokens/*.ts. Nothing compiles the CSS into the TS; it is maintained by hand and it has silently diverged before. Packaging the mirror once turned up two latent bugs nothing indocs/consumed: an extensionless ESM import, and an interpolation ofbrand[660], a ramp step that has never existed. - Update
DESIGN.mdwhere it states the value, and the palette table in the root README.md if the change is to one of the eight colours listed there. - Grep for the old value and triage every hit against the table above.
- Rebuild and check:
npm run build:package && npm run check - Nothing else. The Design project reads
src/styles/colors_and_type.cssdirectly through the upload map, so a token change reaches it on the next push with no regeneration step to forget.
DESIGN.md is the rulebook and is authored directly. If the rule has a
machine-readable half — an anti-pattern that a specimen demonstrates, a
divergence in §10 — move both, and say in the commit which specimen proves it.
Read docs/README.md § Ports first. The short version: a captured component that depends on something the static build cannot carry gets an interface and an adapter, never a stub.
-
The interface goes in
docs/storybook/ports/ports.d.ts, the adapter beside it, and the wiring in thePORTStable intools/build-storybook.mjs. -
An adapter substitutes the environment, never the behaviour.
SanitizerPortis realdompurifywith only thejsdomhalf dropped, because a docs page is only ever a browser. It shipped as an identity function for a day, and that made the one page people open to check sanitising the one page not doing it. -
Where an adapter genuinely cannot reach production, the specimen must say so on its face — not in a code comment nobody reading the page will see.
-
npm run check:portstypechecks every adapter against its interface, and CI runs it. A drifted adapter compiles fine and fails as anundefinedin a reader's browser, which is the one place a specimen must not fail. -
A port exists because a captured component needs it. That check typechecks an adapter whether or not anything imports it, so on its own a green result reads as coverage of something that may be running for nothing. Every
build-storybook.mjsrun therefore names the specimens each port stood in for, and names any port nothing reached for — delete those, or capture what needs them. -
The vendored runtime map is checked against the bundles both ways, and against the disk. The compiled specimens keep
vueand the gsap entry points as bare imports, and only a map resolves them — where a key is matched exactly, and where the choice of file is a judgement rather than arithmetic (vueis one of half a dozen builds in the package;gsapis a directory). Miss one and the browser rejects the whole module graph: the page renders, the canvas is empty, nothing errors.npm run check:importmapfails on an unmapped import, on a mapped specifier nothing imports any more, and on a mapped file that is not indocs/vendor/— that last one is new, because the old check compared two lists of names and a map entry pointing at a runtime nobody had committed passed cleanly and 404'd in the browser. The map istools/storybook-vendor.mjs; it lived inDocPage.astrountil the specimens stopped mounting bundles, and its consumer now isds-bundle/. Both inputs are committed, so it runs in CI. -
The six deliverables under
docs/examples/raw/are checked, because nothing else looks at them. Astro never renders them — that is what makes them documents a client can be handed — and the wrapper pages that embed them cannot see inside an<iframe>. Moving them one directory deeper broke the wordmark in four and the Satoshi@font-facerules in two, with every check green.npm run check:examplesresolves every relative reference in them, and holds theirfont-sizeandborder-radiusliterals to the ramp: an email writes values out in full because a mail client will not resolve a custom property, but that constraint is aboutvar(), not about the values. It also rejects a barecqiin the deck and the poster, which are container-scaled on purpose and derive their ramps from the system's ratios. Exceptions carry their reason and must be exercised. -
Every route carries the same main menu, and the way to break it is to skip the layout.
DocPagerendersDsNavwith no prop to override, so a page cannot get a different menu — only none, by not using the layout, which builds and renders and passes everything while being absent from the navigation.npm run check:linksasserts every page underpages/importsDocPage. It also asserts every documentation route the prose cites resolves, anchors included, which is how the 49 citations the surface collapse broke get found — a dead markdown link fails nowhere except for a reader. The route set comes from committed source, notdist/, so it needs no build. -
Adding or removing a finding moves a number in three other files.
docs/README.md,docs/pages/kitchen-sink/index.astroanddocs/pages/index.astroeach quote how many findings the story pages carry. Do not adjust them by one and hope —npm run check:findingscounts the pages and compares. A finding is an element carryingsb-noteafter that page's<h2>Findings</h2>, withis-warnmarking a defect; notes above the heading are furniture, which is why project-chip's opening note is not a finding.It checks the split, not just the total, because the drift that prompted it moved neither: a note marked fixed upstream lost its
is-warn, turning a defect into an observation while 55 stayed 55. It also fails when one of those three sentences is reworded, since the patterns are literal — that is the point. Fix the pattern intools/check-findings.mjsso the claim stays covered; a check that has quietly stopped covering anything reads exactly like one that passes.
Building the storybook needs a codecave.pro checkout beside this one. That
repo uses pnpm — pnpm install --frozen-lockfile. Reaching for npm there
produces an ERESOLVE that looks like a broken dependency graph and is not.
npm run checkThat is eight assertions in one: the package is byte-identical to its origin,
every storybook port typechecks, the compiled storybook matches the sources it
was built from, no token silently redefines a Tailwind default, the vendored
runtime map resolves exactly what the bundles import and every file it names is
on disk, the findings counts three files quote agree with the story pages, the
six deliverables' own references still resolve, and every documentation route
cited in prose still exists. All eight are things that would otherwise rot
quietly, and CI runs the same eight on development and on chore/*.
"Byte-identical" has one exception, and it is stated by the check itself rather
than left to be discovered. Some spellings the site uses cannot survive the
copy, because each names a file the package already ships in a form that only
resolves from outside the package: codecave.pro's path aliases — @assets/,
@components/, @helpers/, @lib/, one per top-level directory under its
src/ and declared only in its own tsconfig.json — and
@codecavepro/brand/components/common/Button.vue, the package's own name, which
the site writes because it installs this package and which Node would resolve
through the exports map into a second, separately installed copy of the very
package doing the importing. All are rewritten to relative form on the way into
dist/. Those captures match their origin once the alias is resolved, and the
check prints the two counts separately so neither claim is doing the other's
work. The rule is the table in tools/import-aliases.mjs, shared with
build-storybook.mjs — which needs the same answer, because for those files
identical bytes would mean the package was not built from that capture.
@layouts/ and @styles/ are in that table with a null target. The package
ships neither, so the entry exists to make a shipped file reaching one fail by
the right name: without it, @styles/global.css was reported as an undeclared
npm peer called @styles.
The token one is worth a sentence, because it catches a failure with no symptom
here at all. Tailwind declares its theme inside @layer theme;
colors_and_type.css declares in a plain :root, and unlayered CSS beats any
cascade layer regardless of source order. So a token that happens to share a
name with a Tailwind default does not merely coexist with it — it silently and
unconditionally replaces it in every consumer's app, and nothing in this
repository renders any differently. npm run check:collisions diffs the token
layer against Tailwind's complete default theme, and sweeps the SFCs for
properties nothing declares. The first time it ran it found
CCWEB2-323.
Both of its inputs live here — the component sources are committed, and Tailwind's theme comes from a devDependency pinned to the version the site resolves — so it runs in CI. It also sweeps the full site checkout for undeclared properties when one is beside this repo, and says which it did.
That is now the ONLY place a site checkout is read at all. It used to be the
only one read for an answer rather than for a toolchain, and the distinction
has stopped mattering: build-storybook.mjs resolved vue/compiler-sfc, esbuild
and tailwindcss from a codecave.pro checkout until 2026-08-26 and resolves them
from this repository now, so nothing borrows that repo's node_modules any more.
Both remaining reads — this sweep and the sanitizer port's dompurify pin — are
optional and say so when they are skipped. It survived the removal of
check:captures because it
asks a different question. The captures check asserted this repo had not moved
ahead of the site. This one asks whether a token would collide in the
consumer's app — and the consumer is the site, so the Tailwind pin is
checked against the version it builds with. Neither half calls this repo wrong
for being ahead.
* text=auto eol=lf, and it is load-bearing rather than tidiness. Two
derivations read the working tree, not the git blob:
npm run checkassertspackages/brand/dist/colors_and_type.cssandfonts.cssare byte-identical to their origins, andnpm packruns it.build-storybook.mjscompiles.vueSFCs with esbuild, which reproduces only if its inputs do.
A third reason stood here and was the sharpest: tw-bridge.css recorded a
sha256 of the component roots, so a CRLF clone and an LF clone of the same
commit disagreed and the check could pass only on the machine that generated
it — 36 consecutive red Pages deploys in August 2026, green locally every time.
That digest is gone (those files are generated and gitignored as of
2026-08-27), but the two reasons above are not.
A CRLF checkout and an LF checkout of the same commit disagree on every one of
them. That is not a hypothetical: without this file, core.autocrlf=true on
Windows broke the Pages deploy for 36 consecutive runs across 2026-08-20
and 21 — green locally every time, red in CI every time, and the site quietly
served a two-day-old build the whole while. text=auto on its own would not
have helped: it normalises what is committed and leaves the checkout
platform-native, which is exactly the state that caused it.
If you clone into a tree that predates the file, or the check reports a digest mismatch it identifies as a line-ending difference, renormalise in place:
git rm -r --cached . && git reset --hardPrefer an assertion to a comment. This is the repo's strongest habit and it was learned the hard way — a comment asking the next person to keep two things in step failed inside a day, while the pin it guarded was already wrong. If you find yourself writing "remember to update X when Y changes", write the check instead.
Prefer a proof to a claim. A specimen that generates its verdict from the rendered DOM beats a paragraph asserting the behaviour, because the paragraph stays true-looking after the behaviour breaks.
The site is built with Astro now (CCWEB2-317).
npm run devThe dev server needs two rewrites to be usable here, and both are in
tools/astro-passthrough.mjs. Neither is cosmetic; without them the site is
unusable in dev in a way that looks like the site is broken rather than the
server:
- A directory's
index.htmlrequest is rewritten to the directory route. Withbuild.format: 'preserve'the build emitskitchen-sink/index.html, and every link here carries.htmlso the pages also open straight from disk. The dev server routes that page as the bare directory name and 404s/kitchen-sink/index.htmland its trailing-slash form alike — so every entry in the main menu is dead while the leaf pages are perfect. - The published half of
src/is served.publicDirservesdocs/;colors_and_type.css,theme.css,fonts/andtokens/are authored undersrc/and copied out atastro:build:done, which never runs in dev. Without the middleware/colors_and_type.css404s and the whole site renders unstyled, the only clue being a Starlight catch-all warning about agetStaticPaths()route.
npm run preview builds and then serves dist/, which is the form that
actually ships. Use it when the question is about the build rather than about
a component.
docs/ is still the origin and is still committed — the build reads from it and
writes to dist/, which is not. Nothing about the one rule changes: if the
build wrote back into docs/, the npm package's byte-identity assertion would be
comparing a generated file against itself.
Every page is ported: the thirteen preview/ cards, the fourteen storybook/
pages, index.html and index.html. Each is an .astro under
docs/pages/ and reaches the site by being rendered. examples/ is the
exception and always will be — see below.
The half-migrated state is gone, but the machinery that made it survivable is
not, and it is worth keeping: a page reaches dist/ either by being rendered or
by being copied, both silently, and tools/astro-passthrough.mjs asserts
both every build. Adding a page back as plain .html still works.
Four things to know if you write or move a page:
-
The body is
.astronow, not HTML. A brace opens an expression, so literal text likecodecave-{wide|tall}-{size}.pngcompiles to JavaScript — write it as{'…'}instead. Entities are the same hazard in reverse:titleis plain text and the layout escapes it, so passing&puts a literal&in the browser tab. That one is asserted —DocPagerefuses a title containing an entity — because nothing else caught it: the page built, served and looked right, and only the diff below showed the doubled escape. -
Delete the
.htmlyou replace, in the same commit.publicDirandsrcDirare the same directory, and when both offer a path Astro keeps the copied file and skips the page — aWARNin a build that exits 0, leaving the new.astroas dead source. The build now fails instead: seetools/astro-passthrough.mjs. -
Diff the output against the page you replaced.
compressHTMLis off, so a faithful port renders almost byte-identically and the diff is short enough to read. It is off for a better reason than that — on by default it collapsed a line break before an inline tag to nothing, turning "violet light upward" into "lightupward" in three places on the first page tried. In a repository whose prose is the asset, that is not a minification setting.
The fourth applies only to the specimens, and it used to say the opposite:
-
A specimen imports the component source, and hot-reloads with it. Each kitchen-sink page carries an ordinary
<script type="module">that imports../../../src/components/…and mounts it, so editing a component updates the open page without a reload. That is the whole reason for the arrangement.This rule was "the specimens are not islands, and must not become them" — each page carried a browser import map and an
is:inlinescript mountingcompiled/*.js, esbuild output from codecave.pro's own toolchain, so that a specimen was a record of what the site shipped rather than a rebuild of it. That reasoning depended on this repository being downstream, and it stopped being downstream on 2026-08-25. The components are authored insrc/now and the site installs the package built from them, so a specimen compiled from source is what ships, and a prebuilt bundle is whichever version the last package build left behind.Two things did not change.
npm run build:storybookstill writescompiled/*.jsandtw-bridge.css;ds-bundle/consumes them for a Design project that cannot run a bundler, and its cards still mount them through an import map. And a specimen page still has to bring its own utilities — for an Astro page that is a frontmatterimport '../../tailwind.css', nottw-bridge.css, which nothing underpages/links any more. Without it a component mounts perfectly and renders as raw HTML, every class resolving to nothing, which does not look like a failure at all.check:linksasks in both directions.Watch the depth when you move one of these pages. A processed
<script>is resolved by Vite at build time against the source tree; anis:inlineone, and everysrc/href/url()in the markup, is resolved by the browser against the output tree. Those are different numbers of levels. Converting these pages moved fourplaceholders.jsimports from right to wrong without altering a character;check:linksis what noticed.
The six specimens under examples/ are never rendered. They are the
deliverable being shown, not pages about it, and an HTML email with a
documentation bar welded to the top is not a valid email. They pass through
untouched.
The gallery that links them is the exception this paragraph always reserved,
and it took it: docs/pages/artifacts/index.astro. It is chrome rather than a
specimen — it always carried the documentation bar — and while it was payload
it carried its own hand-written copy of that bar's markup. Once the bar became
a component, that copy would have been the only markup left in the repository
claiming to be the nav while not being it. Its route did not move:
build.format: 'preserve' emits it to examples/index.html, the href every
nav already carries. Nothing else under examples/ will follow it.
The site normally runs whatever version its lockfile pins, so a change here reaches it only after a release and a bump. For development there is a way round that, and it needs no flag and no configuration on your part.
codecave.pro's astro.config.mjs resolves @codecavepro/brand from
../brand/packages/brand/dist whenever that directory exists. Clone this
repo beside it, build the package once, and its astro dev is running your
working tree:
npm run build:package # in this repo — creates packages/brand/dist
npm run watch:package # leave running: rebuilds dist/ on every save under src/Edit anything under src/, and about two seconds later the page reloads with
the change on it. USE_LOCAL_BUILD=false over there forces the published
package back, which is how you find out whether a bug is yours or the
package's.
Two things to know, because both were arrived at by measurement rather than by taste:
- The build updates
dist/, it does not rebuild it. An output whose bytes have not changed is not written, so editing one component moves one mtime rather than all 58. It also writes through a temp file and a rename where Windows permits one — a dev server reading a file mid-write sees an empty module and reports it asCannot find module '@codecavepro/brand/…', naming a different component each time. - Only one build runs at a time.
npm run build:storybookstarts with a package build, so running it while the watcher is up used to have two builds sharing one scratch directory; the loser shipped an incompletedist/and exited 0. The second one now waits.npm run checkis unaffected and can be run whenever.
Jira is the only list. This repo had a TODO.md; it was deleted on
2026-08-20 once its contents were filed, so the absence of a to-do file here
does not mean there is no open work.
Brand-package items are in CCWEB2
under the brand-kit label. They sit in the website project because no brand
project exists yet; the label is what makes them movable in bulk when one does.
CLAUDE.md carries the current index with status.
A flaw in a file the SITE still owns is not ours to fix. It gets written up
in WEBSITE-REVIEW.md and filed in CCWEB2 without the
brand-kit label — never silently "corrected" in docs/.
That is a much smaller set than it used to be. The components moved here, so a
flaw in one of them is ours: fix it in src/components/, release, and the
site gets it at its next bump. What remains genuinely site-side is the site's
own pages, layouts and configuration.
Print colour space — CMYK, and whether a spot colour is used — is deliberately not filed anywhere. It is Maria Shaban's decision and gets its own project.
Changing docs/ does not publish anything to npm. Pushing a bare version tag
does — git push origin 2.2.0 starts
.github/workflows/release.yml, which verifies
and then publishes as a trusted publisher. There is no npm token in this
repository and no OTP to type; the tag is the deliberate act, so treat it as one.
The tag is also where the version number lives.
packages/brand/package.json says 0.0.0 in the repository and is supposed to
— there is nothing to bump before you tag, and a version you see in the working
tree after a release is a stamped file to discard, not a change to commit. That
also means a build made from an untagged checkout is unpublishable by
construction, which is the right default for the one command here that cannot be
undone.
See RELEASING.md, and read its rollback section before your first release rather than after: npm's unpublish window is 72 hours and a version number, once used, can never be reissued.
The docs site at https://brand.codecave.pro/ is a different matter — it deploys
from docs/ on push, so a change there is live as soon as it merges.