@justanarthur/modo-ui 0.6.0-canary.6 → 0.6.0-canary.7

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
@@ -106,7 +106,7 @@ Every vendored file opens with a plain `/* */` header. The header names the
106
106
  upstream path and commit, carries the MIT notice, and lists every local
107
107
  modification. The vendored code below the header keeps upstream's formatting.
108
108
  Files written for this port (`preset.ts`, `uno.config.ts`, `vite.ts`, `index.ts`,
109
- `scripts/build-css.ts`, `modo.components.tsx`,
109
+ `scripts/build-css.ts`, `docs/*`,
110
110
  `lib/use-controllable-state.ts`, `primitives/code/index.tsx`, `primitives/craft/*`, the morph files
111
111
  in `lib/` (`use-morph.ts`, `reduced-motion.ts`, `morph-layers.tsx`, `morph-part.tsx`, `goo-indicator.tsx`,
112
112
  `slot.ts`) and `primitives/morph/index.tsx`, `blocks/sidebar-app/index.tsx`,
@@ -137,13 +137,13 @@ positioning) are ported.
137
137
  | `DESIGN.md` | The design language in Google's [DESIGN.md](https://github.com/google-labs-code/design.md) format: the tokens as front matter, the rules as prose. Agents building here follow the `modo-ui` skill (`.claude/skills/modo-ui` at the repo root). |
138
138
  | `tokens/*.css` | Colors, typography (`--font-sans`, `--text-*`), motion (`--duration-*`), radius and spacing. Shipped per group as `dist/tokens/*.css` (`./tokens/*`). |
139
139
  | `base.css` | Theme switching, base styles, focus fallback, scrollbars, shimmer/spinner keyframes and `.scroll-fade`. Shipped in the package. The inline `code` chip applies only inside the docs shell, `data-modo="prose"` and `.modo-prose`. |
140
- | `global.css` | The docs site's stylesheet (`modo.config.ts` `css`): imports `base.css`, then the modo-only focus revert and the docs chrome. |
140
+ | `global.css` | The docs site's stylesheet (`modo.config.ts` `css`): imports `base.css`, then `docs/chrome.css`. |
141
141
  | `preset.ts` | The UnoCSS preset (theme and token utilities), shipped as `@justanarthur/modo-ui/unocss`. |
142
- | `uno.config.ts`, `vite.ts` | The UnoCSS setup of the docs site and the prebuilt CSS (see below). |
142
+ | `uno.config.ts`, `vite.ts` | The UnoCSS setup of the docs site and the prebuilt CSS (see below); `vite.ts` also applies the docs chrome's `docsTheme` plugin. |
143
143
  | `index.ts`, `bunup.config.ts`, `scripts/build-css.ts` | The package build: `bun run build` writes the components, their types and the stylesheets to `dist/`. The docs site builds with `bun run build:docs` into `docs-dist/`. |
144
144
  | `scripts/exports.ts`, `scripts/use-client.ts`, `scripts/check-singletons.ts` | Packaging around that build. `exports.ts` (first step of `bun run build`) writes one `./lib/<name>` export per `lib/` module into `package.json`, plus `./components/*/variants` and `./blocks/*/variants` for the items' server-callable `variants.ts` entries; commit the `package.json` it leaves. `use-client.ts` (after bunup) puts `"use client"` on the emitted modules that render, call a hook or create a context, or use a binding from one; pure helpers (`cn`, class tables, springs, `variants.ts`) and re-export entries stay directive-free so a React Server Component can call them (`--list` prints the split). `check-singletons.ts` (`bun run check:dist`) checks that every React context and the `toast()` manager exist once in `dist/`, and that every entry has its `.d.ts`. `typesVersions` in `package.json` maps every public subpath to `dist/**/*.d.ts`, so `moduleResolution: node` consumers do not type-check the shipped source. |
145
145
  | `lib/` | FF's shared system files (upstream `lib/`, `hooks/` and the fluid-hover highlight, flat, upstream names), imported relatively by items. modo only scans the tier folders, so it never mistakes them for items. |
146
- | `modo.components.tsx` | Components only the docs site uses: the `Icon`, `Select` and `DocsNav` shell-slot adapters and the `ThemeSwitcher` panel item, picked in `modo.config.ts` by export name (`./modo.components.tsx#Select`). |
146
+ | `docs/` | The docs chrome, shipped: `chrome.tsx` (the `Button`, `Link`, `Icon`, `Select` and `DocsNav` shell-slot adapters and the `ThemeSwitcher` panel item, re-exported by `index.ts` as `@justanarthur/modo-ui/docs`), `chrome.css` (the inset layout and the look of the chrome, `@justanarthur/modo-ui/docs/chrome.css`) and `vite.ts` (`docsTheme`, the pre-paint theme controller, `@justanarthur/modo-ui/docs/vite`). `modo.config.ts` picks the slots by export name (`./docs#DocsNav`); another design system documented with modo points at the same module by package specifier (see **The docs chrome, for another design system**). The barrel (`index.ts`) never imports it, `bun run check:dist` fails if it does. |
147
147
  | `primitives/<id>/` | FF's "System" pages: `index.tsx` (component + TSDoc prose) and `examples.mdx`, pulled in by `@example {@include ./examples.mdx}`. |
148
148
  | `components/[<group>/]<id>/` | FF's "Components" pages, same shape. The `AI/`, `inputs/` (with `inputs/date-time/`), `navigation/` and `overlays/` group folders become sidebar sub-groups and overview sections; the core display items stay ungrouped. |
149
149
  | `blocks/<id>/` | Compositions of several items, same shape: `sidebar-app`, FF's sidebar preset `sa1FQfCxH6` as an app shell, with the header and footer blocks it is built from beside it. |
@@ -292,17 +292,17 @@ only what is specific to it:
292
292
  | — | `registry/blocks/sidebar-app/inset-topbar.tsx` | `blocks/sidebar-app/inset-topbar.tsx` | the trigger's peek-time fade dropped (the sidebar opens on hover in flow, so the trigger stays) |
293
293
  | — | `registry/default/lib/sidebar-menu-grid.ts` | `blocks/sidebar-app/menu-grid.ts` | the width reads `--anchor-width` only; the `-ml-1` shift dropped (it would move the morph surface's content off its background) |
294
294
 
295
- `Select` in `modo.components.tsx` is not an item: modo's docs chrome renders its Select slot
295
+ `Select` in `docs/chrome.tsx` is not an item: modo's docs chrome renders its Select slot
296
296
  with a flat `value` / `onChange` / `options` contract
297
297
  (`lib/src/lib/slots.tsx`), and this adapter maps it onto
298
298
  `Select.Trigger` / `Select.Content` / `Select.Item` so the chrome runs on the
299
299
  design system's own Select. The
300
300
  `components/inputs/select` item keeps the upstream API untouched.
301
301
 
302
- `DocsNav` in `modo.components.tsx` is the chrome's Sidebar slot, pinned as
302
+ `DocsNav` in `docs/chrome.tsx` is the chrome's Sidebar slot, pinned as
303
303
  `shell.Sidebar` (by name alone modo would adopt the documented `Sidebar`, an
304
304
  app shell that needs its provider). Each aside is an inset `Sidebar` with its
305
- own provider, and `global.css` turns the content between them into the inset
305
+ own provider, and `docs/chrome.css` turns the content between them into the inset
306
306
  card (the lib's grid columns become `auto`, so they follow the sidebars' width
307
307
  springs). Each provider has `openOnHover`, and its `Sidebar.Trigger` sits in
308
308
  the card's corner: the trigger, `[` for the nav and `]` for the panel, switch
@@ -315,7 +315,7 @@ below xl. A section of links is a collapsible `Sidebar.Group` with one
315
315
  highlight melt from link to link; a panel section (Theme) keeps its control
316
316
  under the group label.
317
317
 
318
- `ThemeSwitcher` in `modo.components.tsx` is not an item either: it is the `Theme` entry in
318
+ `ThemeSwitcher` in `docs/chrome.tsx` is not an item either: it is the `Theme` entry in
319
319
  `modo.config.ts` `panel.items`, a `Select` of Light / Dark / System (each row
320
320
  with its icon, the trigger showing the active one) that reads and sets
321
321
  `window.__uiTheme` and re-reads it on `ui:themechange`. All switching logic lives in the pre-paint controller
@@ -323,6 +323,75 @@ described under **Tailwind → UnoCSS**, so the control holds no theme state of
323
323
  its own. `demo/scripts/run-design-systems.ts` appends its own `Switcher` panel
324
324
  item to whatever a design system declares, so both show up under the runner.
325
325
 
326
+ The chrome's own elements (the corner triggers, the panel section's gutter) are
327
+ styled in `docs/chrome.css` through `data-modo-ui` attributes, not utility
328
+ classes: nothing in `docs/` needs a UnoCSS or Tailwind scan, so a host with
329
+ either generator loads the same file. `Button` and `Link` are pinned too, so a
330
+ design system with items of those names but another prop shape is not matched
331
+ by name (modo would warn on every start).
332
+
333
+ ### The docs chrome, for another design system
334
+
335
+ A design system documented with modo (an `@utegsk/ui`-style package that
336
+ depends on `@justanarthur/modo-ui`) gets the same sidebars, inset card layout,
337
+ panel and Theme switcher as this site by pointing its `modo.config.ts` at the
338
+ shipped module. A `shell` / `panel` value may be a package specifier (the
339
+ viewer resolves it from the project root through the package's `exports`, the
340
+ `source` file first), which needs an `@justanarthur/modo` release after
341
+ 0.9.0-canary.0; with an older viewer keep a one-line local re-export instead
342
+ (`export * from '@justanarthur/modo-ui/docs'` in a `docs/shell.tsx`, and point
343
+ the slots at `./docs/shell.tsx#DocsNav`).
344
+
345
+ ```ts
346
+ // modo.config.ts
347
+ import { defineConfig } from '@justanarthur/modo/config'
348
+
349
+ export default defineConfig({
350
+ name: 'My DS',
351
+ css: './docs.css',
352
+ vite: './vite.mjs',
353
+ shell: {
354
+ Button: '@justanarthur/modo-ui/docs#Button',
355
+ Link: '@justanarthur/modo-ui/docs#Link',
356
+ Select: '@justanarthur/modo-ui/docs#Select',
357
+ Icon: '@justanarthur/modo-ui/docs#Icon',
358
+ Sidebar: '@justanarthur/modo-ui/docs#DocsNav',
359
+ },
360
+ panel: { items: [{ label: 'Theme', component: '@justanarthur/modo-ui/docs#ThemeSwitcher' }] },
361
+ })
362
+ ```
363
+
364
+ ```css
365
+ /* docs.css: the package stylesheet (or base.css with your own utilities), then the chrome */
366
+ @import "@justanarthur/modo-ui/styles.css";
367
+ @import "@justanarthur/modo-ui/docs/chrome.css";
368
+ ```
369
+
370
+ ```js
371
+ // vite.mjs: plain JS, Node loads it as is. Add your own plugins (Tailwind, UnoCSS) beside docsTheme.
372
+ import { docsTheme } from '@justanarthur/modo-ui/docs/vite'
373
+
374
+ export default config => {
375
+ config.plugins = [...(config.plugins ?? []), docsTheme({ storageKey: 'my-ds-theme' })]
376
+ return config
377
+ }
378
+ ```
379
+
380
+ - `chrome.css` also loads by itself, as the stylesheet beside the slot module,
381
+ when modo bundles `@justanarthur/modo-ui/docs` from its source; the explicit
382
+ `@import` fixes where it sits in your cascade and is the one to rely on.
383
+ - `docsTheme` puts `.light` / `.dark` on `<html>` before first paint and
384
+ exposes `window.__uiTheme` for `ThemeSwitcher`; `storageKey` (default
385
+ `modo-ui-theme`) keeps two sites on one origin apart.
386
+ - The panel's narrow-screen behavior (below xl the panel is a drawer, opened on
387
+ hover or with `]`, never a strip above the content) comes with `chrome.css`.
388
+ A stylesheet that re-implements the layout instead of importing it can
389
+ recreate the lib's `grid-template-rows: auto` strip: a viewport-tall blank
390
+ row with the Theme select behind it.
391
+ - `lucide-react` (the `Icon` slot) is already a dependency of the package;
392
+ the docs entry adds no other. `@justanarthur/modo-ui` itself must be a
393
+ dependency of the host: modo resolves the specifier from the project root.
394
+
326
395
  ## Tier mapping
327
396
 
328
397
  The tiers mirror the FF docs navigation. FF's "System" pages become
@@ -371,7 +440,7 @@ No ported file imports any of them.
371
440
 
372
441
  The files only they, or the FF docs site's nav, use are left out too:
373
442
  `mobile-drawer`, `nav-item`, `nav-menu`, `lib/theme-context.tsx` (the
374
- `ThemeSwitcher` panel item and vite.ts's pre-paint controller take its
443
+ `ThemeSwitcher` panel item and docs/vite.ts's pre-paint controller take its
375
444
  place) and the blocks other than the sidebar's (`queued-stack`,
376
445
  `dialog-sidebar`). Inside kept files, the same rule removed
377
446
  `SURFACE_HOVER_BG`, `SURFACE_HOVER_SHADOW` and `surfaceHoverClasses` from
@@ -398,7 +467,8 @@ prebuilt `styles.css`, and turns `preset.ts` into `tailwind.css`.
398
467
 
399
468
  - **Dark mode.** `dark: 'class'` produces `.dark .dark\:x`, the same "inside a
400
469
  `.dark` ancestor" contract as FF's `@custom-variant dark (&:is(.dark *))`.
401
- modo ships no theme of its own, so `vite.ts` injects a small controller into
470
+ modo ships no theme of its own, so `docsTheme` (`docs/vite.ts`, applied by
471
+ `vite.ts`) injects a small controller into
402
472
  `index.html` (`transformIndexHtml`, `head-prepend`). Before first paint it
403
473
  puts `light` or `dark` on `<html>` — the stored preference, or the OS one
404
474
  under `system` — which is also what flips every `light-dark()` token through
@@ -411,7 +481,10 @@ prebuilt `styles.css`, and turns `preset.ts` into `tailwind.css`.
411
481
  `global.css` (and the `base.css` it imports) and fixes the order of the
412
482
  layer its `@layer base` rules join.
413
483
  `vite.ts` is loaded by Node with type stripping, so it may only use erasable
414
- TypeScript and hands `uno.config.ts` to UnoCSS by absolute path.
484
+ TypeScript, names the `.ts` extension of the one relative module it imports
485
+ (`./docs/vite.ts`) and hands `uno.config.ts` to UnoCSS by absolute path.
486
+ `docs/vite.ts` ships built (`dist/docs/vite.js`): Node never strips types
487
+ from a file under `node_modules`.
415
488
  - **Colors.** Theme colors point at the CSS variables directly (for example
416
489
  `accent: 'var(--accent)'`, `surface.1..8`). wind4 applies opacity modifiers
417
490
  to `var()` colors with `color-mix(in srgb, …)`, so `bg-accent/12` works on
@@ -435,7 +508,7 @@ prebuilt `styles.css`, and turns `preset.ts` into `tailwind.css`.
435
508
  after the reset, so utilities (in `default`) still win. This is the same
436
509
  arrangement as Tailwind's base/utilities split.
437
510
  - **Content.** UnoCSS scans `{lib,primitives,components,blocks}/**/*.{ts,tsx,mdx}`
438
- and `modo.components.tsx` from disk (`content.filesystem`, and the same
511
+ from disk (`content.filesystem`, and the same
439
512
  globs in `content.pipeline.include`, since the default pipeline skips
440
513
  `.ts`). Two reasons:
441
514
  - modo serves items pre-bundled, so the sources never pass through Vite's
@@ -0,0 +1,83 @@
1
+ import { AnchorHTMLAttributes, ReactNode } from "react";
2
+ import { ForwardRefExoticComponent as ForwardRefExoticComponent_tcubfn } from "react";
3
+ import { ButtonProps as ButtonProps_1qs1xe } from "..";
4
+ import { RefAttributes as RefAttributes_miuf56 } from "react";
5
+ import { JSX as JSX_1kxb } from "react";
6
+ import { LucideIcon as LucideIcon_b49o7r } from "lucide-react";
7
+ /**
8
+ * Button slot: the chrome's icon buttons (Show code, Copy, the anchor toggles). The chrome passes
9
+ * `variant="ghost"`, `size="icon-sm"`, `aria-label` and `aria-pressed`; the design system's Button takes them as is.
10
+ * Pinned so a host with an item named `Button` of another shape is not matched by name.
11
+ */
12
+ declare const Button: ForwardRefExoticComponent_tcubfn<ButtonProps_1qs1xe & RefAttributes_miuf56<HTMLButtonElement>>;
13
+ type Element_12xgc = JSX_1kxb["Element"];
14
+ /**
15
+ * Link slot: every chrome and Markdown link, a plain anchor so the chrome's own link styling applies.
16
+ * Pinned so a host with an item named `Link` of another shape is not matched by name.
17
+ */
18
+ declare function Link(props: AnchorHTMLAttributes<HTMLAnchorElement> & {
19
+ href: string;
20
+ }): Element_12xgc;
21
+ declare const icons: {
22
+ code: LucideIcon_b49o7r;
23
+ copy: LucideIcon_b49o7r;
24
+ check: LucideIcon_b49o7r;
25
+ link: LucideIcon_b49o7r;
26
+ };
27
+ /** Icon slot: the chrome asks for icons by name; lucide draws them. */
28
+ declare function Icon({ name }: {
29
+ /** Which chrome icon to draw. */
30
+ name: keyof typeof icons;
31
+ /** Accessible text; the button around the icon carries it. */
32
+ label: string;
33
+ }): Element_12xgc;
34
+ /**
35
+ * Select slot: the chrome passes a flat `value` / `onChange` / `options`
36
+ * contract; FF's Select is compositional, so this maps one onto the other.
37
+ */
38
+ declare function Select({ value, onChange, options }: {
39
+ value: string;
40
+ onChange: (value: string) => void;
41
+ options: {
42
+ value: string;
43
+ label: string;
44
+ }[];
45
+ }): Element_12xgc;
46
+ /**
47
+ * Sidebar slot: both asides of the chrome, each an inset `Sidebar` with its
48
+ * own `openOnHover` provider, so the content between them is the inset card
49
+ * (chrome.css). The trigger in the card's corner (or `[` for the nav, `]` for
50
+ * the panel) switches a side between open and on hover. Below md each side
51
+ * is a drawer. The panel starts on hover below xl.
52
+ *
53
+ * A section of links is a collapsible `Sidebar.Group` whose rows share one
54
+ * `Sidebar.Menu`, so the current page and the hover highlight melt from link
55
+ * to link (the TOC's current heading too, as the page scrolls); a panel
56
+ * section (the Theme select) keeps its control under the group label. Pin it
57
+ * in modo.config.ts: by name alone modo would adopt a documented `Sidebar`
58
+ * item, an app shell that needs its provider.
59
+ */
60
+ declare function DocsNav({ children }: {
61
+ children?: ReactNode;
62
+ }): Element_12xgc;
63
+ declare namespace DocsNav {
64
+ var Item: typeof DocsNavItem;
65
+ var Section: typeof DocsNavSection;
66
+ }
67
+ declare function DocsNavItem({ href, active, children }: {
68
+ href: string;
69
+ active?: boolean;
70
+ children?: ReactNode;
71
+ }): Element_12xgc;
72
+ declare function DocsNavSection({ title, children }: {
73
+ title: string;
74
+ children?: ReactNode;
75
+ }): Element_12xgc;
76
+ /**
77
+ * Theme panel item. The switching itself is the pre-paint script the vite
78
+ * helper injects (`docsTheme` in `@justanarthur/modo-ui/docs/vite`, `window.__uiTheme`), so a stored preference applies before first
79
+ * paint; this only reflects and sets it. `ui:themechange` also fires when the
80
+ * OS flips underneath 'system'.
81
+ */
82
+ declare function ThemeSwitcher(): Element_12xgc;
83
+ export { Button, DocsNav, Icon, Link, Select, ThemeSwitcher };
@@ -0,0 +1,287 @@
1
+ "use client";
2
+ import"../shared/components-de99ykt7.js";
3
+ import"../shared/components-78x40gee.js";
4
+ import"../shared/components-34txk9rj.js";
5
+ import"../shared/components-h83az0bz.js";
6
+ import"../shared/components-3ey0hzpb.js";
7
+ import"../shared/components-jcnjay5g.js";
8
+ import"../shared/components-4vfm04wq.js";
9
+ import {
10
+ button_default
11
+ } from "../shared/components-4swzve2d.js";
12
+ import"../shared/components-awra5sff.js";
13
+ import"../shared/components-pw06rbxq.js";
14
+ import"../shared/components-qdm3yh26.js";
15
+ import"../shared/components-8nwsqjn8.js";
16
+ import"../shared/components-b1e239g3.js";
17
+ import"../shared/components-4y816hwp.js";
18
+ import"../shared/components-h6p86be3.js";
19
+ import"../shared/components-krtx3m5b.js";
20
+ import"../shared/components-mrbaxt8q.js";
21
+ import"../shared/components-vf45j12c.js";
22
+ import"../shared/components-dhgk6mbt.js";
23
+ import"../shared/components-yhgq0qxx.js";
24
+ import"../shared/components-tz4gaf1v.js";
25
+ import"../shared/components-drxrtnp2.js";
26
+ import"../shared/components-2z48g4fp.js";
27
+ import"../shared/components-dcc02hs2.js";
28
+ import"../shared/components-xdqmt6ed.js";
29
+ import {
30
+ select_default
31
+ } from "../shared/components-hx4a4cvm.js";
32
+ import"../shared/components-tz1fv35x.js";
33
+ import"../shared/components-jqpefjsn.js";
34
+ import"../shared/components-030n1m1v.js";
35
+ import"../shared/components-nd1baxp1.js";
36
+ import"../shared/components-124epb7f.js";
37
+ import"../shared/components-6zng858q.js";
38
+ import"../shared/components-z3v0n0de.js";
39
+ import"../shared/components-rrbpmfx4.js";
40
+ import"../shared/components-vqqapw72.js";
41
+ import {
42
+ SIDEBAR_KEYBOARD_SHORTCUT2,
43
+ SIDEBAR_KEYBOARD_SHORTCUT_RIGHT2
44
+ } from "../shared/components-kswxnm3h.js";
45
+ import {
46
+ useSidebar2,
47
+ sidebar_default
48
+ } from "../shared/components-9n43qf8y.js";
49
+
50
+ // docs/chrome.tsx
51
+ import { Check, CodeXml, Copy, Link2, Monitor, Moon, Sun } from "lucide-react";
52
+ import {
53
+ Children,
54
+ createContext,
55
+ isValidElement,
56
+ useContext,
57
+ useEffect,
58
+ useState
59
+ } from "react";
60
+ import { jsx, jsxs } from "react/jsx-runtime";
61
+ var Button = button_default;
62
+ function Link(props) {
63
+ return /* @__PURE__ */ jsx("a", {
64
+ ...props
65
+ });
66
+ }
67
+ var icons = { code: CodeXml, copy: Copy, check: Check, link: Link2 };
68
+ function Icon({
69
+ name
70
+ }) {
71
+ const Glyph = icons[name];
72
+ return /* @__PURE__ */ jsx(Glyph, {
73
+ size: 16,
74
+ strokeWidth: 1.5,
75
+ "aria-hidden": true
76
+ });
77
+ }
78
+ function Select({
79
+ value,
80
+ onChange,
81
+ options
82
+ }) {
83
+ return /* @__PURE__ */ jsxs(select_default, {
84
+ value,
85
+ onValueChange: onChange,
86
+ children: [
87
+ /* @__PURE__ */ jsx(select_default.Trigger, {}),
88
+ /* @__PURE__ */ jsx(select_default.Content, {
89
+ children: options.map((option, index) => /* @__PURE__ */ jsx(select_default.Item, {
90
+ value: option.value,
91
+ index,
92
+ children: option.label
93
+ }, option.value))
94
+ })
95
+ ]
96
+ });
97
+ }
98
+ function DocsNav({ children }) {
99
+ const [side, setSide] = useState();
100
+ if (!side)
101
+ return /* @__PURE__ */ jsx("div", {
102
+ ref: (node) => node && setSide(node.closest('[data-modo="panel"]') ? "right" : "left")
103
+ });
104
+ return /* @__PURE__ */ jsx(DocsSidebar, {
105
+ side,
106
+ children
107
+ });
108
+ }
109
+ function DocsSidebar({ side, children }) {
110
+ return /* @__PURE__ */ jsxs(sidebar_default.Provider, {
111
+ defaultOpen: side === "left" || window.matchMedia("(min-width: 1280px)").matches,
112
+ openOnHover: true,
113
+ persist: false,
114
+ shortcut: null,
115
+ children: [
116
+ /* @__PURE__ */ jsx(sidebar_default, {
117
+ variant: "inset",
118
+ side,
119
+ children: /* @__PURE__ */ jsx(sidebar_default.Content, {
120
+ children: /* @__PURE__ */ jsx("nav", {
121
+ "aria-label": side === "left" ? "Docs" : "Page",
122
+ children
123
+ })
124
+ })
125
+ }),
126
+ /* @__PURE__ */ jsx(DocsNavTrigger, {
127
+ side
128
+ })
129
+ ]
130
+ });
131
+ }
132
+ function DocsNavTrigger({ side }) {
133
+ const { state, isMobile, toggleSidebar } = useSidebar2();
134
+ useDocsShortcut(side === "left" ? SIDEBAR_KEYBOARD_SHORTCUT2 : SIDEBAR_KEYBOARD_SHORTCUT_RIGHT2, toggleSidebar);
135
+ return /* @__PURE__ */ jsx("div", {
136
+ "data-modo-ui": "nav-trigger",
137
+ "data-side": side,
138
+ "data-gutter": state === "collapsed" || isMobile ? "" : undefined,
139
+ children: /* @__PURE__ */ jsx(sidebar_default.Trigger, {})
140
+ });
141
+ }
142
+ function useDocsShortcut(key, toggle) {
143
+ useEffect(() => {
144
+ const onKeyDown = (event) => {
145
+ if (event.key !== key || event.metaKey || event.ctrlKey || event.altKey)
146
+ return;
147
+ const target = event.target;
148
+ if (target.isContentEditable)
149
+ return;
150
+ if (target.closest('input, textarea, select, [data-modo="content"] [data-slot="sidebar-wrapper"]'))
151
+ return;
152
+ event.stopImmediatePropagation();
153
+ event.preventDefault();
154
+ toggle();
155
+ };
156
+ window.addEventListener("keydown", onKeyDown, true);
157
+ return () => window.removeEventListener("keydown", onKeyDown, true);
158
+ }, [key, toggle]);
159
+ }
160
+ var NavDepth = createContext(0);
161
+ function DocsNavItem({ href, active, children }) {
162
+ const { setOpenMobile } = useSidebar2();
163
+ if (useContext(NavDepth) >= 2) {
164
+ return /* @__PURE__ */ jsx(sidebar_default.MenuSubItem, {
165
+ children: /* @__PURE__ */ jsx(sidebar_default.MenuSubButton, {
166
+ href,
167
+ isActive: active,
168
+ onClick: () => setOpenMobile(false),
169
+ children
170
+ })
171
+ });
172
+ }
173
+ return /* @__PURE__ */ jsx(sidebar_default.MenuItem, {
174
+ children: /* @__PURE__ */ jsx(sidebar_default.MenuButton, {
175
+ isActive: active,
176
+ render: /* @__PURE__ */ jsx("a", {
177
+ href
178
+ }),
179
+ onClick: () => setOpenMobile(false),
180
+ children
181
+ })
182
+ });
183
+ }
184
+ function DocsNavSection({ title, children }) {
185
+ const depth = useContext(NavDepth);
186
+ if (depth >= 1)
187
+ return /* @__PURE__ */ jsx(DocsNavGroup, {
188
+ title,
189
+ children
190
+ });
191
+ const onlyLinks = Children.toArray(children).every((child) => isValidElement(child) && (child.type === DocsNavItem || child.type === DocsNavSection));
192
+ if (onlyLinks) {
193
+ return /* @__PURE__ */ jsxs(sidebar_default.Group, {
194
+ collapsible: true,
195
+ children: [
196
+ /* @__PURE__ */ jsx(sidebar_default.GroupLabel, {
197
+ children: title
198
+ }),
199
+ /* @__PURE__ */ jsx(NavDepth.Provider, {
200
+ value: 1,
201
+ children: /* @__PURE__ */ jsx(sidebar_default.Menu, {
202
+ children
203
+ })
204
+ })
205
+ ]
206
+ });
207
+ }
208
+ return /* @__PURE__ */ jsxs(sidebar_default.Group, {
209
+ children: [
210
+ /* @__PURE__ */ jsx(sidebar_default.GroupLabel, {
211
+ children: title
212
+ }),
213
+ /* @__PURE__ */ jsx("div", {
214
+ "data-modo-ui": "nav-controls",
215
+ children
216
+ })
217
+ ]
218
+ });
219
+ }
220
+ var holdsActive = (children) => Children.toArray(children).some((child) => isValidElement(child) && (child.props.active || holdsActive(child.props.children)));
221
+ function DocsNavGroup({ title, children }) {
222
+ const depth = useContext(NavDepth);
223
+ const current = holdsActive(children);
224
+ const [open, setOpen] = useState(current);
225
+ useEffect(() => {
226
+ if (current)
227
+ setOpen(true);
228
+ }, [current]);
229
+ return /* @__PURE__ */ jsxs(sidebar_default.MenuItem, {
230
+ collapsible: true,
231
+ open,
232
+ onOpenChange: setOpen,
233
+ children: [
234
+ /* @__PURE__ */ jsx(sidebar_default.MenuButton, {
235
+ children: title
236
+ }),
237
+ /* @__PURE__ */ jsx(NavDepth.Provider, {
238
+ value: depth + 1,
239
+ children: /* @__PURE__ */ jsx(sidebar_default.MenuSub, {
240
+ children
241
+ })
242
+ })
243
+ ]
244
+ });
245
+ }
246
+ DocsNav.Item = DocsNavItem;
247
+ DocsNav.Section = DocsNavSection;
248
+ var THEMES = [
249
+ { value: "light", label: "Light", icon: Sun },
250
+ { value: "dark", label: "Dark", icon: Moon },
251
+ { value: "system", label: "System", icon: Monitor }
252
+ ];
253
+ function ThemeSwitcher() {
254
+ const [preference, setPreference] = useState(() => window.__uiTheme?.get() ?? "system");
255
+ useEffect(() => {
256
+ const sync = () => setPreference(window.__uiTheme?.get() ?? "system");
257
+ window.addEventListener("ui:themechange", sync);
258
+ return () => window.removeEventListener("ui:themechange", sync);
259
+ }, []);
260
+ const active = THEMES.find((theme) => theme.value === preference) ?? THEMES[2];
261
+ return /* @__PURE__ */ jsxs(select_default, {
262
+ value: preference,
263
+ onValueChange: (next) => window.__uiTheme?.set(next),
264
+ children: [
265
+ /* @__PURE__ */ jsx(select_default.Trigger, {
266
+ "aria-label": "Color theme",
267
+ icon: active.icon
268
+ }),
269
+ /* @__PURE__ */ jsx(select_default.Content, {
270
+ children: THEMES.map((theme, index) => /* @__PURE__ */ jsx(select_default.Item, {
271
+ index,
272
+ value: theme.value,
273
+ icon: theme.icon,
274
+ children: theme.label
275
+ }, theme.value))
276
+ })
277
+ ]
278
+ });
279
+ }
280
+ export {
281
+ Button,
282
+ DocsNav,
283
+ Icon,
284
+ Link,
285
+ Select,
286
+ ThemeSwitcher
287
+ };
@@ -0,0 +1,31 @@
1
+ /** The Vite plugin `docsTheme` returns: assignable to Vite's own `Plugin`. */
2
+ interface DocsThemePlugin {
3
+ name: string;
4
+ /** Puts the theme controller in `<head>`, ahead of everything else. */
5
+ transformIndexHtml(): Array<{
6
+ tag: "script";
7
+ children: string;
8
+ injectTo: "head-prepend";
9
+ }>;
10
+ }
11
+ /** What `docsTheme` takes. */
12
+ interface DocsThemeOptions {
13
+ /** The localStorage key holding the stored preference. Give each site its own. Default `modo-ui-theme`. */
14
+ storageKey?: string;
15
+ }
16
+ /**
17
+ * A Vite plugin that puts the theme controller in `<head>` ahead of everything
18
+ * else: the stored preference (or the OS one) lands as `.light` / `.dark` on
19
+ * `<html>` before first paint, and `window.__uiTheme` (`get` / `set`) plus the
20
+ * `ui:themechange` event serve the `ThemeSwitcher` panel item.
21
+ *
22
+ * @example
23
+ * // vite.mjs, modo.config.ts `vite: './vite.mjs'`
24
+ * import { docsTheme } from '@justanarthur/modo-ui/docs/vite'
25
+ * config => {
26
+ * config.plugins = [...(config.plugins ?? []), docsTheme({ storageKey: 'my-ds-theme' })]
27
+ * return config
28
+ * }
29
+ */
30
+ declare function docsTheme({ storageKey }?: DocsThemeOptions): DocsThemePlugin;
31
+ export { DocsThemeOptions, DocsThemePlugin, docsTheme };
@@ -0,0 +1,45 @@
1
+ // docs/vite.ts
2
+ var controller = (storageKey) => `(() => {
3
+ const KEY = ${JSON.stringify(storageKey)}
4
+ const root = document.documentElement
5
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
6
+ const read = () => {
7
+ try {
8
+ const stored = localStorage.getItem(KEY)
9
+ return stored === 'light' || stored === 'dark' ? stored : 'system'
10
+ } catch {
11
+ return 'system'
12
+ }
13
+ }
14
+ let preference = read()
15
+ const apply = () => {
16
+ const resolved = preference === 'system' ? (mq.matches ? 'dark' : 'light') : preference
17
+ root.classList.toggle('dark', resolved === 'dark')
18
+ root.classList.toggle('light', resolved === 'light')
19
+ window.dispatchEvent(new Event('ui:themechange'))
20
+ }
21
+ window.__uiTheme = {
22
+ get: () => preference,
23
+ set: (next) => {
24
+ preference = next === 'light' || next === 'dark' ? next : 'system'
25
+ try {
26
+ if (preference === 'system') localStorage.removeItem(KEY)
27
+ else localStorage.setItem(KEY, preference)
28
+ } catch {}
29
+ apply()
30
+ },
31
+ }
32
+ mq.addEventListener('change', () => { if (preference === 'system') apply() })
33
+ apply()
34
+ })()`;
35
+ function docsTheme({ storageKey = "modo-ui-theme" } = {}) {
36
+ return {
37
+ name: "modo-ui:docs-theme",
38
+ transformIndexHtml() {
39
+ return [{ tag: "script", children: controller(storageKey), injectTo: "head-prepend" }];
40
+ }
41
+ };
42
+ }
43
+ export {
44
+ docsTheme
45
+ };
@@ -0,0 +1,120 @@
1
+ /*
2
+ * The docs chrome's look and layout (modo-ui's own docs, and any design system
3
+ * documented with modo that loads this file). The lib ships structure only;
4
+ * these give its hooks the design system's look, tokens only. Both asides are
5
+ * inset Sidebars (`DocsNav` in ./chrome.tsx), which bring their own look; the
6
+ * rules for ./chrome.tsx's own elements key on `data-modo-ui`, so no utility
7
+ * generator has to scan the module.
8
+ *
9
+ * Load it after the package stylesheet (base.css / styles.css) and the lib's
10
+ * structural shell.css, which modo already puts first.
11
+ */
12
+
13
+ /* modo's structural shell.css draws an unlayered `*:focus-visible` outline,
14
+ which would outrank the layered fallbacks and every component's own
15
+ ring (a second ring on every control). Reverting both properties to the
16
+ layered cascade hands focus styling back to @layer base and the utilities. */
17
+ *:focus-visible {
18
+ outline: revert-layer;
19
+ outline-offset: revert-layer;
20
+ }
21
+
22
+ :root {
23
+ /* A fixed length: the lib's 72ch resolves per element, so the lead,
24
+ captions and paragraphs each ended at a different width. */
25
+ --modo-measure: 45rem;
26
+ }
27
+
28
+ /* One reading column on every page: the article (example cards and prop
29
+ tables included), and an overview's header and grid, keep to the measure,
30
+ centered in the content area. */
31
+ [data-modo="content"] > * {
32
+ max-width: var(--modo-measure);
33
+ margin-inline: auto;
34
+ }
35
+
36
+ /* The inset layout, as Sidebar.Inset draws it: both asides are inset
37
+ Sidebars (DocsNav) and the content is the card between them. Each aside's
38
+ column follows its sidebar's width spring, so hiding one reflows the card;
39
+ below md both are drawers and the card takes the row. */
40
+ [data-modo="app"] {
41
+ grid-template-columns: auto minmax(0, 1fr) auto;
42
+ grid-template-rows: none;
43
+ grid-template-areas: "sidebar content panel";
44
+ }
45
+
46
+ [data-modo="sidebar"],
47
+ [data-modo="panel"] {
48
+ position: sticky;
49
+ top: 0;
50
+ /* Over the card: the corner triggers overlap it. */
51
+ z-index: 1;
52
+ height: 100vh;
53
+ max-height: none;
54
+ /* The Sidebar scrolls its own rows. */
55
+ overflow: visible;
56
+ }
57
+
58
+ [data-modo="content"] {
59
+ margin: var(--space-2);
60
+ /* The inset topbar's band, where the corner triggers sit. */
61
+ padding-top: calc(3rem + var(--space-2));
62
+ border-radius: var(--radius-xl);
63
+ background-color: var(--surface-2);
64
+ box-shadow: var(--shadow-2);
65
+ transition: margin var(--duration-fast);
66
+ }
67
+
68
+ /* Flush with an open sidebar: its rows keep their own gutter. */
69
+ @media (min-width: 768px) {
70
+ [data-modo="app"]:has(> [data-modo="sidebar"] [data-slot="sidebar"][data-state="expanded"]) > [data-modo="content"] {
71
+ margin-left: 0;
72
+ }
73
+
74
+ [data-modo="app"]:has(> [data-modo="panel"] [data-slot="sidebar"][data-state="expanded"]) > [data-modo="content"] {
75
+ margin-right: 0;
76
+ }
77
+ }
78
+
79
+ /* The lib drops the TOC below xl; here the panel is a sidebar at any width. */
80
+ [data-modo="toc"] {
81
+ display: block;
82
+ }
83
+
84
+ /* The code panel under an example is flush with the frame, not a card in a card. */
85
+ [data-modo="example-code"] > * {
86
+ border-radius: 0;
87
+ }
88
+
89
+ /* The trigger in the card's top corner (DocsNav): level with the topbar band,
90
+ just outside the aside's edge, a gutter further out while that aside is hidden. */
91
+ [data-modo-ui="nav-trigger"] {
92
+ position: absolute;
93
+ top: var(--space-2);
94
+ display: flex;
95
+ height: 3rem;
96
+ align-items: center;
97
+ padding-inline: calc(var(--space-1) * 1.5);
98
+ transition: margin var(--duration-fast) cubic-bezier(0.4, 0, 0.2, 1);
99
+ }
100
+
101
+ [data-modo-ui="nav-trigger"][data-side="left"] {
102
+ left: 100%;
103
+ }
104
+
105
+ [data-modo-ui="nav-trigger"][data-side="right"] {
106
+ right: 100%;
107
+ }
108
+
109
+ [data-modo-ui="nav-trigger"][data-side="left"][data-gutter] {
110
+ margin-left: var(--space-2);
111
+ }
112
+
113
+ [data-modo-ui="nav-trigger"][data-side="right"][data-gutter] {
114
+ margin-right: var(--space-2);
115
+ }
116
+
117
+ /* A panel section's control (the Theme select) under its group label. */
118
+ [data-modo-ui="nav-controls"] {
119
+ padding-inline: var(--space-2);
120
+ }
@@ -0,0 +1,283 @@
1
+ /*
2
+ * The docs chrome: every slot adapter modo's viewer needs to render a design
3
+ * system's docs the way modo-ui's own site is drawn. modo.config.ts picks each
4
+ * one by export name (`./docs#DocsNav`, or `@justanarthur/modo-ui/docs#DocsNav`
5
+ * from another package); chrome.css holds the layout rules and the look they
6
+ * share, so nothing here needs a utility generator to scan this file.
7
+ */
8
+
9
+ import { Check, CodeXml, Copy, Link2, Monitor, Moon, Sun } from 'lucide-react'
10
+ import {
11
+ type AnchorHTMLAttributes,
12
+ Children,
13
+ createContext,
14
+ isValidElement,
15
+ type ReactNode,
16
+ useContext,
17
+ useEffect,
18
+ useState,
19
+ } from 'react'
20
+ import ModoButton from '../components/button'
21
+ import FluidSelect from '../components/inputs/select'
22
+ import Sidebar, {
23
+ SIDEBAR_KEYBOARD_SHORTCUT,
24
+ SIDEBAR_KEYBOARD_SHORTCUT_RIGHT,
25
+ type SidebarSide,
26
+ useSidebar,
27
+ } from '../components/navigation/sidebar'
28
+
29
+ /**
30
+ * Button slot: the chrome's icon buttons (Show code, Copy, the anchor toggles). The chrome passes
31
+ * `variant="ghost"`, `size="icon-sm"`, `aria-label` and `aria-pressed`; the design system's Button takes them as is.
32
+ * Pinned so a host with an item named `Button` of another shape is not matched by name.
33
+ */
34
+ export const Button = ModoButton
35
+
36
+ /**
37
+ * Link slot: every chrome and Markdown link, a plain anchor so the chrome's own link styling applies.
38
+ * Pinned so a host with an item named `Link` of another shape is not matched by name.
39
+ */
40
+ export function Link(props: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
41
+ return <a {...props} />
42
+ }
43
+
44
+ const icons = { code: CodeXml, copy: Copy, check: Check, link: Link2 }
45
+
46
+ /** Icon slot: the chrome asks for icons by name; lucide draws them. */
47
+ export function Icon({
48
+ name,
49
+ }: {
50
+ /** Which chrome icon to draw. */
51
+ name: keyof typeof icons
52
+ /** Accessible text; the button around the icon carries it. */
53
+ label: string
54
+ }) {
55
+ const Glyph = icons[name]
56
+ return <Glyph size={16} strokeWidth={1.5} aria-hidden />
57
+ }
58
+
59
+ /**
60
+ * Select slot: the chrome passes a flat `value` / `onChange` / `options`
61
+ * contract; FF's Select is compositional, so this maps one onto the other.
62
+ */
63
+ export function Select({
64
+ value,
65
+ onChange,
66
+ options,
67
+ }: {
68
+ value: string
69
+ onChange: (value: string) => void
70
+ options: { value: string; label: string }[]
71
+ }) {
72
+ return (
73
+ <FluidSelect value={value} onValueChange={onChange}>
74
+ <FluidSelect.Trigger />
75
+ <FluidSelect.Content>
76
+ {options.map((option, index) => (
77
+ <FluidSelect.Item key={option.value} value={option.value} index={index}>
78
+ {option.label}
79
+ </FluidSelect.Item>
80
+ ))}
81
+ </FluidSelect.Content>
82
+ </FluidSelect>
83
+ )
84
+ }
85
+
86
+ type ThemePreference = 'light' | 'dark' | 'system'
87
+
88
+ declare global {
89
+ interface Window {
90
+ __uiTheme?: { get: () => ThemePreference; set: (next: ThemePreference) => void }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Sidebar slot: both asides of the chrome, each an inset `Sidebar` with its
96
+ * own `openOnHover` provider, so the content between them is the inset card
97
+ * (chrome.css). The trigger in the card's corner (or `[` for the nav, `]` for
98
+ * the panel) switches a side between open and on hover. Below md each side
99
+ * is a drawer. The panel starts on hover below xl.
100
+ *
101
+ * A section of links is a collapsible `Sidebar.Group` whose rows share one
102
+ * `Sidebar.Menu`, so the current page and the hover highlight melt from link
103
+ * to link (the TOC's current heading too, as the page scrolls); a panel
104
+ * section (the Theme select) keeps its control under the group label. Pin it
105
+ * in modo.config.ts: by name alone modo would adopt a documented `Sidebar`
106
+ * item, an app shell that needs its provider.
107
+ */
108
+ export function DocsNav({ children }: { children?: ReactNode }) {
109
+ const [side, setSide] = useState<SidebarSide>()
110
+ // The slot gets no side: which aside it landed in tells, before first paint.
111
+ if (!side) return <div ref={node => node && setSide(node.closest('[data-modo="panel"]') ? 'right' : 'left')} />
112
+ return <DocsSidebar side={side}>{children}</DocsSidebar>
113
+ }
114
+
115
+ function DocsSidebar({ side, children }: { side: SidebarSide; children?: ReactNode }) {
116
+ return (
117
+ <Sidebar.Provider
118
+ defaultOpen={side === 'left' || window.matchMedia('(min-width: 1280px)').matches}
119
+ openOnHover
120
+ persist={false}
121
+ shortcut={null}
122
+ >
123
+ <Sidebar variant="inset" side={side}>
124
+ <Sidebar.Content>
125
+ <nav aria-label={side === 'left' ? 'Docs' : 'Page'}>{children}</nav>
126
+ </Sidebar.Content>
127
+ </Sidebar>
128
+ <DocsNavTrigger side={side} />
129
+ </Sidebar.Provider>
130
+ )
131
+ }
132
+
133
+ /**
134
+ * The trigger, in the card's top corner where the inset topbar keeps it. The
135
+ * card sits flush with an open sidebar and a gutter away from a hidden one,
136
+ * so the trigger follows.
137
+ */
138
+ function DocsNavTrigger({ side }: { side: SidebarSide }) {
139
+ const { state, isMobile, toggleSidebar } = useSidebar()
140
+ useDocsShortcut(side === 'left' ? SIDEBAR_KEYBOARD_SHORTCUT : SIDEBAR_KEYBOARD_SHORTCUT_RIGHT, toggleSidebar)
141
+
142
+ return (
143
+ <div data-modo-ui="nav-trigger" data-side={side} data-gutter={state === 'collapsed' || isMobile ? '' : undefined}>
144
+ <Sidebar.Trigger />
145
+ </div>
146
+ )
147
+ }
148
+
149
+ /**
150
+ * `[` / `]` for the docs sidebars, owned here rather than by their providers:
151
+ * a provider's key goes to the first one mounted when focus is on the page,
152
+ * and on a fresh load that is a demo in the content, mounted a render before
153
+ * the chrome's. The demos keep the key while focus is inside one.
154
+ */
155
+ function useDocsShortcut(key: string, toggle: () => void) {
156
+ useEffect(() => {
157
+ const onKeyDown = (event: KeyboardEvent) => {
158
+ if (event.key !== key || event.metaKey || event.ctrlKey || event.altKey) return
159
+ const target = event.target as HTMLElement
160
+ if (target.isContentEditable) return
161
+ if (target.closest('input, textarea, select, [data-modo="content"] [data-slot="sidebar-wrapper"]')) return
162
+ // Capture phase: the demos' providers listen on window too, and must not toggle with it.
163
+ event.stopImmediatePropagation()
164
+ event.preventDefault()
165
+ toggle()
166
+ }
167
+ window.addEventListener('keydown', onKeyDown, true)
168
+ return () => window.removeEventListener('keydown', onKeyDown, true)
169
+ }, [key, toggle])
170
+ }
171
+
172
+ /** How deep a nav element sits: a tier is a section, a group folder a section inside it, a nested group deeper still. */
173
+ const NavDepth = createContext(0)
174
+
175
+ function DocsNavItem({ href, active, children }: { href: string; active?: boolean; children?: ReactNode }) {
176
+ const { setOpenMobile } = useSidebar()
177
+ if (useContext(NavDepth) >= 2) {
178
+ return (
179
+ <Sidebar.MenuSubItem>
180
+ <Sidebar.MenuSubButton href={href} isActive={active} onClick={() => setOpenMobile(false)}>
181
+ {children}
182
+ </Sidebar.MenuSubButton>
183
+ </Sidebar.MenuSubItem>
184
+ )
185
+ }
186
+ return (
187
+ <Sidebar.MenuItem>
188
+ <Sidebar.MenuButton isActive={active} render={<a href={href} />} onClick={() => setOpenMobile(false)}>
189
+ {children}
190
+ </Sidebar.MenuButton>
191
+ </Sidebar.MenuItem>
192
+ )
193
+ }
194
+
195
+ function DocsNavSection({ title, children }: { title: string; children?: ReactNode }) {
196
+ const depth = useContext(NavDepth)
197
+ if (depth >= 1) return <DocsNavGroup title={title}>{children}</DocsNavGroup>
198
+ const onlyLinks = Children.toArray(children).every(
199
+ child => isValidElement(child) && (child.type === DocsNavItem || child.type === DocsNavSection),
200
+ )
201
+ if (onlyLinks) {
202
+ return (
203
+ <Sidebar.Group collapsible>
204
+ <Sidebar.GroupLabel>{title}</Sidebar.GroupLabel>
205
+ <NavDepth.Provider value={1}>
206
+ <Sidebar.Menu>{children}</Sidebar.Menu>
207
+ </NavDepth.Provider>
208
+ </Sidebar.Group>
209
+ )
210
+ }
211
+ return (
212
+ <Sidebar.Group>
213
+ <Sidebar.GroupLabel>{title}</Sidebar.GroupLabel>
214
+ <div data-modo-ui="nav-controls">{children}</div>
215
+ </Sidebar.Group>
216
+ )
217
+ }
218
+
219
+ /** Whether a nav subtree holds the current page, nested groups included. */
220
+ const holdsActive = (children: ReactNode): boolean =>
221
+ Children.toArray(children).some(
222
+ child =>
223
+ isValidElement<{ active?: boolean; children?: ReactNode }>(child) &&
224
+ (child.props.active || holdsActive(child.props.children)),
225
+ )
226
+
227
+ /** A group folder inside a tier (or inside another group): a collapsible row, opened while it holds the current page. */
228
+ function DocsNavGroup({ title, children }: { title: string; children?: ReactNode }) {
229
+ const depth = useContext(NavDepth)
230
+ const current = holdsActive(children)
231
+ const [open, setOpen] = useState(current)
232
+ useEffect(() => {
233
+ if (current) setOpen(true)
234
+ }, [current])
235
+ return (
236
+ <Sidebar.MenuItem collapsible open={open} onOpenChange={setOpen}>
237
+ <Sidebar.MenuButton>{title}</Sidebar.MenuButton>
238
+ <NavDepth.Provider value={depth + 1}>
239
+ <Sidebar.MenuSub>{children}</Sidebar.MenuSub>
240
+ </NavDepth.Provider>
241
+ </Sidebar.MenuItem>
242
+ )
243
+ }
244
+
245
+ DocsNav.Item = DocsNavItem
246
+ DocsNav.Section = DocsNavSection
247
+
248
+ const THEMES = [
249
+ { value: 'light' as const, label: 'Light', icon: Sun },
250
+ { value: 'dark' as const, label: 'Dark', icon: Moon },
251
+ { value: 'system' as const, label: 'System', icon: Monitor },
252
+ ]
253
+
254
+ /**
255
+ * Theme panel item. The switching itself is the pre-paint script the vite
256
+ * helper injects (`docsTheme` in `@justanarthur/modo-ui/docs/vite`, `window.__uiTheme`), so a stored preference applies before first
257
+ * paint; this only reflects and sets it. `ui:themechange` also fires when the
258
+ * OS flips underneath 'system'.
259
+ */
260
+ export function ThemeSwitcher() {
261
+ const [preference, setPreference] = useState<ThemePreference>(() => window.__uiTheme?.get() ?? 'system')
262
+
263
+ useEffect(() => {
264
+ const sync = () => setPreference(window.__uiTheme?.get() ?? 'system')
265
+ window.addEventListener('ui:themechange', sync)
266
+ return () => window.removeEventListener('ui:themechange', sync)
267
+ }, [])
268
+
269
+ const active = THEMES.find(theme => theme.value === preference) ?? THEMES[2]!
270
+
271
+ return (
272
+ <FluidSelect value={preference} onValueChange={next => window.__uiTheme?.set(next as ThemePreference)}>
273
+ <FluidSelect.Trigger aria-label="Color theme" icon={active.icon} />
274
+ <FluidSelect.Content>
275
+ {THEMES.map((theme, index) => (
276
+ <FluidSelect.Item key={theme.value} index={index} value={theme.value} icon={theme.icon}>
277
+ {theme.label}
278
+ </FluidSelect.Item>
279
+ ))}
280
+ </FluidSelect.Content>
281
+ </FluidSelect>
282
+ )
283
+ }
package/docs/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The docs chrome as one module, for a design system documented with modo:
3
+ * `shell: { Sidebar: '@justanarthur/modo-ui/docs#DocsNav', … }` and
4
+ * `panel: { items: [{ label: 'Theme', component: '@justanarthur/modo-ui/docs#ThemeSwitcher' }] }`.
5
+ * The layout and look are `@justanarthur/modo-ui/docs/chrome.css`; the pre-paint theme
6
+ * controller is `docsTheme` in `@justanarthur/modo-ui/docs/vite`. Never imported by the
7
+ * package's main entry.
8
+ */
9
+ export * from './chrome'
package/docs/vite.ts ADDED
@@ -0,0 +1,85 @@
1
+ /*
2
+ * The docs chrome's vite glue, for a modo.config.ts `vite` extension. Node
3
+ * loads that extension natively, and never strips types from a file under
4
+ * node_modules, so this ships built (dist/docs/vite.js); it may only use
5
+ * erasable TypeScript. It imports no `vite` types: `DocsThemePlugin` is the
6
+ * structural slice of a Vite plugin it returns, so the package needs no
7
+ * `vite` dependency of its own.
8
+ */
9
+
10
+ /** The Vite plugin `docsTheme` returns: assignable to Vite's own `Plugin`. */
11
+ export interface DocsThemePlugin {
12
+ name: string
13
+ /** Puts the theme controller in `<head>`, ahead of everything else. */
14
+ transformIndexHtml(): Array<{ tag: 'script'; children: string; injectTo: 'head-prepend' }>
15
+ }
16
+
17
+ /** What `docsTheme` takes. */
18
+ export interface DocsThemeOptions {
19
+ /** The localStorage key holding the stored preference. Give each site its own. Default `modo-ui-theme`. */
20
+ storageKey?: string
21
+ }
22
+
23
+ // The design system's `dark:` variant needs a `.dark` ancestor (upstream
24
+ // ThemeProvider sets it on <html>), and `color-scheme` drives every
25
+ // light-dark() token. modo has no theme of its own, so this runs before first
26
+ // paint: it applies the stored preference (or the OS one under 'system') and
27
+ // exposes `window.__uiTheme` for the Theme panel item (`ThemeSwitcher` in
28
+ // chrome.tsx). The 'ui:themechange' event keeps that control in sync when the
29
+ // OS flips underneath 'system'.
30
+ const controller = (storageKey: string) => `(() => {
31
+ const KEY = ${JSON.stringify(storageKey)}
32
+ const root = document.documentElement
33
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
34
+ const read = () => {
35
+ try {
36
+ const stored = localStorage.getItem(KEY)
37
+ return stored === 'light' || stored === 'dark' ? stored : 'system'
38
+ } catch {
39
+ return 'system'
40
+ }
41
+ }
42
+ let preference = read()
43
+ const apply = () => {
44
+ const resolved = preference === 'system' ? (mq.matches ? 'dark' : 'light') : preference
45
+ root.classList.toggle('dark', resolved === 'dark')
46
+ root.classList.toggle('light', resolved === 'light')
47
+ window.dispatchEvent(new Event('ui:themechange'))
48
+ }
49
+ window.__uiTheme = {
50
+ get: () => preference,
51
+ set: (next) => {
52
+ preference = next === 'light' || next === 'dark' ? next : 'system'
53
+ try {
54
+ if (preference === 'system') localStorage.removeItem(KEY)
55
+ else localStorage.setItem(KEY, preference)
56
+ } catch {}
57
+ apply()
58
+ },
59
+ }
60
+ mq.addEventListener('change', () => { if (preference === 'system') apply() })
61
+ apply()
62
+ })()`
63
+
64
+ /**
65
+ * A Vite plugin that puts the theme controller in `<head>` ahead of everything
66
+ * else: the stored preference (or the OS one) lands as `.light` / `.dark` on
67
+ * `<html>` before first paint, and `window.__uiTheme` (`get` / `set`) plus the
68
+ * `ui:themechange` event serve the `ThemeSwitcher` panel item.
69
+ *
70
+ * @example
71
+ * // vite.mjs, modo.config.ts `vite: './vite.mjs'`
72
+ * import { docsTheme } from '@justanarthur/modo-ui/docs/vite'
73
+ * export default config => {
74
+ * config.plugins = [...(config.plugins ?? []), docsTheme({ storageKey: 'my-ds-theme' })]
75
+ * return config
76
+ * }
77
+ */
78
+ export function docsTheme({ storageKey = 'modo-ui-theme' }: DocsThemeOptions = {}): DocsThemePlugin {
79
+ return {
80
+ name: 'modo-ui:docs-theme',
81
+ transformIndexHtml() {
82
+ return [{ tag: 'script', children: controller(storageKey), injectTo: 'head-prepend' }]
83
+ },
84
+ }
85
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justanarthur/modo-ui",
3
3
  "type": "module",
4
- "version": "0.6.0-canary.6",
4
+ "version": "0.6.0-canary.7",
5
5
  "description": "Fluid Functionalism (Base UI flavor, MIT © 2026 Micka Touillaud) ported to UnoCSS: motion springs, fluid hover, a two-step size ladder and an eight-level elevated surface system. Documented with modo.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "primitives",
43
43
  "components",
44
44
  "blocks",
45
+ "docs",
45
46
  "DESIGN.md",
46
47
  "README.md",
47
48
  "LICENSE*"
@@ -234,6 +235,17 @@
234
235
  "source": "./preset.ts",
235
236
  "default": "./dist/preset.js"
236
237
  },
238
+ "./docs": {
239
+ "types": "./dist/docs/index.d.ts",
240
+ "source": "./docs/index.ts",
241
+ "default": "./dist/docs/index.js"
242
+ },
243
+ "./docs/vite": {
244
+ "types": "./dist/docs/vite.d.ts",
245
+ "source": "./docs/vite.ts",
246
+ "default": "./dist/docs/vite.js"
247
+ },
248
+ "./docs/chrome.css": "./docs/chrome.css",
237
249
  "./styles.css": "./dist/styles.css",
238
250
  "./base.css": "./dist/base.css",
239
251
  "./tokens.css": "./dist/tokens.css",
@@ -263,6 +275,12 @@
263
275
  ],
264
276
  "unocss": [
265
277
  "dist/preset.d.ts"
278
+ ],
279
+ "docs/vite": [
280
+ "dist/docs/vite.d.ts"
281
+ ],
282
+ "docs": [
283
+ "dist/docs/index.d.ts"
266
284
  ]
267
285
  }
268
286
  },
@@ -290,7 +308,7 @@
290
308
  "react-dom": ">=18"
291
309
  },
292
310
  "devDependencies": {
293
- "@justanarthur/modo": "0.9.0-canary.0",
311
+ "@justanarthur/modo": "0.9.0-canary.1",
294
312
  "@types/react": "^18.3.0",
295
313
  "@types/react-dom": "^18.3.0",
296
314
  "bunup": "^0.16.0",