@evinvest/uikit 0.24.0 → 0.26.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.
Files changed (54) hide show
  1. package/README.md +147 -4
  2. package/dist/components/alert-dialog.js +1 -1
  3. package/dist/components/alert-dialog.js.map +1 -1
  4. package/dist/components/app-shell.js +85 -0
  5. package/dist/components/app-shell.js.map +1 -0
  6. package/dist/components/bottom-tab-bar.js +91 -0
  7. package/dist/components/bottom-tab-bar.js.map +1 -0
  8. package/dist/components/dialog.js +2 -1
  9. package/dist/components/dialog.js.map +1 -1
  10. package/dist/components/drawer.js +6 -4
  11. package/dist/components/drawer.js.map +1 -1
  12. package/dist/components/list-rows.js +73 -0
  13. package/dist/components/list-rows.js.map +1 -0
  14. package/dist/components/mobile-app-bar.js +51 -0
  15. package/dist/components/mobile-app-bar.js.map +1 -0
  16. package/dist/components/nav-badge.js +61 -0
  17. package/dist/components/nav-badge.js.map +1 -0
  18. package/dist/components/nav-link.js +50 -0
  19. package/dist/components/nav-link.js.map +1 -0
  20. package/dist/components/nav-model.js +55 -0
  21. package/dist/components/nav-model.js.map +1 -0
  22. package/dist/components/page-frame.js +92 -0
  23. package/dist/components/page-frame.js.map +1 -0
  24. package/dist/components/resource-error.js +47 -0
  25. package/dist/components/resource-error.js.map +1 -0
  26. package/dist/components/section-nav.js +86 -0
  27. package/dist/components/section-nav.js.map +1 -0
  28. package/dist/components/settled.js +19 -0
  29. package/dist/components/settled.js.map +1 -0
  30. package/dist/components/sheet.js +8 -2
  31. package/dist/components/sheet.js.map +1 -1
  32. package/dist/components/shell-icons.js +57 -0
  33. package/dist/components/shell-icons.js.map +1 -0
  34. package/dist/components/shell-nav.js +141 -0
  35. package/dist/components/shell-nav.js.map +1 -0
  36. package/dist/components/system-banner.js +53 -0
  37. package/dist/components/system-banner.js.map +1 -0
  38. package/dist/components/use-shell-nav.js +46 -0
  39. package/dist/components/use-shell-nav.js.map +1 -0
  40. package/dist/generated/alert-dialog.js +2 -2
  41. package/dist/generated/alert-dialog.js.map +1 -1
  42. package/dist/generated/dialog.js +3 -3
  43. package/dist/generated/dialog.js.map +1 -1
  44. package/dist/generated/drawer.js +1 -1
  45. package/dist/generated/drawer.js.map +1 -1
  46. package/dist/generated/sheet.js +3 -3
  47. package/dist/generated/sheet.js.map +1 -1
  48. package/dist/index.d.ts +451 -6
  49. package/dist/index.js +12 -0
  50. package/dist/primitives/focus-scope.js +22 -6
  51. package/dist/primitives/focus-scope.js.map +1 -1
  52. package/package.json +1 -1
  53. package/styles/theme.css +180 -1
  54. package/styles/tokens.css +182 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/primitives/focus-scope.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n// `tabindex=\"-1\"` opts an element out of Tab order whatever its tag, so the\n// exclusion goes on every branch — otherwise a roving-tabindex grid's parked\n// cells would count as tab stops.\nconst FOCUSABLE = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"textarea:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"[tabindex]\",\n]\n .map(sel => `${sel}:not([tabindex=\"-1\"])`)\n .join(\",\");\n\n/**\n * Every enabled, Tab-reachable candidate under `root` in DOM (= Tab) order,\n * before the layout-based visibility filter; `tabindex=\"-1\"` is skipped on any\n * tag. For content that is fully visible while it exists (a popover), this is\n * the tab order itself. Internal to the kit.\n */\nexport function focusCandidates(root: HTMLElement): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));\n}\n\nfunction focusable(root: HTMLElement): HTMLElement[] {\n return focusCandidates(root).filter(\n (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,\n );\n}\n\n/**\n * Traps Tab focus inside the returned ref's subtree and restores focus to the\n * previously-focused element on unmount — the dep-light core of dialogs/menus,\n * replacing `@radix-ui/react-focus-scope`. On mount, focuses the first\n * focusable (or the container itself).\n *\n * Rust dialogs rely on the browser's native focus order within a fixed overlay;\n * see the README \"Limitations\".\n */\nexport function useFocusScope(enabled: boolean): React.RefObject<HTMLDivElement | null> {\n const ref = React.useRef<HTMLDivElement | null>(null);\n\n React.useEffect(() => {\n if (!enabled) return;\n const root = ref.current;\n if (!root) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const first = focusable(root)[0];\n (first ?? root).focus();\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key !== \"Tab\" || !root) return;\n const items = focusable(root);\n if (items.length === 0) {\n event.preventDefault();\n return;\n }\n const firstEl = items[0]!;\n const lastEl = items[items.length - 1]!;\n const active = document.activeElement;\n if (event.shiftKey && active === firstEl) {\n event.preventDefault();\n lastEl.focus();\n } else if (!event.shiftKey && active === lastEl) {\n event.preventDefault();\n firstEl.focus();\n }\n }\n\n root.addEventListener(\"keydown\", onKeyDown);\n return () => {\n root.removeEventListener(\"keydown\", onKeyDown);\n previouslyFocused?.focus?.();\n };\n }, [enabled]);\n\n return ref;\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAKvB,MAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EACG,IAAI,SAAO,GAAG,GAAG,uBAAuB,EACxC,KAAK,GAAG;AAQJ,SAAS,gBAAgB,MAAkC;AAChE,SAAO,MAAM,KAAK,KAAK,iBAA8B,SAAS,CAAC;AACjE;AAEA,SAAS,UAAU,MAAkC;AACnD,SAAO,gBAAgB,IAAI,EAAE;AAAA,IAC3B,CAAC,OAAO,GAAG,cAAc,KAAK,GAAG,eAAe,KAAK,OAAO,SAAS;AAAA,EACvE;AACF;AAWO,SAAS,cAAc,SAA0D;AACtF,QAAM,MAAM,MAAM,OAA8B,IAAI;AAEpD,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,IAAI;AACjB,QAAI,CAAC,KAAM;AAEX,UAAM,oBAAoB,SAAS;AACnC,UAAM,QAAQ,UAAU,IAAI,EAAE,CAAC;AAC/B,KAAC,SAAS,MAAM,MAAM;AAEtB,aAAS,UAAU,OAAsB;AACvC,UAAI,MAAM,QAAQ,SAAS,CAAC,KAAM;AAClC,YAAM,QAAQ,UAAU,IAAI;AAC5B,UAAI,MAAM,WAAW,GAAG;AACtB,cAAM,eAAe;AACrB;AAAA,MACF;AACA,YAAM,UAAU,MAAM,CAAC;AACvB,YAAM,SAAS,MAAM,MAAM,SAAS,CAAC;AACrC,YAAM,SAAS,SAAS;AACxB,UAAI,MAAM,YAAY,WAAW,SAAS;AACxC,cAAM,eAAe;AACrB,eAAO,MAAM;AAAA,MACf,WAAW,CAAC,MAAM,YAAY,WAAW,QAAQ;AAC/C,cAAM,eAAe;AACrB,gBAAQ,MAAM;AAAA,MAChB;AAAA,IACF;AAEA,SAAK,iBAAiB,WAAW,SAAS;AAC1C,WAAO,MAAM;AACX,WAAK,oBAAoB,WAAW,SAAS;AAC7C,yBAAmB,QAAQ;AAAA,IAC7B;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/primitives/focus-scope.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n// `tabindex=\"-1\"` opts an element out of Tab order whatever its tag, so the\n// exclusion goes on every branch — otherwise a roving-tabindex grid's parked\n// cells would count as tab stops.\nconst FOCUSABLE = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"textarea:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"[tabindex]\",\n]\n .map(sel => `${sel}:not([tabindex=\"-1\"])`)\n .join(\",\");\n\n/**\n * Every enabled, Tab-reachable candidate under `root` in DOM (= Tab) order,\n * before the layout-based visibility filter; `tabindex=\"-1\"` is skipped on any\n * tag. For content that is fully visible while it exists (a popover), this is\n * the tab order itself. Internal to the kit.\n */\nexport function focusCandidates(root: HTMLElement): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));\n}\n\nfunction focusable(root: HTMLElement): HTMLElement[] {\n return focusCandidates(root).filter(\n (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,\n );\n}\n\n/**\n * Where focus lands when the scope opens. `first` is the first Tab stop inside\n * (an input, an alert dialog's least destructive action); `container` is the\n * scope root itself, so a reader starts at the top and the first Tab reaches the\n * first control — right for a panel of content, where the first stop may be an\n * action one keypress away from firing.\n */\nexport type InitialFocus = \"first\" | \"container\";\n\nexport interface FocusScopeOptions {\n /** Default `first`; falls back to the container when nothing inside is focusable. */\n initialFocus?: InitialFocus;\n /**\n * Where focus goes back on close when the element focused before opening is\n * no use: `<body>` (Safari does not focus a clicked button), or detached (the\n * opener was re-rendered away). Pass the overlay's trigger.\n */\n returnFocusTo?: React.RefObject<HTMLElement | null>;\n}\n\nfunction usable(el: HTMLElement | null | undefined): el is HTMLElement {\n return !!el && el !== document.body && el.isConnected;\n}\n\n// A `<div>` without a tabindex ignores `.focus()`, so a scope with no Tab stops\n// used to leave focus on the trigger behind the scrim. `-1` makes the root\n// focusable by script (and by a click on its empty area, which keeps focus in\n// the scope) without adding it to the Tab order.\nfunction focusRoot(root: HTMLElement) {\n if (!root.hasAttribute(\"tabindex\")) root.tabIndex = -1;\n root.focus();\n}\n\n/**\n * Traps Tab focus inside the returned ref's subtree and restores focus to the\n * previously-focused element on unmount — the dep-light core of dialogs/menus,\n * replacing `@radix-ui/react-focus-scope`. On mount, focuses per\n * `initialFocus`; the root gets `tabindex=\"-1\"` whenever it is the target.\n *\n * Rust dialogs rely on the browser's native focus order within a fixed overlay;\n * see the README \"Limitations\".\n */\nexport function useFocusScope(\n enabled: boolean,\n { initialFocus = \"first\", returnFocusTo }: FocusScopeOptions = {},\n): React.RefObject<HTMLDivElement | null> {\n const ref = React.useRef<HTMLDivElement | null>(null);\n // Read at close, not captured at open: a trigger re-rendered while the\n // overlay was up is a new node by then.\n const fallbackRef = React.useRef(returnFocusTo);\n fallbackRef.current = returnFocusTo;\n\n React.useEffect(() => {\n if (!enabled) return;\n const root = ref.current;\n if (!root) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const first = initialFocus === \"first\" ? focusable(root)[0] : undefined;\n if (first) first.focus();\n else focusRoot(root);\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key !== \"Tab\" || !root) return;\n const items = focusable(root);\n if (items.length === 0) {\n event.preventDefault();\n return;\n }\n const firstEl = items[0]!;\n const lastEl = items[items.length - 1]!;\n const active = document.activeElement;\n // From the root itself Shift+Tab would leave the scope for whatever\n // precedes the portal, so both directions are steered explicitly.\n if (active === root) {\n event.preventDefault();\n (event.shiftKey ? lastEl : firstEl).focus();\n } else if (event.shiftKey && active === firstEl) {\n event.preventDefault();\n lastEl.focus();\n } else if (!event.shiftKey && active === lastEl) {\n event.preventDefault();\n firstEl.focus();\n }\n }\n\n root.addEventListener(\"keydown\", onKeyDown);\n return () => {\n root.removeEventListener(\"keydown\", onKeyDown);\n // Focus that already left the scope (a click outside landed on a field)\n // is the user's; only focus still inside, or dropped to <body>, returns.\n const active = document.activeElement;\n if (active && active !== document.body && !root.contains(active)) return;\n const target = usable(previouslyFocused) ? previouslyFocused : fallbackRef.current?.current;\n if (usable(target)) target.focus({ preventScroll: true });\n };\n }, [enabled, initialFocus]);\n\n return ref;\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAKvB,MAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EACG,IAAI,SAAO,GAAG,GAAG,uBAAuB,EACxC,KAAK,GAAG;AAQJ,SAAS,gBAAgB,MAAkC;AAChE,SAAO,MAAM,KAAK,KAAK,iBAA8B,SAAS,CAAC;AACjE;AAEA,SAAS,UAAU,MAAkC;AACnD,SAAO,gBAAgB,IAAI,EAAE;AAAA,IAC3B,CAAC,OAAO,GAAG,cAAc,KAAK,GAAG,eAAe,KAAK,OAAO,SAAS;AAAA,EACvE;AACF;AAsBA,SAAS,OAAO,IAAuD;AACrE,SAAO,CAAC,CAAC,MAAM,OAAO,SAAS,QAAQ,GAAG;AAC5C;AAMA,SAAS,UAAU,MAAmB;AACpC,MAAI,CAAC,KAAK,aAAa,UAAU,EAAG,MAAK,WAAW;AACpD,OAAK,MAAM;AACb;AAWO,SAAS,cACd,SACA,EAAE,eAAe,SAAS,cAAc,IAAuB,CAAC,GACxB;AACxC,QAAM,MAAM,MAAM,OAA8B,IAAI;AAGpD,QAAM,cAAc,MAAM,OAAO,aAAa;AAC9C,cAAY,UAAU;AAEtB,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,IAAI;AACjB,QAAI,CAAC,KAAM;AAEX,UAAM,oBAAoB,SAAS;AACnC,UAAM,QAAQ,iBAAiB,UAAU,UAAU,IAAI,EAAE,CAAC,IAAI;AAC9D,QAAI,MAAO,OAAM,MAAM;AAAA,QAClB,WAAU,IAAI;AAEnB,aAAS,UAAU,OAAsB;AACvC,UAAI,MAAM,QAAQ,SAAS,CAAC,KAAM;AAClC,YAAM,QAAQ,UAAU,IAAI;AAC5B,UAAI,MAAM,WAAW,GAAG;AACtB,cAAM,eAAe;AACrB;AAAA,MACF;AACA,YAAM,UAAU,MAAM,CAAC;AACvB,YAAM,SAAS,MAAM,MAAM,SAAS,CAAC;AACrC,YAAM,SAAS,SAAS;AAGxB,UAAI,WAAW,MAAM;AACnB,cAAM,eAAe;AACrB,SAAC,MAAM,WAAW,SAAS,SAAS,MAAM;AAAA,MAC5C,WAAW,MAAM,YAAY,WAAW,SAAS;AAC/C,cAAM,eAAe;AACrB,eAAO,MAAM;AAAA,MACf,WAAW,CAAC,MAAM,YAAY,WAAW,QAAQ;AAC/C,cAAM,eAAe;AACrB,gBAAQ,MAAM;AAAA,MAChB;AAAA,IACF;AAEA,SAAK,iBAAiB,WAAW,SAAS;AAC1C,WAAO,MAAM;AACX,WAAK,oBAAoB,WAAW,SAAS;AAG7C,YAAM,SAAS,SAAS;AACxB,UAAI,UAAU,WAAW,SAAS,QAAQ,CAAC,KAAK,SAAS,MAAM,EAAG;AAClE,YAAM,SAAS,OAAO,iBAAiB,IAAI,oBAAoB,YAAY,SAAS;AACpF,UAAI,OAAO,MAAM,EAAG,QAAO,MAAM,EAAE,eAAe,KAAK,CAAC;AAAA,IAC1D;AAAA,EACF,GAAG,CAAC,SAAS,YAAY,CAAC;AAE1B,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/uikit",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Dep-light React UI kit: shadcn-semantics components with zero @radix-ui / cva, mirroring the ev_lib::uikit Rust feature. Ships the shared design tokens.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
package/styles/theme.css CHANGED
@@ -1,6 +1,8 @@
1
1
  /* AUTO-GENERATED by `nix run .#gen` from the repo-root theme.css — do not edit. */
2
2
  /*
3
- * Toast (sonner) and Drawer enter/exit motion, shared by both ports. Imported by the token
3
+ * Toast (sonner) and Drawer enter/exit motion, the reduced-motion fade for Sheet and
4
+ * the dialogs, the entrances (rise / stagger / settled / skeleton) and the app-shell
5
+ * markers, shared by both ports. Imported by the token
4
6
  * sheets so the lifecycle ships with the theme contract.
5
7
  */
6
8
 
@@ -306,6 +308,163 @@
306
308
  }
307
309
  }
308
310
 
311
+ /* ── Sheet and dialogs under reduced motion ──────────────────────────────────
312
+ Both ports animate these with tw-animate-css (`animate-in` / `animate-out` +
313
+ `slide-*` / `zoom-*`), whose `enter` / `exit` keyframes read the
314
+ --tw-enter-* / --tw-exit-* variables the slide and zoom utilities set. Zeroing
315
+ the travel and the scale here leaves a fade: the dialogs already fade, the
316
+ sheet does not, so its opacity is set too. The keyframes still run on their
317
+ usual clock, so the TS port's exit `animationend` unmount is untouched.
318
+ Unlayered, like the rest of this sheet, so it outranks those utilities
319
+ without !important. */
320
+ @media (prefers-reduced-motion: reduce) {
321
+ [data-slot="sheet-content"],
322
+ [data-slot="dialog-content"],
323
+ [data-slot="alert-dialog-content"] {
324
+ --tw-enter-translate-x: 0;
325
+ --tw-enter-translate-y: 0;
326
+ --tw-exit-translate-x: 0;
327
+ --tw-exit-translate-y: 0;
328
+ --tw-enter-scale: 1;
329
+ --tw-exit-scale: 1;
330
+ --tw-enter-opacity: 0;
331
+ --tw-exit-opacity: 0;
332
+ }
333
+ }
334
+
335
+ /* ── Entrances: rise, stagger, settled, skeleton ─────────────────────────────
336
+ One keyframe for every "arriving" thing, driven by attributes so a consumer
337
+ without a motion library (and a Server Component) gets it for free:
338
+
339
+ data-enter="rise" the element fades in and rises --ev-rise, once, on
340
+ insertion; --enter-delay postpones it.
341
+ data-enter="stagger" each direct child does the same, one --enter-step
342
+ (default --ev-stagger) after the previous. The index
343
+ comes from nth-child; set --enter-index inline to
344
+ override it. Capped at 12 — past that the tail is
345
+ still arriving after the head has been read.
346
+ [data-slot="settled"][data-state="revealed"]
347
+ a skeleton → content handover (uikit <Settled>).
348
+
349
+ It animates the independent `translate` property, not `transform`, so it
350
+ composes with any transform the element already has. `backwards` holds the
351
+ start frame through the delay and then lets go, so nothing about the element
352
+ (stacking, containing block for `fixed` children) lingers after it lands.
353
+ Reduced motion keeps the fade and drops the travel. */
354
+ @keyframes ev-enter-rise {
355
+ from {
356
+ opacity: 0;
357
+ translate: 0 var(--ev-rise, 8px);
358
+ }
359
+ }
360
+ [data-enter="rise"],
361
+ [data-enter="stagger"] > *,
362
+ [data-slot="settled"][data-state="revealed"] {
363
+ animation: ev-enter-rise var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out)
364
+ var(--enter-delay, 0ms) backwards;
365
+ }
366
+ [data-enter="stagger"] > * {
367
+ animation-delay: calc(
368
+ var(--enter-delay, 0ms) + var(--enter-index, 0) * var(--enter-step, var(--ev-stagger, 35ms))
369
+ );
370
+ }
371
+ [data-enter="stagger"] > :nth-child(2) { --enter-index: 1; }
372
+ [data-enter="stagger"] > :nth-child(3) { --enter-index: 2; }
373
+ [data-enter="stagger"] > :nth-child(4) { --enter-index: 3; }
374
+ [data-enter="stagger"] > :nth-child(5) { --enter-index: 4; }
375
+ [data-enter="stagger"] > :nth-child(6) { --enter-index: 5; }
376
+ [data-enter="stagger"] > :nth-child(7) { --enter-index: 6; }
377
+ [data-enter="stagger"] > :nth-child(8) { --enter-index: 7; }
378
+ [data-enter="stagger"] > :nth-child(9) { --enter-index: 8; }
379
+ [data-enter="stagger"] > :nth-child(10) { --enter-index: 9; }
380
+ [data-enter="stagger"] > :nth-child(11) { --enter-index: 10; }
381
+ [data-enter="stagger"] > :nth-child(n + 12) { --enter-index: 11; }
382
+
383
+ /* Every skeleton fades in before it starts to pulse. Keyed on the pulse class as
384
+ well as the slot: a running animation beats a transition on the same property,
385
+ so the entrance has to be the first of two animations rather than anything
386
+ layered beside `animate-pulse` — and a caller's `animate-none` (which `cn`
387
+ merges over the pulse) removes the class and with it this rule. Reduced motion
388
+ keeps the pulse, the signal that work is in flight, and drops the entrance. */
389
+ @keyframes ev-skeleton-in {
390
+ from {
391
+ opacity: 0;
392
+ }
393
+ }
394
+ @keyframes ev-skeleton-pulse {
395
+ 50% {
396
+ opacity: 0.5;
397
+ }
398
+ }
399
+ [data-slot="skeleton"].animate-pulse {
400
+ animation:
401
+ ev-skeleton-in 200ms var(--ev-ease-out, ease-out),
402
+ ev-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 200ms infinite;
403
+ }
404
+
405
+ /* ── App shell: the rail's active-row marker and the tab bar's rule ──────────
406
+ ShellNav: one marker node per nav section, placed on the [aria-current] row by
407
+ measurement and moved by a `transform` transition — on the compositor, so it
408
+ keeps its frames while the main thread renders the page just navigated to (a
409
+ JS layout projection here dropped exactly those). Until the first placement
410
+ (SSR, every frame to hydration) the marker has no position: it stays hidden
411
+ and the marked row carries the fill itself, so the first paint is right.
412
+ `data-placed` flips both at once; `entered` is a marker that mounted into a
413
+ section already on screen (a move across sections — a fade), `first` one that
414
+ arrived with its section (a cut: fading under a row whose own fill fades out
415
+ would dip). */
416
+ @keyframes ev-shell-fade-in {
417
+ from {
418
+ opacity: 0;
419
+ }
420
+ }
421
+ [data-slot="shell-nav-marker"] {
422
+ transition: transform var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out);
423
+ }
424
+ [data-slot="shell-nav-marker"]:not([data-placed]) {
425
+ visibility: hidden;
426
+ }
427
+ [data-slot="shell-nav-marker"][data-placed="entered"] {
428
+ animation: ev-shell-fade-in var(--ev-dur-fast, 180ms) var(--ev-ease-out, ease-out);
429
+ }
430
+ [data-slot="shell-nav-marker"]:not([data-placed]) ~ * [aria-current="page"] {
431
+ background-color: var(--primary);
432
+ }
433
+
434
+ /* BottomTabBar: one rule for the whole bar, mounted once and translated by
435
+ whole tab widths (it is exactly one tab wide, so the two cannot drift). A
436
+ route no tab claims fades it out where it stands, and coming back fades it in
437
+ already in place. */
438
+ [data-slot="tab-bar-marker"] {
439
+ transition:
440
+ translate var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out),
441
+ opacity var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out);
442
+ }
443
+ [data-slot="tab-bar-marker"][data-state="idle"] {
444
+ opacity: 0;
445
+ }
446
+
447
+ @keyframes ev-enter-fade {
448
+ from {
449
+ opacity: 0;
450
+ }
451
+ }
452
+ @media (prefers-reduced-motion: reduce) {
453
+ [data-enter="rise"],
454
+ [data-enter="stagger"] > *,
455
+ [data-slot="settled"][data-state="revealed"] {
456
+ animation-name: ev-enter-fade;
457
+ }
458
+ [data-slot="skeleton"].animate-pulse {
459
+ animation: ev-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
460
+ }
461
+ [data-slot="shell-nav-marker"],
462
+ [data-slot="tab-bar-marker"] {
463
+ transition: none;
464
+ animation: none;
465
+ }
466
+ }
467
+
309
468
  /* ═══ CONTRACT ═══════════════════════════════════════════════════ */
310
469
 
311
470
  @theme inline {
@@ -428,6 +587,26 @@
428
587
 
429
588
  --sidebar-width: 14rem;
430
589
 
590
+ /* app shell (uikit <AppShell> / <ShellNav> / <BottomTabBar>) */
591
+ --shell-rail-w: 15.5rem; /* 248px */
592
+ --shell-tab-bar-h: 4rem; /* 64px, before the bottom safe area */
593
+
594
+ /* ── motion ──────────────────────────────────────────────────── *
595
+ * One place to retune the feel of every animation the kit ships (motion.css
596
+ * reads these) and every one a consumer writes against them:
597
+ * `ease-(--ev-ease-out) duration-(--ev-dur-base)`. Quiet on purpose — short,
598
+ * small travel: motion here says what changed, it never decorates. */
599
+ --ev-ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* every entrance */
600
+ --ev-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* state that reverses */
601
+ --ev-dur-fast: 180ms;
602
+ --ev-dur-base: 280ms;
603
+ --ev-dur-slow: 400ms;
604
+ --ev-rise: 8px; /* how far an entrance travels */
605
+ --ev-stagger: 35ms; /* between rows of a list */
606
+ /* between the sections of a page: cards, not rows — at 35ms six of them read
607
+ as one block twitching; at 50ms the last lands ~0.55s in, still "opening" */
608
+ --ev-stagger-section: 50ms;
609
+
431
610
  /* page container — responsive gutter + max width (uikit <Container>) */
432
611
  --page-max: 90rem; /* 1440px */
433
612
  --page-px: 1rem; /* 16px — mobile gutter, scales up below */
package/styles/tokens.css CHANGED
@@ -21,7 +21,8 @@
21
21
  * accent-trace · accent-debug · accent-info · accent-warn · accent-error
22
22
  * primary-ink — the primary role as ink on a surface, where the
23
23
  * fill and the readable value are different colours
24
- * SCALARS radius · control-* · display-scale · page-* · shadows · fonts
24
+ * SCALARS radius · control-* · display-scale · page-* · shell-* · shadows · fonts
25
+ * MOTION ev-ease-* · ev-dur-* · ev-rise · ev-stagger(-section)
25
26
  * CHARTS chart-1 … chart-5
26
27
  * MARK brand-mark · brand-aspect — supplied, never defined here
27
28
  *
@@ -40,7 +41,9 @@
40
41
  */
41
42
 
42
43
  /*
43
- * Toast (sonner) and Drawer enter/exit motion, shared by both ports. Imported by the token
44
+ * Toast (sonner) and Drawer enter/exit motion, the reduced-motion fade for Sheet and
45
+ * the dialogs, the entrances (rise / stagger / settled / skeleton) and the app-shell
46
+ * markers, shared by both ports. Imported by the token
44
47
  * sheets so the lifecycle ships with the theme contract.
45
48
  */
46
49
 
@@ -346,6 +349,163 @@
346
349
  }
347
350
  }
348
351
 
352
+ /* ── Sheet and dialogs under reduced motion ──────────────────────────────────
353
+ Both ports animate these with tw-animate-css (`animate-in` / `animate-out` +
354
+ `slide-*` / `zoom-*`), whose `enter` / `exit` keyframes read the
355
+ --tw-enter-* / --tw-exit-* variables the slide and zoom utilities set. Zeroing
356
+ the travel and the scale here leaves a fade: the dialogs already fade, the
357
+ sheet does not, so its opacity is set too. The keyframes still run on their
358
+ usual clock, so the TS port's exit `animationend` unmount is untouched.
359
+ Unlayered, like the rest of this sheet, so it outranks those utilities
360
+ without !important. */
361
+ @media (prefers-reduced-motion: reduce) {
362
+ [data-slot="sheet-content"],
363
+ [data-slot="dialog-content"],
364
+ [data-slot="alert-dialog-content"] {
365
+ --tw-enter-translate-x: 0;
366
+ --tw-enter-translate-y: 0;
367
+ --tw-exit-translate-x: 0;
368
+ --tw-exit-translate-y: 0;
369
+ --tw-enter-scale: 1;
370
+ --tw-exit-scale: 1;
371
+ --tw-enter-opacity: 0;
372
+ --tw-exit-opacity: 0;
373
+ }
374
+ }
375
+
376
+ /* ── Entrances: rise, stagger, settled, skeleton ─────────────────────────────
377
+ One keyframe for every "arriving" thing, driven by attributes so a consumer
378
+ without a motion library (and a Server Component) gets it for free:
379
+
380
+ data-enter="rise" the element fades in and rises --ev-rise, once, on
381
+ insertion; --enter-delay postpones it.
382
+ data-enter="stagger" each direct child does the same, one --enter-step
383
+ (default --ev-stagger) after the previous. The index
384
+ comes from nth-child; set --enter-index inline to
385
+ override it. Capped at 12 — past that the tail is
386
+ still arriving after the head has been read.
387
+ [data-slot="settled"][data-state="revealed"]
388
+ a skeleton → content handover (uikit <Settled>).
389
+
390
+ It animates the independent `translate` property, not `transform`, so it
391
+ composes with any transform the element already has. `backwards` holds the
392
+ start frame through the delay and then lets go, so nothing about the element
393
+ (stacking, containing block for `fixed` children) lingers after it lands.
394
+ Reduced motion keeps the fade and drops the travel. */
395
+ @keyframes ev-enter-rise {
396
+ from {
397
+ opacity: 0;
398
+ translate: 0 var(--ev-rise, 8px);
399
+ }
400
+ }
401
+ [data-enter="rise"],
402
+ [data-enter="stagger"] > *,
403
+ [data-slot="settled"][data-state="revealed"] {
404
+ animation: ev-enter-rise var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out)
405
+ var(--enter-delay, 0ms) backwards;
406
+ }
407
+ [data-enter="stagger"] > * {
408
+ animation-delay: calc(
409
+ var(--enter-delay, 0ms) + var(--enter-index, 0) * var(--enter-step, var(--ev-stagger, 35ms))
410
+ );
411
+ }
412
+ [data-enter="stagger"] > :nth-child(2) { --enter-index: 1; }
413
+ [data-enter="stagger"] > :nth-child(3) { --enter-index: 2; }
414
+ [data-enter="stagger"] > :nth-child(4) { --enter-index: 3; }
415
+ [data-enter="stagger"] > :nth-child(5) { --enter-index: 4; }
416
+ [data-enter="stagger"] > :nth-child(6) { --enter-index: 5; }
417
+ [data-enter="stagger"] > :nth-child(7) { --enter-index: 6; }
418
+ [data-enter="stagger"] > :nth-child(8) { --enter-index: 7; }
419
+ [data-enter="stagger"] > :nth-child(9) { --enter-index: 8; }
420
+ [data-enter="stagger"] > :nth-child(10) { --enter-index: 9; }
421
+ [data-enter="stagger"] > :nth-child(11) { --enter-index: 10; }
422
+ [data-enter="stagger"] > :nth-child(n + 12) { --enter-index: 11; }
423
+
424
+ /* Every skeleton fades in before it starts to pulse. Keyed on the pulse class as
425
+ well as the slot: a running animation beats a transition on the same property,
426
+ so the entrance has to be the first of two animations rather than anything
427
+ layered beside `animate-pulse` — and a caller's `animate-none` (which `cn`
428
+ merges over the pulse) removes the class and with it this rule. Reduced motion
429
+ keeps the pulse, the signal that work is in flight, and drops the entrance. */
430
+ @keyframes ev-skeleton-in {
431
+ from {
432
+ opacity: 0;
433
+ }
434
+ }
435
+ @keyframes ev-skeleton-pulse {
436
+ 50% {
437
+ opacity: 0.5;
438
+ }
439
+ }
440
+ [data-slot="skeleton"].animate-pulse {
441
+ animation:
442
+ ev-skeleton-in 200ms var(--ev-ease-out, ease-out),
443
+ ev-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 200ms infinite;
444
+ }
445
+
446
+ /* ── App shell: the rail's active-row marker and the tab bar's rule ──────────
447
+ ShellNav: one marker node per nav section, placed on the [aria-current] row by
448
+ measurement and moved by a `transform` transition — on the compositor, so it
449
+ keeps its frames while the main thread renders the page just navigated to (a
450
+ JS layout projection here dropped exactly those). Until the first placement
451
+ (SSR, every frame to hydration) the marker has no position: it stays hidden
452
+ and the marked row carries the fill itself, so the first paint is right.
453
+ `data-placed` flips both at once; `entered` is a marker that mounted into a
454
+ section already on screen (a move across sections — a fade), `first` one that
455
+ arrived with its section (a cut: fading under a row whose own fill fades out
456
+ would dip). */
457
+ @keyframes ev-shell-fade-in {
458
+ from {
459
+ opacity: 0;
460
+ }
461
+ }
462
+ [data-slot="shell-nav-marker"] {
463
+ transition: transform var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out);
464
+ }
465
+ [data-slot="shell-nav-marker"]:not([data-placed]) {
466
+ visibility: hidden;
467
+ }
468
+ [data-slot="shell-nav-marker"][data-placed="entered"] {
469
+ animation: ev-shell-fade-in var(--ev-dur-fast, 180ms) var(--ev-ease-out, ease-out);
470
+ }
471
+ [data-slot="shell-nav-marker"]:not([data-placed]) ~ * [aria-current="page"] {
472
+ background-color: var(--primary);
473
+ }
474
+
475
+ /* BottomTabBar: one rule for the whole bar, mounted once and translated by
476
+ whole tab widths (it is exactly one tab wide, so the two cannot drift). A
477
+ route no tab claims fades it out where it stands, and coming back fades it in
478
+ already in place. */
479
+ [data-slot="tab-bar-marker"] {
480
+ transition:
481
+ translate var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out),
482
+ opacity var(--ev-dur-base, 280ms) var(--ev-ease-out, ease-out);
483
+ }
484
+ [data-slot="tab-bar-marker"][data-state="idle"] {
485
+ opacity: 0;
486
+ }
487
+
488
+ @keyframes ev-enter-fade {
489
+ from {
490
+ opacity: 0;
491
+ }
492
+ }
493
+ @media (prefers-reduced-motion: reduce) {
494
+ [data-enter="rise"],
495
+ [data-enter="stagger"] > *,
496
+ [data-slot="settled"][data-state="revealed"] {
497
+ animation-name: ev-enter-fade;
498
+ }
499
+ [data-slot="skeleton"].animate-pulse {
500
+ animation: ev-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
501
+ }
502
+ [data-slot="shell-nav-marker"],
503
+ [data-slot="tab-bar-marker"] {
504
+ transition: none;
505
+ animation: none;
506
+ }
507
+ }
508
+
349
509
  /* ═══ CONTRACT ═══════════════════════════════════════════════════ */
350
510
 
351
511
  @theme inline {
@@ -468,6 +628,26 @@
468
628
 
469
629
  --sidebar-width: 14rem;
470
630
 
631
+ /* app shell (uikit <AppShell> / <ShellNav> / <BottomTabBar>) */
632
+ --shell-rail-w: 15.5rem; /* 248px */
633
+ --shell-tab-bar-h: 4rem; /* 64px, before the bottom safe area */
634
+
635
+ /* ── motion ──────────────────────────────────────────────────── *
636
+ * One place to retune the feel of every animation the kit ships (motion.css
637
+ * reads these) and every one a consumer writes against them:
638
+ * `ease-(--ev-ease-out) duration-(--ev-dur-base)`. Quiet on purpose — short,
639
+ * small travel: motion here says what changed, it never decorates. */
640
+ --ev-ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* every entrance */
641
+ --ev-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* state that reverses */
642
+ --ev-dur-fast: 180ms;
643
+ --ev-dur-base: 280ms;
644
+ --ev-dur-slow: 400ms;
645
+ --ev-rise: 8px; /* how far an entrance travels */
646
+ --ev-stagger: 35ms; /* between rows of a list */
647
+ /* between the sections of a page: cards, not rows — at 35ms six of them read
648
+ as one block twitching; at 50ms the last lands ~0.55s in, still "opening" */
649
+ --ev-stagger-section: 50ms;
650
+
471
651
  /* page container — responsive gutter + max width (uikit <Container>) */
472
652
  --page-max: 90rem; /* 1440px */
473
653
  --page-px: 1rem; /* 16px — mobile gutter, scales up below */