6.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
modern-sk — a tactile, dark-first React component library built on Radix primitives. Distributed as a git-installable / publishable package; only dist/ ships. Consumers get built ESM + CJS, .d.ts types, and a single styles.css.
Commands
npm run dev # Rsbuild playground at http://localhost:3000 (renders src/App.tsx — every component on one page)
npm run storybook # Storybook component explorer + autodocs at http://localhost:6006
npm run build # build publishable package: tsup (JS/types) + build:css into dist/
npm run lint # rslint (rslint.config.ts) — covers src/ including stories
npm run format # prettier --write .
No test suite exists. npm run build runs automatically on install via the prepare script — consumers build the package themselves.
The build is two steps that must both run (the build script chains them):
tsupbundlessrc/index.ts→ ESM/CJS + types.react/react-domare externalized (peer deps);radix-ui+@phosphor-icons/reactare bundled.build:cssruns esbuild twice:src/styles/index.css→dist/styles.css(fontless core), andsrc/styles/fonts.css→dist/fonts.csswith--loader:.ttf=dataurl(inlines the self-hosted Anta font). Both are package exports.
Architecture
Two parallel surfaces share the same source but never mix at publish time:
- Library (shipped):
src/index.tsis the public entry. It re-exports everything fromsrc/components/ui.tsx, plusThemeProvider/useThemefromsrc/components/theme.tsx, and exposesTooltipProvider(Radix'sTooltip.Provider). The shipped stylesheet entry issrc/styles/index.css. - Playground (dev-only, never published): two of them —
src/index.tsxmounts thesrc/App.tsxkitchen sink (Rsbuild dev target,src/styles/global.css), and Storybook (.storybook/config +src/stories/*.stories.tsx) is the component catalogue with autodocs.
Only dist/ ships (files: ["dist"]), so the playground, stories, and .storybook/ are excluded from the package automatically — but they ARE committed to git so anyone can run them.
src/styles/index.css (shipped) vs src/styles/global.css (dev) is a deliberate split: index.css imports only tokens.css + components.css and applies box-sizing at zero specificity via :where([class^='modern-sk-']) so it never touches consumer elements. global.css adds a global reset, the optional fonts.css, and kitchen-sink layout helpers — those must stay out of the shipped bundle.
Fonts (src/styles/fonts.css)
Fonts are NOT in the core stylesheet. tokens.css defines --font-display/sans/mono chains that degrade to system-ui; the actual faces (Anta @font-face + Onest/Geist Mono Google Fonts @import) live in src/styles/fonts.css, shipped as the optional modern-sk/fonts.css export. Consumers either import it, or override the --font-* tokens to remap typefaces. Storybook's preview.tsx and the dev global.css both import fonts.css so the playgrounds stay branded.
Components (src/components/)
src/components/ui.tsx is a barrel that re-exports one folder per component (button/, text-field/, …); cx/shared helpers live in src/components/utils.ts. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach modern-sk-* classes and spread props. The cx() helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — all appearance is driven by modern-sk-* classes resolving against CSS custom properties.
Exception — text inputs (text-field/): TextField/TextArea/SearchField animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (modern-sk-field--animated, caret stays visible) over a mirrored per-character <span> overlay that plays the modern-sk-char-in/out keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from modern-sk-* classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass animated={false} to opt out and render the plain native field.
Exception — knob (knob/): a rotary circular slider (Knob), ported from the design handoff. Two visuals move at different speeds — the dial is bound 1:1 to the pointer while dragging (instant, continuous angle via inline transform: rotate), while the gauge fill + value snap to detents and glide between them (like the stepped Slider). It tracks two values: a continuous visual (drives the dial mid-drag) and the snapped committed (drives fill/ticks/aria); dialValue = dragging ? visual : committed, so on drag-release the dial settles to the detent. Three non-obvious constraints, all already tried-and-rejected — don't regress them: (1) the gauge fill must be the full arc revealed via strokeDashoffset set as a plain number — pathLength=1 + CSS var()/calc silently collapsed to a full ring, and transition: d on a swept arc interpolates the endpoint as a straight chord and distorts the arc mid-glide; dashoffset eases along the true circle. (2) the dial transition is disabled via .is-dragging so the mouse-bound dial never lags. (3) wheel-to-change uses a native non-passive wheel listener (via useEffect + a ref-held handler) because React's synthetic onWheel is passive and preventDefault can't block page scroll. animated defaults true when step > 0.
Styling system (src/styles/)
tokens.css— single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here.components.css— themodern-sk-*class definitions.- Dark/light is driven by
data-themeon<html>, set byThemeProvider(persisted tolocalStorageunder keymodern-sk-theme, defaultdark).
When adding or changing a component: add the wrapper folder under src/components/ and re-export it from ui.tsx, define its modern-sk-* styles in components.css, and pull any new color/spacing value from a token in tokens.css rather than hardcoding.
Consumer contract
Consumers import modern-sk/styles.css once at app root, wrap their tree in ThemeProvider, and wrap any tooltip-using subtree in TooltipProvider. Keep these provider requirements intact when refactoring exports.
Conventions
- React 19, React Compiler is enabled (
babel-plugin-react-compilerin the Rsbuild dev pipeline). - TypeScript is
noEmit+verbatimModuleSyntax: useimport typefor type-only imports.noUnusedLocals/noUnusedParametersare on. - ESM-only package (
"type": "module").