Files
modern-sk/CLAUDE.md
T
2026-06-02 18:10:19 +03:00

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):

  • tsup bundles src/index.ts → ESM/CJS + types. react/react-dom are externalized (peer deps); radix-ui + @phosphor-icons/react are bundled.
  • build:css runs esbuild twice: src/styles/index.cssdist/styles.css (fontless core), and src/styles/fonts.cssdist/fonts.css with --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.ts is the public entry. It re-exports everything from src/components/ui.tsx, plus ThemeProvider/useTheme from src/components/theme.tsx, and exposes TooltipProvider (Radix's Tooltip.Provider). The shipped stylesheet entry is src/styles/index.css.
  • Playground (dev-only, never published): two of them — src/index.tsx mounts the src/App.tsx kitchen 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 numberpathLength=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 — the modern-sk-* class definitions.
  • Dark/light is driven by data-theme on <html>, set by ThemeProvider (persisted to localStorage under key modern-sk-theme, default dark).

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-compiler in the Rsbuild dev pipeline).
  • TypeScript is noEmit + verbatimModuleSyntax: use import type for type-only imports. noUnusedLocals/noUnusedParameters are on.
  • ESM-only package ("type": "module").