@weichenlin/swell-ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,288 @@
1
- # Temporary Holding Version
1
+ # swell-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hand-built React 19 components styled with Tailwind CSS v4: Badge, Button, Card, Checkbox,
4
+ CheckboxGroup, Dialog, Drawer, Input (plus Search, Password and Copy), Meter, Select, Sidebar,
5
+ Switch, Tabs, TabNav, ThemeSwitcher, Toast and Tooltip.
6
+
7
+ The package ships ESM, type declarations and two CSS files. Your app compiles the CSS with its own
8
+ Tailwind v4, so the design tokens become your utilities too (`bg-primary`, `text-fg`, ...).
9
+
10
+ ## Use it in an app
11
+
12
+ Peer dependencies: `react` and `react-dom` 19, `tailwindcss` 4.3+, `lucide-react`.
13
+
14
+ ```sh
15
+ pnpm add @weichenlin/swell-ui
16
+ ```
17
+
18
+ In the CSS file that imports Tailwind:
19
+
20
+ ```css
21
+ @import 'tailwindcss';
22
+ @import '@weichenlin/swell-ui/styles.css';
23
+ ```
24
+
25
+ `styles.css` brings the tokens (`theme.css`) and an `@source` that points Tailwind at the bundled
26
+ components, so their classes are generated even though Tailwind skips `node_modules` by default.
27
+
28
+ ```tsx
29
+ import { Button, toast, Toaster } from '@weichenlin/swell-ui'
30
+
31
+ export function App() {
32
+ return (
33
+ <>
34
+ <Button onClick={() => toast.success('Saved')}>Save</Button>
35
+ <Toaster />
36
+ </>
37
+ )
38
+ }
39
+ ```
40
+
41
+ ### Change the brand
42
+
43
+ The default look is neutral gray with a near-black primary. A rebrand is two tokens, set once,
44
+ after the import:
45
+
46
+ ```css
47
+ @import 'tailwindcss';
48
+ @import '@weichenlin/swell-ui/styles.css';
49
+
50
+ @theme {
51
+ --color-primary: #f7a501; /* buttons, input focus, checked switches and checkboxes, halos */
52
+ --color-primary-fg: #141413; /* text and marks drawn on the primary fill */
53
+ }
54
+ ```
55
+
56
+ That covers light, dark-from-the-system and `data-theme="dark"` alike. Input focus
57
+ (`--color-input-focus`), checked controls (`--color-control-checked`, `-fg`) and the focus halo
58
+ (`--color-input-halo`) are `var()` references to these two, resolved at runtime, so they follow.
59
+
60
+ To use a different value per scheme, write both on one line with `light-dark()`:
61
+
62
+ ```css
63
+ @theme {
64
+ --color-primary: light-dark(#2563eb, #60a5fa);
65
+ --color-primary-fg: light-dark(#ffffff, #0b1220);
66
+ }
67
+ ```
68
+
69
+ A light brand color (yellow, lime, cyan) can fall under 3:1 against the light page. The buttons
70
+ stay readable through `--color-primary-fg`, but if the focus border or a checked box needs more
71
+ contrast, override just that one:
72
+
73
+ ```css
74
+ @theme {
75
+ --color-input-focus: light-dark(#b37700, #f7a501); /* darker amber on light only */
76
+ }
77
+ ```
78
+
79
+ Set tokens in `@theme` (or a plain `:root` rule) rather than on an inner element: the derived
80
+ tokens are resolved on the root.
81
+
82
+ ### Tokens
83
+
84
+ Every color token is a `light-dark(<light>, <dark>)` pair; override it the same way, once. All of
85
+ them are also Tailwind utilities (`bg-surface`, `text-fg-muted`, `border-input-border`, ...).
86
+
87
+ | Token | Used for |
88
+ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
89
+ | `--color-primary`, `--color-primary-fg` | Brand fill and the text on it |
90
+ | `--color-accent` | Secondary brand color; unused by components, defaults to primary |
91
+ | `--color-bg` | Page background |
92
+ | `--color-surface`, `-surface-raised`, `-surface-muted` | Cards and panels; raised (tooltips, sheets); muted (hover fills, secondary button) |
93
+ | `--color-fg`, `-fg-strong`, `-fg-muted`, `-fg-subtle` | Text, from strongest to quietest (`fg-subtle` stays >=4.5:1) |
94
+ | `--color-border`, `-border-strong` | Dividers and hairlines |
95
+ | `--color-ring`, `-ring-deep` | 1px button and tab-list rings |
96
+ | `--color-inverse`, `-inverse-fg` | `dark` button, tooltips |
97
+ | `--color-focus` | Keyboard focus outline (blue) |
98
+ | `--color-input-bg`, `-input-bg-hover` | Text field fill |
99
+ | `--color-input-border`, `-input-border-hover` | Text field border; also the off track of Switch and box of Checkbox |
100
+ | `--color-input-placeholder` | Placeholder text |
101
+ | `--color-input-focus` | Focused field border. Default: `var(--color-primary)` |
102
+ | `--color-input-halo` | 3px ring around a focused field. Default: input-focus at 14% |
103
+ | `--color-input-halo-invalid`, `-invalid-focus` | Same ring for invalid fields (danger at 12% / 22%) |
104
+ | `--color-input-highlight` | Inset top highlight on fields |
105
+ | `--color-control-checked`, `-control-checked-fg` | Checked Switch/Checkbox fill and its mark. Default: primary / primary-fg |
106
+ | `--color-popover-bg`, `-popover-border`, `-popover-highlight` | Select picker, dialogs; highlight is the active row |
107
+ | `--color-dialog-backdrop` | Dimmed page behind Dialog and Drawer |
108
+ | `--color-{success,warning,danger,info,neutral}` | Status tone: solid fill / icon |
109
+ | `--color-{tone}-fg` | Text on the solid fill |
110
+ | `--color-{tone}-subtle-bg`, `-subtle-fg`, `-border` | Tinted badge, its text and hairline |
111
+ | `--shadow-input`, `-input-lift`, `-switch-thumb`, `-popover`, `-dialog`, `-whisper` | Elevation (`shadow-*` utilities) |
112
+ | `--container-page` | `max-w-page` (1200px) |
113
+
114
+ ### Optional looks
115
+
116
+ ```css
117
+ @import 'tailwindcss';
118
+ @import '@weichenlin/swell-ui/styles.css';
119
+ @import '@weichenlin/swell-ui/themes/warm.css'; /* parchment surfaces, brown primary */
120
+ @import '@weichenlin/swell-ui/typography.css'; /* h1-h3 in the serif stack, weight 500 */
121
+ ```
122
+
123
+ Both are independent; your own `@theme` overrides go after them.
124
+
125
+ ### Fonts
126
+
127
+ swell-ui sets no font names: text uses Tailwind's `--font-sans` (the system UI stack), and
128
+ `font-serif` / `font-mono` are Tailwind's defaults too. To use a web font, ship its files with
129
+ your app, for example with [Fontsource](https://fontsource.org):
130
+
131
+ ```sh
132
+ pnpm add @fontsource-variable/inter
133
+ ```
134
+
135
+ ```css
136
+ @import 'tailwindcss';
137
+ @import '@fontsource-variable/inter';
138
+ @import '@weichenlin/swell-ui/styles.css';
139
+
140
+ @theme {
141
+ --font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
142
+ }
143
+ ```
144
+
145
+ ### Radius
146
+
147
+ swell-ui does not touch Tailwind's radius scale; components use the stock `rounded-*` classes, so
148
+ `rounded-md` is still 6px in your app. Changing `--radius-*` in your `@theme` changes the
149
+ components too.
150
+
151
+ Coming from 0.1, which replaced the scale: shift each class one step up to keep the old size.
152
+
153
+ | 0.1 class | px | Now |
154
+ | --------------- | --- | ------------- |
155
+ | `rounded-sharp` | 4 | `rounded-sm` |
156
+ | `rounded-sm` | 6 | `rounded-md` |
157
+ | `rounded-md` | 8 | `rounded-lg` |
158
+ | `rounded-lg` | 12 | `rounded-xl` |
159
+ | `rounded-xl` | 16 | `rounded-2xl` |
160
+ | `rounded-2xl` | 24 | `rounded-3xl` |
161
+ | `rounded-3xl` | 32 | `rounded-4xl` |
162
+
163
+ ### Tokens only
164
+
165
+ To use the tokens without the components (no component classes generated), import `theme.css`
166
+ instead:
167
+
168
+ ```css
169
+ @import 'tailwindcss';
170
+ @import '@weichenlin/swell-ui/theme.css';
171
+ ```
172
+
173
+ ### `cn`
174
+
175
+ `cn` (clsx + tailwind-merge) is exported and knows the custom tokens, so
176
+ `cn('text-fg-muted', 'text-danger')` gives `text-danger` and `cn('shadow-popover', 'shadow-none')`
177
+ gives `shadow-none`.
178
+
179
+ ## Dark mode
180
+
181
+ Without a `data-theme` attribute the theme follows the system (`prefers-color-scheme`).
182
+ `data-theme="light"` or `data-theme="dark"` on `<html>` forces one. The `dark:` variant follows the
183
+ same rule.
184
+
185
+ Under the hood the root's `color-scheme` is `light dark`, `light` or `dark` accordingly, and every
186
+ color token picks its side with `light-dark()`. Browsers support it natively from Chrome 123,
187
+ Safari 17.5 and Firefox 120; Tailwind's production build rewrites it for older targets.
188
+
189
+ `useTheme` persists the choice and sets the attribute; `ThemeSwitcher` is the toggle button:
190
+
191
+ ```tsx
192
+ import { ThemeSwitcher, useTheme } from '@weichenlin/swell-ui'
193
+
194
+ function Header() {
195
+ const { theme, setTheme } = useTheme({ storageKey: 'my-app-theme' }) // default 'swell-ui-theme'
196
+ return <ThemeSwitcher value={theme} onChange={setTheme} />
197
+ }
198
+ ```
199
+
200
+ To avoid a flash of the wrong theme before React mounts, apply the saved value in `<head>`:
201
+
202
+ ```html
203
+ <script>
204
+ try {
205
+ const t = localStorage.getItem('my-app-theme')
206
+ if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t
207
+ } catch {}
208
+ </script>
209
+ ```
210
+
211
+ ## Router links: `asChild`
212
+
213
+ `SidebarItem` and `TabNavLink` render a plain `<a>`. With `asChild` they render their only child
214
+ instead (your router's Link) and merge their props, classes, ref and handlers into it. The link
215
+ sets `href` and `aria-current="page"`; the current item is styled from that attribute.
216
+
217
+ ```tsx
218
+ import { Link } from '@tanstack/react-router' // any router's Link
219
+ import {
220
+ Sidebar,
221
+ SidebarBadge,
222
+ SidebarBody,
223
+ SidebarGroup,
224
+ SidebarIcon,
225
+ SidebarItem,
226
+ SidebarProvider,
227
+ SidebarTrigger,
228
+ } from '@weichenlin/swell-ui'
229
+ import { Box } from 'lucide-react'
230
+
231
+ function Nav() {
232
+ return (
233
+ <SidebarProvider>
234
+ <SidebarTrigger />
235
+ <Sidebar aria-label="Main">
236
+ <SidebarBody>
237
+ <SidebarGroup>
238
+ <SidebarItem asChild>
239
+ <Link to="/projects">
240
+ <SidebarIcon>
241
+ <Box />
242
+ </SidebarIcon>
243
+ Projects
244
+ <SidebarBadge>8</SidebarBadge>
245
+ </Link>
246
+ </SidebarItem>
247
+ </SidebarGroup>
248
+ </SidebarBody>
249
+ </Sidebar>
250
+ </SidebarProvider>
251
+ )
252
+ }
253
+ ```
254
+
255
+ The accessible name is "Projects 8". Plain text children become the truncating label. Below 960px
256
+ the sidebar is a drawer; a click the link handles in place (it calls `preventDefault`, as router
257
+ links do) closes it.
258
+
259
+ `TabNavLink` works the same way and takes `icon` and `badge` props like `Tab`:
260
+
261
+ ```tsx
262
+ <TabNav aria-label="Sections">
263
+ <TabNavLink asChild icon={<Boxes />} badge={<Badge size="sm">12</Badge>}>
264
+ <Link to="/resources">Resources</Link>
265
+ </TabNavLink>
266
+ </TabNav>
267
+ ```
268
+
269
+ ## Develop
270
+
271
+ Node 26.11.1 (`.node-version`, via fnm) and pnpm 12.
272
+
273
+ ```sh
274
+ pnpm install
275
+ pnpm storybook # http://localhost:6006
276
+ pnpm test # vitest (jsdom)
277
+ pnpm typecheck # tsc -b
278
+ pnpm lint # vp check: format, lint, type-aware lint
279
+ pnpm knip # unused files, exports and dependencies
280
+ pnpm build # vp pack -> dist/index.js, index.d.ts and the CSS files
281
+ pnpm build-storybook
282
+ ```
283
+
284
+ The CSS files under `src/styles/` are copied into `dist/` as they are, so the
285
+ `@source "./*.js"` in `styles.css` is relative to `dist/`. Storybook and the lint use
286
+ `src/styles/dev.css`, which adds Tailwind and scans `src/` and `stories/`.
287
+
288
+ A lefthook pre-commit hook runs `vp check --fix` on staged files and `knip`.