@owlmeans/web-panel 0.1.18-rc.17 → 0.1.18-rc.19

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-panel",
4
- "version": "0.1.18-rc.16",
5
- "generatedAt": "2026-08-27T21:16:47.143Z",
4
+ "version": "0.1.18-rc.18",
5
+ "generatedAt": "2026-08-30T20:53:30.853Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-panel
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.16"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.18"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
@@ -17,6 +17,7 @@ user-invocable: false
17
17
  | `makeContext<C, T>(cfg)` | Base web context factory (shadcn/Radix + default OwlMeans router) |
18
18
  | `NavLayout` | The application shell — header, section menu, screen menu, content, footer |
19
19
  | `TopNav` / `SideNav` / `Footer` | The shell's pieces, mountable on their own |
20
+ | `Toaster` | The application's toast surface — mounted once, in the layout |
20
21
  | `components` submodule | shadcn/Radix panel/form components |
21
22
  | Re-exports from `@owlmeans/client-panel` | Cross-platform panel primitives, incl. `usePanelNav` and the `PanelNav*` types |
22
23
  | `main`, `exports`, `context`, `modules`, `types` | Wiring helpers |
@@ -146,6 +147,33 @@ Rules that make the shell behave:
146
147
  - **Vendor `navigation-menu`.** `SideNav` builds on the existing `Button`; `TopNav` uses the shadcn
147
148
  `navigation-menu` primitive — see the `@` contract below.
148
149
 
150
+ ### Toasts — `Toaster`
151
+
152
+ An action that succeeded or failed says so in a toast. The surface is `Toaster`; the messages are
153
+ `toast.success(...)` / `toast.error(...)` imported from **`sonner`** by whatever raised them.
154
+
155
+ ```tsx
156
+ import { NavLayout, Toaster } from '@owlmeans/web-panel'
157
+
158
+ export const MainLayout: FC<PropsWithChildren> = ({ children }) => <>
159
+ <NavLayout nav={navConfig} title="My App">{children}</NavLayout>
160
+ <Toaster />
161
+ </>
162
+ ```
163
+
164
+ - **Mount it exactly once, in the layout.** `toast()` writes to a module-global store, so two
165
+ mounted `Toaster`s render every message twice and none renders any of them — both failures are
166
+ silent, which is why `tests/toaster.spec.ts` asserts the count rather than the presence.
167
+ - **Colours come from the app's own tokens** — `--popover`, `--popover-foreground`, `--border` —
168
+ so a toast matches every other floating surface. A caller's `style` merges over them.
169
+ - **The theme follows the `.dark` class on the document element**, not a theme provider: the
170
+ package reads the class an app's `next-themes`, the owl theme provider, or a hand-written
171
+ toggle all set, and depends on none of them. Pass `theme` explicitly to override.
172
+ - Defaults are `richColors`, `closeButton`, `duration={5000}`, `position="top-right"`; every one
173
+ of them, and every other `ToasterProps` field, is overridable per app.
174
+ - `sonner` is a dependency of this package, so nothing is required of the consumer — but an app
175
+ raising its own toasts should declare `sonner` too, at a range that resolves to the same copy.
176
+
149
177
  ## Consumer setup — the `@` contract and Tailwind
150
178
 
151
179
  `web-panel` emits `@/components/ui/*` and `@/lib/utils` verbatim; the app's bundler resolves `@` to
@@ -13,4 +13,5 @@ export * from './text.js';
13
13
  export * from './link.js';
14
14
  export * from './helper.js';
15
15
  export * from './status.js';
16
+ export * from './toaster.js';
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
@@ -12,4 +12,5 @@ export * from './text.js';
12
12
  export * from './link.js';
13
13
  export * from './helper.js';
14
14
  export * from './status.js';
15
+ export * from './toaster.js';
15
16
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAEA,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAEA,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
@@ -0,0 +1,16 @@
1
+ import type { FC } from 'react';
2
+ import type { ToasterProps } from 'sonner';
3
+ /**
4
+ * The application's toast surface — mount it ONCE, in the layout.
5
+ *
6
+ * `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
7
+ * message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
8
+ * layout is therefore the only place this belongs.
9
+ *
10
+ * Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
11
+ * below are what its stylesheet paints with, so a toast follows `--popover` the way every other
12
+ * floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
13
+ * shift or recolour the stack keeps the rest.
14
+ */
15
+ export declare const Toaster: FC<ToasterProps>;
16
+ //# sourceMappingURL=toaster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toaster.d.ts","sourceRoot":"","sources":["../../src/components/toaster.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,EAAE,EAAE,MAAM,OAAO,CAAA;AAG9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAA;AAkC1C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,EAAE,EAAE,CAAC,YAAY,CAkBpC,CAAA"}
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Toaster as Sonner } from 'sonner';
4
+ import { cn } from '@/lib/utils';
5
+ const DARK_CLASS = 'dark';
6
+ /**
7
+ * Follow whatever puts `.dark` on the document element.
8
+ *
9
+ * The panel's Tailwind theme switches on that class (`@custom-variant dark (&:is(.dark *))`),
10
+ * and every OwlMeans app drives it from something different — `next-themes` with
11
+ * `attribute="class"`, the owl theme provider, or nothing at all. Reading the class rather than a
12
+ * provider's hook means this component follows all three and depends on none of them: a package
13
+ * that imported `next-themes` would force it on every consumer of `@owlmeans/web-panel`, and a
14
+ * second copy of a theme provider is its own class of bug.
15
+ *
16
+ * `'system'` is the answer before the first paint and whenever nothing sets the class — sonner
17
+ * resolves it from `prefers-color-scheme`, which is what an app with no toggle means by it.
18
+ */
19
+ const useDocumentTheme = () => {
20
+ const [theme, setTheme] = useState('system');
21
+ useEffect(() => {
22
+ const root = document.documentElement;
23
+ const read = () => setTheme(root.classList.contains(DARK_CLASS) ? 'dark' : 'system');
24
+ read();
25
+ const observer = new MutationObserver(read);
26
+ observer.observe(root, { attributes: true, attributeFilter: ['class'] });
27
+ return () => observer.disconnect();
28
+ }, []);
29
+ return theme;
30
+ };
31
+ /**
32
+ * The application's toast surface — mount it ONCE, in the layout.
33
+ *
34
+ * `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
35
+ * message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
36
+ * layout is therefore the only place this belongs.
37
+ *
38
+ * Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
39
+ * below are what its stylesheet paints with, so a toast follows `--popover` the way every other
40
+ * floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
41
+ * shift or recolour the stack keeps the rest.
42
+ */
43
+ export const Toaster = ({ style, theme, className, ...props }) => {
44
+ const detected = useDocumentTheme();
45
+ return _jsx(Sonner, { theme: theme ?? detected, className: cn('toaster group', className), richColors: true, closeButton: true, duration: 5000, position: "top-right", style: {
46
+ '--normal-bg': 'var(--popover)',
47
+ '--normal-text': 'var(--popover-foreground)',
48
+ '--normal-border': 'var(--border)',
49
+ ...(style ?? {})
50
+ }, ...props });
51
+ };
52
+ //# sourceMappingURL=toaster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toaster.js","sourceRoot":"","sources":["../../src/components/toaster.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,QAAQ,CAAA;AAE1C,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAEhC,MAAM,UAAU,GAAG,MAAM,CAAA;AAEzB;;;;;;;;;;;;GAYG;AACH,MAAM,gBAAgB,GAAG,GAA0B,EAAE;IACnD,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAwB,QAAQ,CAAC,CAAA;IAEnE,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,QAAQ,CAAC,eAAe,CAAA;QACrC,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAA;QACpF,IAAI,EAAE,CAAA;QACN,MAAM,QAAQ,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,CAAA;QAC3C,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QAExE,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAA;IACpC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,KAAK,CAAA;AACd,CAAC,CAAA;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,OAAO,GAAqB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE;IACjF,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAA;IAEnC,OAAO,KAAC,MAAM,IACZ,KAAK,EAAE,KAAK,IAAI,QAAQ,EACxB,SAAS,EAAE,EAAE,CAAC,eAAe,EAAE,SAAS,CAAC,EACzC,UAAU,QACV,WAAW,QACX,QAAQ,EAAE,IAAI,EACd,QAAQ,EAAC,WAAW,EACpB,KAAK,EAAE;YACL,aAAa,EAAE,gBAAgB;YAC/B,eAAe,EAAE,2BAA2B;YAC5C,iBAAiB,EAAE,eAAe;YAClC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;SACA,KACd,KAAK,GACT,CAAA;AACJ,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-panel",
3
- "version": "0.1.18-rc.17",
3
+ "version": "0.1.18-rc.19",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -63,7 +63,8 @@
63
63
  "@owlmeans/web-router": "^0.1.18-rc.8",
64
64
  "i18next-browser-languagedetector": "^8.0.0",
65
65
  "qrcode.react": "^4.2.0",
66
- "react-google-recaptcha": "^3.1.0"
66
+ "react-google-recaptcha": "^3.1.0",
67
+ "sonner": "^2.0.7"
67
68
  },
68
69
  "peerDependencies": {
69
70
  "@radix-ui/react-label": "*",
@@ -17,3 +17,4 @@ export * from './text.js'
17
17
  export * from './link.js'
18
18
  export * from './helper.js'
19
19
  export * from './status.js'
20
+ export * from './toaster.js'
@@ -0,0 +1,68 @@
1
+ import type { CSSProperties, FC } from 'react'
2
+ import { useEffect, useState } from 'react'
3
+ import { Toaster as Sonner } from 'sonner'
4
+ import type { ToasterProps } from 'sonner'
5
+ import { cn } from '@/lib/utils'
6
+
7
+ const DARK_CLASS = 'dark'
8
+
9
+ /**
10
+ * Follow whatever puts `.dark` on the document element.
11
+ *
12
+ * The panel's Tailwind theme switches on that class (`@custom-variant dark (&:is(.dark *))`),
13
+ * and every OwlMeans app drives it from something different — `next-themes` with
14
+ * `attribute="class"`, the owl theme provider, or nothing at all. Reading the class rather than a
15
+ * provider's hook means this component follows all three and depends on none of them: a package
16
+ * that imported `next-themes` would force it on every consumer of `@owlmeans/web-panel`, and a
17
+ * second copy of a theme provider is its own class of bug.
18
+ *
19
+ * `'system'` is the answer before the first paint and whenever nothing sets the class — sonner
20
+ * resolves it from `prefers-color-scheme`, which is what an app with no toggle means by it.
21
+ */
22
+ const useDocumentTheme = (): ToasterProps['theme'] => {
23
+ const [theme, setTheme] = useState<ToasterProps['theme']>('system')
24
+
25
+ useEffect(() => {
26
+ const root = document.documentElement
27
+ const read = () => setTheme(root.classList.contains(DARK_CLASS) ? 'dark' : 'system')
28
+ read()
29
+ const observer = new MutationObserver(read)
30
+ observer.observe(root, { attributes: true, attributeFilter: ['class'] })
31
+
32
+ return () => observer.disconnect()
33
+ }, [])
34
+
35
+ return theme
36
+ }
37
+
38
+ /**
39
+ * The application's toast surface — mount it ONCE, in the layout.
40
+ *
41
+ * `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
42
+ * message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
43
+ * layout is therefore the only place this belongs.
44
+ *
45
+ * Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
46
+ * below are what its stylesheet paints with, so a toast follows `--popover` the way every other
47
+ * floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
48
+ * shift or recolour the stack keeps the rest.
49
+ */
50
+ export const Toaster: FC<ToasterProps> = ({ style, theme, className, ...props }) => {
51
+ const detected = useDocumentTheme()
52
+
53
+ return <Sonner
54
+ theme={theme ?? detected}
55
+ className={cn('toaster group', className)}
56
+ richColors
57
+ closeButton
58
+ duration={5000}
59
+ position="top-right"
60
+ style={{
61
+ '--normal-bg': 'var(--popover)',
62
+ '--normal-text': 'var(--popover-foreground)',
63
+ '--normal-border': 'var(--border)',
64
+ ...(style ?? {})
65
+ } as CSSProperties}
66
+ {...props}
67
+ />
68
+ }
package/tests/context.ts CHANGED
@@ -25,6 +25,10 @@ export const getHarnessUrl = async (): Promise<string> => {
25
25
  alias: { '@': resolve(here, '../src/@') },
26
26
  dedupe: ['react', 'react-dom'],
27
27
  },
28
+ // Pre-bundled at server start rather than discovered while the first request is being
29
+ // served: a dependency found mid-transform re-optimizes and reloads the page underneath
30
+ // the navigation, which the first `goto` of a cold run pays for as a timeout.
31
+ optimizeDeps: { include: ['sonner'] },
28
32
  server: { port: 0 },
29
33
  logLevel: 'warn'
30
34
  })
@@ -8,8 +8,9 @@ import { BASE, HOME } from '@owlmeans/context'
8
8
  import { entrypoint } from '@owlmeans/client-entrypoint'
9
9
  import { frontend, route } from '@owlmeans/route'
10
10
  import { handler, useNavigate } from '@owlmeans/client'
11
+ import { toast } from 'sonner'
11
12
  import type { PanelNavConfig, PanelNavLink } from '../../src/index.js'
12
- import { makeContext, modules as baseModules, NavLayout, PanelApp } from '../../src/index.js'
13
+ import { makeContext, modules as baseModules, NavLayout, PanelApp, Toaster } from '../../src/index.js'
13
14
 
14
15
  // A real app: a context, a layout entrypoint rendering NavLayout, and screens under it.
15
16
  // The nav model resolves the active screen from the router, so nothing here may be faked.
@@ -48,22 +49,34 @@ const footerLinks: PanelNavLink[] = [
48
49
 
49
50
  const screen = (id: string, text: string): FC => () => <div id={id}>{text}</div>
50
51
 
51
- const Layout: FC<PropsWithChildren> = ({ children }) => <NavLayout
52
- nav={navConfig}
53
- title="Harness"
54
- actions={<button id="action-slot">action</button>}
55
- footer={footerLinks}
56
- // A DARK APPLICATION SHELL, which is what a themed app does to the root: a contrasting
57
- // surface pair, both halves correct. The header paints its own opaque background, so it is
58
- // a different surface, and everything in it must stay legible against `--background`
59
- // rather than against this. The harness carries it permanently so every navigation test
60
- // runs against the hostile case instead of a default-coloured page.
61
- className="bg-primary text-primary-foreground"
62
- // A WIDTH-ONLY rhythm override, which is what a design pass writes when it wants a wider
63
- // page. It names the width and nothing else, so the centring and the side padding must
64
- // survive it — substituting this for the default is a page running flush to the window edge.
65
- containerClassName="max-w-[1280px]"
66
- >{children}</NavLayout>
52
+ const Layout: FC<PropsWithChildren> = ({ children }) => <>
53
+ <NavLayout
54
+ nav={navConfig}
55
+ title="Harness"
56
+ actions={<button id="action-slot">action</button>}
57
+ footer={footerLinks}
58
+ // A DARK APPLICATION SHELL, which is what a themed app does to the root: a contrasting
59
+ // surface pair, both halves correct. The header paints its own opaque background, so it is
60
+ // a different surface, and everything in it must stay legible against `--background`
61
+ // rather than against this. The harness carries it permanently so every navigation test
62
+ // runs against the hostile case instead of a default-coloured page.
63
+ className="bg-primary text-primary-foreground"
64
+ // A WIDTH-ONLY rhythm override, which is what a design pass writes when it wants a wider
65
+ // page. It names the width and nothing else, so the centring and the side padding must
66
+ // survive it — substituting this for the default is a page running flush to the window edge.
67
+ containerClassName="max-w-[1280px]"
68
+ >{children}</NavLayout>
69
+ {/* Mounted ONCE, in the layout — exactly where an application mounts it. */}
70
+ <Toaster />
71
+ </>
72
+
73
+ /** The toast surface's exercise: a screen action that reports its outcome. */
74
+ const PrefsScreen: FC = () => <div id="prefs">
75
+ prefs-screen
76
+ <button id="fire-toast" onClick={() => toast.success('preferences saved')}>save</button>
77
+ {/* Outlives any assertion — a theme check must not race the 5s default dismissal. */}
78
+ <button id="fire-sticky" onClick={() => toast.error('sticky failure', { duration: 600_000 })}>fail</button>
79
+ </div>
67
80
 
68
81
  /** A grouping screen — it renders whichever child the router matched. */
69
82
  const ReportsGroup: FC<PropsWithChildren> = ({ children }) => <div id="reports-group">{children}</div>
@@ -115,7 +128,7 @@ const modules = [
115
128
  route(alias.reportDetail, '/detail', frontend({ parent: alias.reports })),
116
129
  handler(screen('detail', 'detail-screen'))
117
130
  ),
118
- entrypoint(route(alias.prefs, '/prefs', frontend({ parent: BASE })), handler(screen('prefs', 'prefs-screen'))),
131
+ entrypoint(route(alias.prefs, '/prefs', frontend({ parent: BASE })), handler(PrefsScreen)),
119
132
  ]
120
133
 
121
134
  context.registerEntrypoints(modules)
@@ -0,0 +1,59 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
3
+ import { HARNESS_URL } from './context.js'
4
+
5
+ // Browser work does not fit the 5s default: a cold harness compiles the app on first request.
6
+ const TIMEOUT = 30_000
7
+
8
+ afterAll(async () => {
9
+ await closeBrowser()
10
+ })
11
+
12
+ const open = async (path: string) => mountComponent({ url: `${HARNESS_URL.replace(/\/$/, '')}${path}` })
13
+
14
+ describe('@owlmeans/web-panel — the toast surface', () => {
15
+ test('an action reports its outcome, once, through the layout-mounted Toaster', async () => {
16
+ // The whole point of shipping the Toaster from the panel: a screen calls `toast()` from
17
+ // `sonner` and something the LAYOUT mounted renders it. Both failure modes are silent —
18
+ // no mount renders nothing at all, two mounts render every message twice — so the count
19
+ // is asserted rather than the mere presence.
20
+ const { page, close } = await open('/prefs')
21
+ try {
22
+ await page.waitForSelector('#prefs')
23
+ await page.locator('#fire-toast').click()
24
+
25
+ const toast = page.locator('[data-sonner-toast]')
26
+ await toast.first().waitFor({ state: 'visible' })
27
+ expect(await page.locator('[data-sonner-toaster]').count()).toBe(1)
28
+ expect(await toast.count()).toBe(1)
29
+ expect(await toast.first().textContent()).toContain('preferences saved')
30
+ } finally {
31
+ await close()
32
+ }
33
+ }, TIMEOUT)
34
+
35
+ test('the toasts follow the document theme class', async () => {
36
+ // The panel reads `.dark` off the document element rather than a theme provider's hook, so
37
+ // it follows next-themes, the owl theme provider and a hand-set class alike. A toast painted
38
+ // in the wrong theme is the visible half; the invisible half is a package that would have
39
+ // forced `next-themes` on every consumer.
40
+ const { page, close } = await open('/prefs')
41
+ try {
42
+ await page.waitForSelector('#prefs')
43
+ await page.locator('#fire-sticky').click()
44
+ // The toast is what becomes visible; the list around it is a zero-box positioning
45
+ // container, so waiting on the list itself never resolves.
46
+ await page.locator('[data-sonner-toast]').first().waitFor({ state: 'visible' })
47
+ const toaster = page.locator('[data-sonner-toaster]')
48
+ expect(await toaster.getAttribute('data-sonner-theme')).toBe('light')
49
+
50
+ await page.evaluate(() => document.documentElement.classList.add('dark'))
51
+ await page.waitForFunction(
52
+ () => document.querySelector('[data-sonner-toaster]')?.getAttribute('data-sonner-theme') === 'dark'
53
+ )
54
+ expect(await toaster.getAttribute('data-sonner-theme')).toBe('dark')
55
+ } finally {
56
+ await close()
57
+ }
58
+ }, TIMEOUT)
59
+ })