@blakesteve/roster 4.4.0 → 4.6.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
@@ -41,7 +41,10 @@ npm install @tanstack/react-table
41
41
  ```
42
42
 
43
43
  ```tsx
44
- import { DataTable, type RosterTableFeatures } from "@blakesteve/roster/data-table";
44
+ import {
45
+ DataTable,
46
+ type RosterTableFeatures,
47
+ } from "@blakesteve/roster/data-table";
45
48
  ```
46
49
 
47
50
  It is a peer rather than a bundled dependency because v9 types column
@@ -87,7 +90,7 @@ stripping padding off buttons and turning `border-transparent` into a visible
87
90
  1px line. **Below `utilities`**, so your app's classes and variants such as
88
91
  `dark:hidden` still win.
89
92
 
90
- Layers are ordered by *first registration*, so importing Roster before Tailwind
93
+ Layers are ordered by _first registration_, so importing Roster before Tailwind
91
94
  is the reliable arrangement. If you would rather not depend on import order at
92
95
  all, declare the same line yourself at the top of your global stylesheet and it
93
96
  holds regardless.
@@ -130,7 +133,9 @@ document head:
130
133
  <script>
131
134
  try {
132
135
  var s = localStorage.getItem("roster-theme");
133
- var dark = s ? s === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
136
+ var dark = s
137
+ ? s === "dark"
138
+ : matchMedia("(prefers-color-scheme: dark)").matches;
134
139
  if (dark) document.documentElement.classList.add("dark");
135
140
  } catch (e) {}
136
141
  </script>
@@ -140,9 +145,157 @@ If you pass a custom `storageKey`, change the script to match — a mismatch mea
140
145
  the script and the toggle disagree, which shows up as a flash on every reload.
141
146
 
142
147
  `Navbar`'s `themeMode` prop is a different thing: it describes what palette the
143
- bar paints *itself* with. Pair them with `themeMode="auto"` and the nav follows
148
+ bar paints _itself_ with. Pair them with `themeMode="auto"` and the nav follows
144
149
  whatever `ThemeToggle` sets.
145
150
 
151
+ ### The UI font
152
+
153
+ Controls, labels and table cells render in `--roster-font-ui`. Set nothing and
154
+ they use a system sans stack, which is the point: without it they would inherit
155
+ whatever your app puts on `body`, and an app that reads in a serif would get
156
+ serif buttons and serif badges.
157
+
158
+ To match your own UI face, define the variable once:
159
+
160
+ ```css
161
+ :root {
162
+ --roster-font-ui: "Archivo", ui-sans-serif, system-ui, sans-serif;
163
+ }
164
+ ```
165
+
166
+ Two sets of components opt out. `Card` and `Link` inherit on purpose, because
167
+ they wrap your content and that text is not Roster's to restyle. `Eyebrow`,
168
+ `InlineCode`, `Stat`, `Pullquote`, `DescriptionList`, `MatchupCard` and
169
+ `Countdown` ask for a monospace face as a design decision.
170
+
171
+ ### Solid fill contrast
172
+
173
+ `solid` is the one variant where a component picks both the background and the
174
+ text on it, so it can fail contrast on its own with no help from your app. Every
175
+ solid fill in `Badge`, `Pill` and `Button` is measured against WCAG AA (4.5:1)
176
+ by `src/contrast.test.ts`, at rest **and** on hover, in both themes.
177
+
178
+ Which foreground a family gets is decided per family, not by a rule:
179
+
180
+ - **White** on primary, error and neutral.
181
+ - **`gray-950`** on teal, success, orange, purple, info and amber — a mid-tone
182
+ fill cannot carry white. Solid teal with white text measured 2.49:1.
183
+ - Three of them flip: orange, purple and success take dark text in light mode
184
+ and white in dark, because Badge uses a darker fill in dark mode.
185
+
186
+ One consequence worth knowing if you retheme. A dark foreground assumes the fill
187
+ stays light. If you retint `--roster-teal-500` substantially darker, solid teal
188
+ becomes near-black on near-black — worse than the white it replaced. The
189
+ `Foundations/Solid fill contrast` story measures the rendered elements rather
190
+ than the tokens, so pointing it at your palette reports _your_ ratios.
191
+
192
+ Note also that a solid fill's contrast against the surface _behind_ it is a
193
+ separate requirement (WCAG 1.4.11, 3:1) and is not covered here. Solid amber on
194
+ a white page is 1.67:1, so it has effectively no visible edge.
195
+
196
+ ### Focus
197
+
198
+ Every focusable control draws the same ring: `--roster-ring` for the ring
199
+ itself, `--roster-ring-offset` for the 2px band that separates it from the
200
+ control's own fill. Both flip with the theme — `primary-500` on white in light,
201
+ `primary-400` on `gray-950` in dark — which puts the indicator at 6.37:1 and
202
+ 7.31:1 against the surface either side of it, where WCAG 1.4.11 asks for 3:1.
203
+
204
+ Retint it for your own accent:
205
+
206
+ ```css
207
+ :root {
208
+ --roster-ring: #7c3aed;
209
+ }
210
+ .dark {
211
+ --roster-ring: #a78bfa;
212
+ }
213
+ ```
214
+
215
+ Set it in **both** scopes. Roster's own `.dark` rule has the same specificity
216
+ and comes later in the stylesheet, so a `:root`-only override is silently
217
+ discarded in dark mode.
218
+
219
+ The band matters more than it looks. Without it the ring sits directly on the
220
+ fill, and a `primary-500` ring on a `primary-600` button is 1.37:1 — which is
221
+ why the ring and its offset are a pair rather than two independent knobs.
222
+
223
+ ### Motion
224
+
225
+ Two hand-rolled utilities, both of which stop for `prefers-reduced-motion`.
226
+
227
+ `animate-in` is the entrance, composed from modifiers the way
228
+ `tailwindcss-animate` does it — one keyframe reading four custom properties, and
229
+ each modifier setting one, so `fade-in-0 zoom-in-95 slide-in-from-top-2` combine
230
+ into a single animation rather than three fighting over `transform`. `Tooltip`
231
+ uses it, keyed off Radix's `data-side` so the panel always rises from the side it
232
+ is anchored on.
233
+
234
+ The suffixes are that plugin's, kept so anyone arriving from it keeps their
235
+ vocabulary. Each one is the value the modifier animates from: `-0` is an opacity,
236
+ `95` is a scale percentage, and `-2` is a step on Tailwind's spacing scale, so
237
+ `slide-in-from-top-2` starts `0.5rem` above its resting position. Only the steps
238
+ Roster's own components need are implemented; adding `-4` or `zoom-in-90` is one
239
+ line each.
240
+
241
+ `animate-in` on its own does nothing, by design: with no modifier the keyframe's
242
+ start values are the element's resting state. It is the engine, and a modifier is
243
+ what gives it somewhere to start. It fills `backwards`, so `animation-delay` can
244
+ stagger a group without every item flashing at rest first.
245
+
246
+ The default duration is 150ms, matching the plugin. At that speed a `-2` slide
247
+ covers 8px, which is right for a tooltip and reads as a twitch on anything
248
+ larger — `Foundations/Motion` in Storybook has controls for duration, easing,
249
+ direction and stagger to find what suits a bigger surface.
250
+
251
+ It is hand-rolled rather than a dependency: seven utilities does not justify a
252
+ package in a library that ships compiled CSS, and it keeps the stylesheet
253
+ self-contained — the same reasoning as not bundling a preflight.
254
+
255
+ `animate-shimmer` is a highlight travelling across a base, used by `Countdown`'s
256
+ `gradient` variant. Retint it with two variables:
257
+
258
+ ```css
259
+ :root {
260
+ --roster-shimmer-base: #084063;
261
+ --roster-shimmer-highlight: #9bcce9;
262
+ }
263
+ .dark {
264
+ /* … */
265
+ }
266
+ ```
267
+
268
+ The base is what carries the text whenever the highlight is elsewhere, so it has
269
+ to be readable on its own — it is also all you get under reduced motion, where
270
+ the sweep stops and the highlight parks off-frame. The highlight is deliberately
271
+ exempt from that bar: it is a specular pass on screen for a fraction of a second
272
+ per glyph, and holding it to 3:1 would flatten the effect.
273
+
274
+ ### Scrollbars
275
+
276
+ `custom-scrollbar` gives any scrollable surface a slim themed scrollbar.
277
+ `Textarea` carries it already.
278
+
279
+ ```css
280
+ :root {
281
+ --roster-scrollbar-thumb: #0f6498;
282
+ }
283
+ .dark {
284
+ --roster-scrollbar-thumb: #5ea3de;
285
+ }
286
+ ```
287
+
288
+ Set it in both scopes — Roster's own `.dark` rule has equal specificity and comes
289
+ later, so a `:root`-only override is discarded in dark mode.
290
+
291
+ The default sits close to what the browser already draws, on purpose: a
292
+ component library should not repaint your scrollbars merely for being installed.
293
+ Color is the part that works everywhere. The 8px width and inset-pill thumb live
294
+ in a `::-webkit-scrollbar` block that only legacy engines reach. Every current
295
+ browser honors `scrollbar-width` / `scrollbar-color`, and drops the WebKit
296
+ pseudo-elements for any element that sets either of them, so on anything modern
297
+ `thin` is the whole shape control available.
298
+
146
299
  ### Components that render links
147
300
 
148
301
  `Breadcrumbs` renders a plain `<a>` by default, which is right for a static
@@ -157,8 +310,8 @@ import NextLink from "next/link";
157
310
 
158
311
  In an app with React Server Components, do that from a client component.
159
312
  `linkComponent` is a function, and functions do not cross the RSC boundary —
160
- passing it from a server component fails the render with *Functions cannot be
161
- passed directly to Client Components*. A small `"use client"` wrapper binds it
313
+ passing it from a server component fails the render with _Functions cannot be
314
+ passed directly to Client Components_. A small `"use client"` wrapper binds it
162
315
  once, and the pages that use the wrapper stay server-rendered.
163
316
 
164
317
  `Eyebrow` takes the other approach: `as` accepts any element and the props
@@ -192,58 +345,58 @@ function App() {
192
345
 
193
346
  ### Atoms
194
347
 
195
- | Component | Description |
196
- |---|---|
197
- | `Avatar` | User avatar with image, initials fallback, and optional popover |
198
- | `Badge` | Status label with semantic color schemes and fill variants |
199
- | `Button` | Primary interactive element: solid, soft, outline, ghost, link variants |
200
- | `Card` | Bordered surface container |
201
- | `Checkbox` | Accessible checkbox with label support |
202
- | `Disclosure` | Show/hide toggle using HeadlessUI |
203
- | `Eyebrow` | Small tracked-out uppercase label above a heading or beside a rule; polymorphic via `as` |
204
- | `InlineCode` | Inline `<code>` for identifiers in running prose |
205
- | `Input` | Text input with label, error state, and icon slots |
206
- | `LabeledDivider` | Horizontal rule carrying a label, with an optional trailing count |
207
- | `Link` | Styled anchor with variant support |
208
- | `PasswordInput` | Password field with a show/hide reveal toggle |
209
- | `Pill` | Inline phrase chrome: social proof, live state, applied filters |
210
- | `AvatarStrip` | Stacked avatar row with overflow chip, dismiss button, trailing slot, and label area |
211
- | `CollapsibleSection` | Clamps any content (prose, chips, image grids) to a fixed height with a fade and expand/collapse toggle |
212
- | `LiquidTabs` | Controlled tab strip with a liquid sliding pill indicator: pill and filled variants |
213
- | `Select` | Dropdown selector |
214
- | `SegmentBar` | Proportional horizontal bar divided into colored segments with optional legend |
215
- | `Stat` | A single figure with its label and, optionally, where the figure came from |
216
- | `Spinner` | Loading indicator |
217
- | `Switch` | Toggle switch |
218
- | `Textarea` | Multi-line text input |
219
- | `ThemeToggle` | Flips class-based dark mode on the document root and remembers the choice; labels and icons are configurable |
220
- | `Tooltip` | Radix-powered tooltip: hover/focus on desktop, tap-to-toggle on mobile |
348
+ | Component | Description |
349
+ | -------------------- | ------------------------------------------------------------------------------------------------------------ |
350
+ | `Avatar` | User avatar with image, initials fallback, and optional popover |
351
+ | `Badge` | Status label with semantic color schemes and fill variants |
352
+ | `Button` | Primary interactive element: solid, soft, outline, ghost, link variants |
353
+ | `Card` | Bordered surface container |
354
+ | `Checkbox` | Accessible checkbox with label support |
355
+ | `Disclosure` | Show/hide toggle using HeadlessUI |
356
+ | `Eyebrow` | Small tracked-out uppercase label above a heading or beside a rule; polymorphic via `as` |
357
+ | `InlineCode` | Inline `<code>` for identifiers in running prose |
358
+ | `Input` | Text input with label, error state, and icon slots |
359
+ | `LabeledDivider` | Horizontal rule carrying a label, with an optional trailing count |
360
+ | `Link` | Styled anchor with variant support |
361
+ | `PasswordInput` | Password field with a show/hide reveal toggle |
362
+ | `Pill` | Inline phrase chrome: social proof, live state, applied filters |
363
+ | `AvatarStrip` | Stacked avatar row with overflow chip, dismiss button, trailing slot, and label area |
364
+ | `CollapsibleSection` | Clamps any content (prose, chips, image grids) to a fixed height with a fade and expand/collapse toggle |
365
+ | `LiquidTabs` | Controlled tab strip with a liquid sliding pill indicator: pill and filled variants |
366
+ | `Select` | Dropdown selector |
367
+ | `SegmentBar` | Proportional horizontal bar divided into colored segments with optional legend |
368
+ | `Stat` | A single figure with its label and, optionally, where the figure came from |
369
+ | `Spinner` | Loading indicator |
370
+ | `Switch` | Toggle switch |
371
+ | `Textarea` | Multi-line text input |
372
+ | `ThemeToggle` | Flips class-based dark mode on the document root and remembers the choice; labels and icons are configurable |
373
+ | `Tooltip` | Radix-powered tooltip: hover/focus on desktop, tap-to-toggle on mobile |
221
374
 
222
375
  ### Molecules
223
376
 
224
- | Component | Description |
225
- |---|---|
226
- | `Accordion` | Collapsible content sections (single or multi-expand) |
227
- | `Alert` | Inline notice strip with optional title and dismiss |
228
- | `Breadcrumbs` | Navigation trail; pass `linkComponent` to keep navigation client-side |
229
- | `CallToAction` | Prominent hero-style CTA block |
230
- | `DescriptionList` | Label and value pairs as a real `<dl>`: inline, stacked, or split |
231
- | `EmptyState` | Zero-data placeholder with icon and action slot |
232
- | `ErrorState` | Error display with retry action |
233
- | `MatchupCard` | Head-to-head comparison card |
234
- | `Pullquote` | A line lifted out of prose, as `<figure>` + `<blockquote>` + `<figcaption>` |
377
+ | Component | Description |
378
+ | ----------------- | --------------------------------------------------------------------------- |
379
+ | `Accordion` | Collapsible content sections (single or multi-expand) |
380
+ | `Alert` | Inline notice strip with optional title and dismiss |
381
+ | `Breadcrumbs` | Navigation trail; pass `linkComponent` to keep navigation client-side |
382
+ | `CallToAction` | Prominent hero-style CTA block |
383
+ | `DescriptionList` | Label and value pairs as a real `<dl>`: inline, stacked, or split |
384
+ | `EmptyState` | Zero-data placeholder with icon and action slot |
385
+ | `ErrorState` | Error display with retry action |
386
+ | `MatchupCard` | Head-to-head comparison card |
387
+ | `Pullquote` | A line lifted out of prose, as `<figure>` + `<blockquote>` + `<figcaption>` |
235
388
 
236
389
  ### Organisms
237
390
 
238
- | Component | Description |
239
- |---|---|
240
- | `ActionBar` | Sticky bottom action strip |
241
- | `Countdown` | Live countdown timer |
391
+ | Component | Description |
392
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
393
+ | `ActionBar` | Sticky bottom action strip |
394
+ | `Countdown` | Live countdown timer |
242
395
  | `DataTable` | Full-featured table with sorting and pagination via TanStack Table v9. Imported from `@blakesteve/roster/data-table` (see [DataTable columns](#datatable-columns)) |
243
- | `Dialog` | Modal dialog |
244
- | `Footer` | Site footer |
245
- | `Navbar` | Responsive navigation bar with mobile slide-out |
246
- | `Table` | Static data table |
396
+ | `Dialog` | Modal dialog |
397
+ | `Footer` | Site footer |
398
+ | `Navbar` | Responsive navigation bar with mobile slide-out |
399
+ | `Table` | Static data table |
247
400
 
248
401
  ### DataTable columns
249
402
 
@@ -252,7 +405,10 @@ the table. `DataTable` registers sorting and pagination and exports that set as
252
405
  `RosterTableFeatures`, so your columns name it:
253
406
 
254
407
  ```tsx
255
- import { DataTable, type RosterTableFeatures } from "@blakesteve/roster/data-table";
408
+ import {
409
+ DataTable,
410
+ type RosterTableFeatures,
411
+ } from "@blakesteve/roster/data-table";
256
412
  import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
257
413
 
258
414
  type Player = { name: string; points: number };
@@ -287,9 +443,9 @@ Server Component it typechecks and then throws at render — use `/utils` there.
287
443
 
288
444
  ### Hooks
289
445
 
290
- | Hook | Description |
291
- |---|---|
292
- | `useCountdown` | Countdown timer logic without the UI |
446
+ | Hook | Description |
447
+ | ---------------- | ------------------------------------------------------------------------------- |
448
+ | `useCountdown` | Countdown timer logic without the UI |
293
449
  | `useKeySequence` | Fires a callback when a sequence of keys is typed in order. Ships `KONAMI_CODE` |
294
450
 
295
451
  ## Authoring components: the `rst:` prefix
@@ -299,9 +455,9 @@ Every class Roster emits carries an `rst:` prefix — `rst:flex`, `rst:bg-white`
299
455
  not write the prefix and never see it.** It exists to stop a host app from
300
456
  overriding Roster by accident.
301
457
 
302
- Roster's stylesheet deliberately sits in a layer *below* the host's `utilities`
458
+ Roster's stylesheet deliberately sits in a layer _below_ the host's `utilities`
303
459
  so a consumer's `className` can override a component. Without a prefix that also
304
- means the host wins any class-name *collision* — including on Roster's own
460
+ means the host wins any class-name _collision_ — including on Roster's own
305
461
  internal elements, which the consumer never touches:
306
462
 
307
463
  - an app that used `bg-white` anywhere defeated the Textarea's
@@ -320,7 +476,7 @@ node scripts/prefix-classes.mjs src/components/atoms/Thing/Thing.tsx # dry ru
320
476
  APPLY=1 node scripts/prefix-classes.mjs src/components/atoms/Thing/Thing.tsx
321
477
  ```
322
478
 
323
- Two things are deliberately never prefixed. `dark` is the *consuming document's*
479
+ Two things are deliberately never prefixed. `dark` is the _consuming document's_
324
480
  theme class, which Roster's own `@custom-variant dark` matches by name — Button
325
481
  applies it directly for `surface="dark"`. And any class a consumer passes in,
326
482
  such as `AvatarStrip`'s `ringClass`, stays exactly as they wrote it.
@@ -351,9 +507,10 @@ npx vitest run --project storybook
351
507
 
352
508
  # Every emitted class carries the rst: prefix (runs as part of the build)
353
509
  npm run check:prefix
510
+ npm run check:classes-emit
354
511
  ```
355
512
 
356
- Two guards are worth knowing about, because the bug they catch cannot be
513
+ Three guards are worth knowing about, because the bugs they catch cannot be
357
514
  reproduced from inside this repo.
358
515
 
359
516
  `check:prefix` reads `dist/roster.css` and fails on any unprefixed class
@@ -361,10 +518,32 @@ selector. The collision it prevents only appears in an app with its own Tailwind
361
518
  build, so asserting on a rendered component here would never see it — the check
362
519
  has to be on the artifact.
363
520
 
521
+ `check:classes-emit` reads the same artifact and fails when a component
522
+ references a utility Tailwind never generated. Tailwind v4 builds utilities from
523
+ theme tokens, so a class naming a token that does not exist is not an error —
524
+ it simply produces no CSS, and the class sits in the component looking correct
525
+ while doing nothing. That is how the focus ring was inert: `Button` asked for
526
+ `ring-ring` and `ring-offset-background`, neither `--color-ring` nor
527
+ `--color-background` existed, and the ring fell back to `currentColor` — a
528
+ white ring on a white page. Nothing caught it, because typecheck cannot read a
529
+ class name and the unit tests assert a class is _present_ rather than that it
530
+ does anything.
531
+
532
+ It compares whole class names, variants included, so a misspelled variant fails
533
+ as readily as a misspelled utility. It reads component source only — `*.test.*`
534
+ and `*.stories.*` are skipped, because docs blurbs carry CSS code fences that
535
+ would false-positive, so a dead class in a story is not covered. Its
536
+ `KNOWN_DEAD` map is empty — the ten classes it was written to catch have all
537
+ been fixed. The map stays for the case
538
+ where a dead class cannot be fixed in the same sitting: add it there with a
539
+ reason and the build goes green on the state it inherited while still failing on
540
+ anything new. It also fails when a listed class starts emitting, so a fix cannot
541
+ leave a stale entry behind. Run it with `--verbose` to print the list.
542
+
364
543
  `src/lib/utils.test.ts` pins `cn`. `tailwind-merge` has to be configured with the
365
544
  prefix or it stops recognising Roster's classes as utilities and silently
366
545
  degrades to concatenation: conflicting classes both survive and the winner falls
367
- back to stylesheet order. The class string still *contains* what you asked for,
546
+ back to stylesheet order. The class string still _contains_ what you asked for,
368
547
  so a snapshot passes while the browser is wrong.
369
548
 
370
549
  ## Building
@@ -375,15 +554,15 @@ npm run build
375
554
 
376
555
  Output in `dist/`:
377
556
 
378
- | File | Description |
379
- |---|---|
380
- | `roster.es.js` | ES module bundle |
381
- | `roster.umd.js` | UMD bundle |
557
+ | File | Description |
558
+ | ------------------ | ------------------------------------------------------------------- |
559
+ | `roster.es.js` | ES module bundle |
560
+ | `roster.umd.js` | UMD bundle |
382
561
  | `data-table.es.js` | DataTable entry, keeping the TanStack import out of the main bundle |
383
- | `roster.css` | Compiled component styles, in the `roster` cascade layer |
384
- | `tokens.css` | Design token CSS variables |
385
- | `preflight.css` | Optional global reset (see Setup) |
386
- | `index.d.ts` | TypeScript definitions |
562
+ | `roster.css` | Compiled component styles, in the `roster` cascade layer |
563
+ | `tokens.css` | Design token CSS variables |
564
+ | `preflight.css` | Optional global reset (see Setup) |
565
+ | `index.d.ts` | TypeScript definitions |
387
566
 
388
567
  ## Contributing
389
568
 
@@ -395,4 +574,4 @@ Pull requests are welcome. Please:
395
574
 
396
575
  ## License
397
576
 
398
- MIT © [Blake Ball](https://github.com/blakesteve)
577
+ MIT © [Blake Ball](https://github.com/blakesteve)