freehire / design-system
AGENTS.md3.5k starsChanged 3 months agoMITView on GitHub
freehire-design-system — tokens and Svelte primitives, a pnpm package sibling to web/, linked in as link:../design-system. web/src/lib/ui/index.ts is a thin re-export surface, so app code imports from #lib/ui and never reaches into the package directly. It re-exports with export *, not a list: an enumerated list is a second copy of src/index.ts that nothing reconciles, and it drifted once already — eleven of the fifteen primitives were built, tested, storybooked and documented while being unreachable from the app, with every…
- Installs packages
Files
Design system
freehire-design-system — tokens and Svelte primitives, a pnpm package sibling to
web/, linked in as link:../design-system. web/src/lib/ui/index.ts is a thin re-export
surface, so app code imports from #lib/ui and never reaches into the package directly.
It re-exports with export *, not a list: an enumerated list is a second copy of src/index.ts
that nothing reconciles, and it drifted once already — eleven of the fifteen primitives were
built, tested, storybooked and documented while being unreachable from the app, with every
check green. cn comes from here too; #lib/utils no longer carries a copy.
Extracted from web/ over seven phases, all landed. docs/ holds what outlived them:
verification.md (each guarantee and the command that enforces it), icons.md, and the
DSDS entity JSON. The phase inventories were plans for finished work and are in git history
rather than here — their census of web/ was already off by a third.
Always true
- pnpm, not npm.
pnpm install && pnpm buildhere, thenpnpm installinweb/. Corepack activates the pinned version. There is nopackage-lock.jsonanywhere — adding one breaks the CIdesign-system→webjob chain. link:, notfile:— and the order matters.link:is a bare symlink, so there is no copy of this package to go stale; editing a token here is visible towebon its next build with no install at all. It wasfile:until 2026-07-31, which copies into web's virtual store keyed by name+version — and this package's version is a permanent0.0.0, so--frozen-lockfilehappily reused a copy from days earlier. That shipped an app referencingbg-warningagainst a design system defining no such token: green release, colourless badges. The price of the symlink is that pnpm does not install a linked package's own dependencies —tailwind-variants,clsx,tailwind-mergeand@lucide/svelteresolve fromdesign-system/node_modules, so install here before buildingweb/or the build dies withMODULE_NOT_FOUND. Loud, which is the trade.src/theme.cssis the package's CSS contract, and every consumer imports it. It holds the token imports, the@sourcescan, and the@theme inlinebridge.web/src/app.cssand.storybook/preview.csseach@importit and add only what is theirs (the typography plugin and app base layer; Tailwind's entry and the story-file scan). Put anything the primitives themselves depend on intheme.css— a second copy in a consumer is how the app and Storybook start disagreeing.- The
@sourcescan is what keeps the primitives styled. The package resolves undernode_modules/, which Tailwind v4's automatic source detection ignores, so its classes are dropped from the built CSS unless@source "./**/*.svelte"intheme.cssmatches the file.@sourcepaths resolve against the file holding them, so that one line covers every consumer — but move a component into a path it doesn't cover and its unique classes vanish. Story files are deliberately outside it:.storybook/preview.cssscans them separately so the app never ships a utility only a story uses. - Tailwind drops unknown classes silently and the build stays green. This is how
<Badge variant="missing">lost its destructive tint in phase 4, and how phase 5's Storybook shipped with no Tailwind at all — 10 stories, a passingbuild-storybook, and every primitive rendering as unstyled markup. CI now asserts positively thatstorybook-staticcontains.animate-pulse, which only the scan oversrc/*.sveltecan put there. That guards the wiring, not each class: still verify a moved or new primitive visually. - Token custom properties are not automatically Tailwind utilities. The z-index tokens
ship from
dist/tokens-*.cssas plain--z-modaletc., but Tailwind mintsz-<name>only from the--z-index-*namespace — hence the@theme inlinealiases intheme.css(--z-index-modal: var(--z-modal);). A new token family needs the same bridge or its utilities silently no-op. - Dark tokens live under a
.darkselector, so switching theme means putting a class on a root element, not swapping a stylesheet. Storybook does it withaddon-themes'withThemeByClassNameonhtml; abackgroundstoolbar option cannot — it only paints the canvas. - Tokens are authored as
tokens/*.tokens.jsonand compiled by Style Dictionary (scripts/build-tokens.mjs) intodist/. Edit the JSON, runpnpm build— never hand-editdist/. .darkcarries only what dark changes. The dark build readscolor-dark.tokens.jsonalone, sodist/tokens-dark.cssis 33 declarations, not a second copy of the whole scale — the cascade already inherits the rest from:root. A dark override of a non-colour token would need its file added todarkSources; nothing needs one yet.- A token name may not be authored twice.
build-tokens.mjsthrows and names both files. Style Dictionary reports its own collisions as a bare count with the details behind a verbosity flag, so a real one reads as the number going up by one. The count that remains (8) is the inert kind: each token file's root$descriptionand$typeare the root group's metadata, they overwrite each other on merge, and nothing consumes them. - A primitive may not style itself outside the token scale.
pnpm check:tokens(in CI) fails on a colour literal or a Tailwind arbitrary value insrc/*.svelte— both are a value the theme cannot move and.darkcannot override, and both compile fine, so nothing else would catch one. Arbitrary variants pass:[&_tr]:border-bis a selector, not a value. A handful of named exceptions are carried in the script's ownALLOWEDlist — each pinned to one file and one detector kind, with the reason it exists — and the script fails if any of them ever stops applying (an unused exception is exactly as wrong as an unlisted violation). Seedocs/verification.md. - The same check has a second radius over
../web/src, and it is not the same rule. The package is held at zero (modulo theALLOWEDlist above); web is held at its current count per file inscripts/web-token-baseline.json(365 across 112 files), because a rule nobody can satisfy gets switched off rather than obeyed. Web also gets a third detector the package does not:text-amber-600is a well-formed utility off Tailwind's own palette — neither a literal nor an arbitrary value, invisible to both other detectors, and the majority of what web has. Add a detector once, inDETECTORS, and put it in a radius — never fork it. - Both baselines are exact in both directions. An improvement is red too, and says so:
rerun with
--updateand commit the diff. Do not "fix" that by loosening the comparison to>=. A ratchet that absorbs improvements silently sits at 459 while reality is 40, and the regression back to 459 passes green — asserting nothing. pnpm check:adoptionis the only check that reads both halves of the repo. This job proves the package builds and thewebjob proves the app builds, and both are true whether or not they are connected — which is how eleven primitives stayed unreachable from app code for three phases with everything green. It counts theweb/srcfiles importing each primitive againstscripts/adoption-baseline.json, and names the unused ones every run (nine of fifteen today). The primitive list is derived fromsrc/index.ts, so a new primitive joins the census by itself — do not list them in the script.#lib/uiis a wall, not a convention. A.svelteor.tsfile underweb/srcthat importsfreehire-design-systemby name failscheck:adoptionoutright — no baseline, no exception, because there are zero today.web/src/app.css's@importoftheme.cssis exempt and must stay so: it is the CSS contract. The walk readsfrom-clauses only.dist/is committed, so CI rebuilds and diffs it (pnpm check:dist). Editing a token without runningpnpm buildused to ship the old CSS with every job green. Style Dictionary writes no timestamp, so the rebuild is byte-identical and the diff means something.- Both cross-boundary checks are repo checks that happen to live here. They read a
directory the package knows nothing about. If
design-system/is ever extracted, they stay with the repo — do not treat them as part of the package's surface. pnpm testruns two vitest projects.components(src/**/*.test.ts, jsdom, the Svelte plugin,vitest.setup.ts) andscripts(scripts/**/*.test.mjs, plain node). A script test put undersrc/would pull in twenty seconds of Svelte transform to test a file walk; a component test underscripts/would get no DOM. Each script guardsmain()behind animport.meta.urlcheck so importing it in a test does not run it.svelteandtailwindcssare peer dependencies; the package must not bundle its own copy. Both are also devDependencies, because Storybook and the tests build against them here.
What Dialog does not cover
Nine surfaces in web/ were built by hand before this primitive was reached for. Three are
now Dialog call sites — ReportDialog, RequestReferralModal, DeleteAccountButton. The
other six are not stragglers; they are two gaps, and forcing either onto Dialog means
re-adding by hand what it deliberately does not do.
- A sheet.
JobDraweris a full-height drawer,FilterModalShellandOnboardingWizardstretch on mobile and centre abovesm,CookieConsentis a bottom banner.Dialogis a centred modal and offers no positioning. Three of the four want the same thing — a Sheet primitive the system does not have. - A structured dialog.
GmailConnectDialogandFollowUpDialogboth want a bordered header with an icon, a scrolling body and a bordered footer.Dialogrenders its ownp-6box with the title, the description and the close button in fixed positions, andclasslands on<dialog>— it cannot be reshaped from the call site. Two call sites is the evidence; aheader/footersnippet pair is the likely answer.
CookieConsent carries role="dialog" on a surface that is not modal — an accessibility
defect, listed here so it is not mistaken for a migration target.
dismissible={false} holds a dialog open while an irreversible request is in flight, so
Escape cannot hide whether it succeeded. Escape, the backdrop and the close button go away
together — leaving one is a dialog that claims to be held and is not.
Preventing cancel is necessary and not sufficient, and jsdom cannot show you why. The
platform's close watcher spends a user-activation budget on each preventDefault, and once it
runs out it fires cancel unprevented and closes anyway — measured in Chrome: two Escapes
refused, the third gets through. That valve exists so a page cannot trap someone. So a held
dialog also reasserts itself in onclose. Keep the window short and always leave the
caller a way out once the request resolves; a unit test that only asserts defaultPrevented
will pass either way.
Storybook
pnpm storybook (port 6006) / pnpm build-storybook. Stories are CSF in
src/*.stories.ts, one file per primitive, and all 15 are covered.
Two ways to give a story its children, and the choice is not taste. For a primitive
whose children are just text, src/story-text.ts wraps a string in the snippet Svelte 5
requires. For one whose children compose other primitives or take snippet parameters —
Dialog needs a trigger, FormField hands its control { id, describedBy, required,
invalid }, Table leaves rows to the caller, Tooltip wires aria-describedby onto the
first focusable child — a raw HTML string would mean hand-copying the markup of Input or
Button, and the story would drift from the component silently. Those get a real component
in .storybook/demos/, named in the story's component.
The demos live there and not in src/ for two reasons: src/* is the package's public
export surface ("./*": "./src/*"), and theme.css's @source "./**/*.svelte" would mint
their layout utilities into the app's bundle. preview.css scans them separately.
(@storybook/addon-svelte-csf would be the tidier answer, but 5.1.2 peers @storybook/svelte
at ^10.4.0-0 and we are on 10.5.4.)
docgen: false in .storybook/main.ts — the svelte docgen plugin hands raw .svelte source
to the bundler's JS parser and dies on the markup. The cost is the autodocs prop tables, so
the Docs tab renders thin, and argTypes are hand-maintained: they drift from a component's
actual variants with nothing to catch it. Button's list already did, losing destructive.
DSDS docs
docs/dsds/*.json describes the system as entities a docs site can render: foundation.json
(one entity per token family, each listing its token names), theme.json (the dark mode and
its .dark selector), components.json (one entity per primitive — props, defaults, and
links to its source and story).
pnpm validate:docs (scripts/validate-docs.mjs, run in CI) checks structure — every entity
carries id, type, name — and then the two things that actually rot: every source.file
and stories[].file resolves, a foundation entity's tokens list matches the keys of the
token file it names in both directions, and no src/*.stories.ts goes unclaimed. Phase 6
shipped with all three broken (every foundation path off by a directory, destructive-foreground
undocumented, five story files orphaned) and the structural check passed, which is why they are
checked now.
What is still hand-maintained: props, their values, and every description. A variant
added to a tv() call leaves the entity stale with CI green — the same drift that costs
argTypes their accuracy, and for the same reason: the values live inside a Svelte module
script that only a compiler can read. Touch a primitive, update its entity in the same commit.
Limitations
- The DSDS docs site itself is not configured yet — only the entity JSON it would consume.
In this repository4 other files it gives its agents.
- AGENTS.mdAGENTS.md
- extension/AGENTS.mdAGENTS.md
- web/AGENTS.mdAGENTS.md
- .cursor/rules/commit-checks.mdcCursor rule
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

