@owlmeans/web-panel 0.1.18-rc.2 → 0.1.18-rc.21

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 (113) hide show
  1. package/README.md +85 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-panel/SKILL.md +157 -4
  4. package/build/@/components/ui/navigation-menu.d.ts +15 -0
  5. package/build/@/components/ui/navigation-menu.d.ts.map +1 -0
  6. package/build/@/components/ui/navigation-menu.js +32 -0
  7. package/build/@/components/ui/navigation-menu.js.map +1 -0
  8. package/build/auth/plugins/basic-ed25519.js +1 -1
  9. package/build/auth/plugins/basic-ed25519.js.map +1 -1
  10. package/build/components/footer/component.d.ts +12 -0
  11. package/build/components/footer/component.d.ts.map +1 -0
  12. package/build/components/footer/component.js +24 -0
  13. package/build/components/footer/component.js.map +1 -0
  14. package/build/components/footer/index.d.ts +3 -0
  15. package/build/components/footer/index.d.ts.map +1 -0
  16. package/build/components/footer/index.js +2 -0
  17. package/build/components/footer/index.js.map +1 -0
  18. package/build/components/footer/types.d.ts +11 -0
  19. package/build/components/footer/types.d.ts.map +1 -0
  20. package/build/components/footer/types.js +2 -0
  21. package/build/components/footer/types.js.map +1 -0
  22. package/build/components/index.d.ts +4 -0
  23. package/build/components/index.d.ts.map +1 -1
  24. package/build/components/index.js +4 -0
  25. package/build/components/index.js.map +1 -1
  26. package/build/components/login/append.d.ts +22 -0
  27. package/build/components/login/append.d.ts.map +1 -0
  28. package/build/components/login/append.js +21 -0
  29. package/build/components/login/append.js.map +1 -0
  30. package/build/components/login/credit.d.ts +17 -0
  31. package/build/components/login/credit.d.ts.map +1 -0
  32. package/build/components/login/credit.js +17 -0
  33. package/build/components/login/credit.js.map +1 -0
  34. package/build/components/login/icons.d.ts +14 -0
  35. package/build/components/login/icons.d.ts.map +1 -0
  36. package/build/components/login/icons.js +29 -0
  37. package/build/components/login/icons.js.map +1 -0
  38. package/build/components/login/index.d.ts +6 -0
  39. package/build/components/login/index.d.ts.map +1 -0
  40. package/build/components/login/index.js +6 -0
  41. package/build/components/login/index.js.map +1 -0
  42. package/build/components/login/screen.d.ts +23 -0
  43. package/build/components/login/screen.d.ts.map +1 -0
  44. package/build/components/login/screen.js +54 -0
  45. package/build/components/login/screen.js.map +1 -0
  46. package/build/components/login/terms.d.ts +9 -0
  47. package/build/components/login/terms.d.ts.map +1 -0
  48. package/build/components/login/terms.js +30 -0
  49. package/build/components/login/terms.js.map +1 -0
  50. package/build/components/nav/index.d.ts +5 -0
  51. package/build/components/nav/index.d.ts.map +1 -0
  52. package/build/components/nav/index.js +4 -0
  53. package/build/components/nav/index.js.map +1 -0
  54. package/build/components/nav/layout.d.ts +12 -0
  55. package/build/components/nav/layout.d.ts.map +1 -0
  56. package/build/components/nav/layout.js +40 -0
  57. package/build/components/nav/layout.js.map +1 -0
  58. package/build/components/nav/side.d.ts +11 -0
  59. package/build/components/nav/side.d.ts.map +1 -0
  60. package/build/components/nav/side.js +25 -0
  61. package/build/components/nav/side.js.map +1 -0
  62. package/build/components/nav/top.d.ts +10 -0
  63. package/build/components/nav/top.d.ts.map +1 -0
  64. package/build/components/nav/top.js +44 -0
  65. package/build/components/nav/top.js.map +1 -0
  66. package/build/components/nav/types.d.ts +55 -0
  67. package/build/components/nav/types.d.ts.map +1 -0
  68. package/build/components/nav/types.js +2 -0
  69. package/build/components/nav/types.js.map +1 -0
  70. package/build/components/toaster.d.ts +16 -0
  71. package/build/components/toaster.d.ts.map +1 -0
  72. package/build/components/toaster.js +52 -0
  73. package/build/components/toaster.js.map +1 -0
  74. package/build/consent/component.d.ts +11 -0
  75. package/build/consent/component.d.ts.map +1 -0
  76. package/build/consent/component.js +53 -0
  77. package/build/consent/component.js.map +1 -0
  78. package/build/consent/index.d.ts +4 -0
  79. package/build/consent/index.d.ts.map +1 -0
  80. package/build/consent/index.js +3 -0
  81. package/build/consent/index.js.map +1 -0
  82. package/build/context.d.ts.map +1 -1
  83. package/build/context.js +4 -0
  84. package/build/context.js.map +1 -1
  85. package/package.json +38 -27
  86. package/src/@/components/ui/navigation-menu.tsx +169 -0
  87. package/src/auth/plugins/basic-ed25519.tsx +1 -1
  88. package/src/components/footer/component.tsx +37 -0
  89. package/src/components/footer/index.ts +3 -0
  90. package/src/components/footer/types.ts +11 -0
  91. package/src/components/index.ts +4 -0
  92. package/src/components/login/append.ts +34 -0
  93. package/src/components/login/credit.tsx +34 -0
  94. package/src/components/login/icons.tsx +39 -0
  95. package/src/components/login/index.ts +5 -0
  96. package/src/components/login/screen.tsx +110 -0
  97. package/src/components/login/terms.tsx +71 -0
  98. package/src/components/nav/index.ts +5 -0
  99. package/src/components/nav/layout.tsx +85 -0
  100. package/src/components/nav/side.tsx +52 -0
  101. package/src/components/nav/top.tsx +62 -0
  102. package/src/components/nav/types.ts +56 -0
  103. package/src/components/toaster.tsx +68 -0
  104. package/src/consent/component.tsx +62 -0
  105. package/src/consent/index.ts +11 -0
  106. package/src/context.ts +5 -0
  107. package/tests/context.ts +43 -0
  108. package/tests/harness/index.html +11 -0
  109. package/tests/harness/mount.tsx +183 -0
  110. package/tests/login.spec.ts +115 -0
  111. package/tests/nav.spec.ts +252 -0
  112. package/tests/smoke.spec.ts +5 -1
  113. package/tests/toaster.spec.ts +59 -0
@@ -0,0 +1,183 @@
1
+ import '../../src/@/globals.css'
2
+
3
+ import type { FC, PropsWithChildren } from 'react'
4
+ import { createRoot } from 'react-dom/client'
5
+ import { config } from '@owlmeans/client-context'
6
+ import { AppType, service } from '@owlmeans/config'
7
+ import { BASE, HOME } from '@owlmeans/context'
8
+ import { entrypoint } from '@owlmeans/client-entrypoint'
9
+ import { frontend, route } from '@owlmeans/route'
10
+ import { handler, useNavigate } from '@owlmeans/client'
11
+ import { toast } from 'sonner'
12
+ import type { PanelNavConfig, PanelNavLink } from '../../src/index.js'
13
+ import { makeContext, modules as baseModules, NavLayout, PanelApp, Toaster } from '../../src/index.js'
14
+ import { LoginScreen } from '../../src/components/login/index.js'
15
+ import { LoginOutcome, ensureLoginService } from '@owlmeans/client-auth/login'
16
+ import type { LoginMethod } from '@owlmeans/client-auth/login'
17
+
18
+ // A real app: a context, a layout entrypoint rendering NavLayout, and screens under it.
19
+ // The nav model resolves the active screen from the router, so nothing here may be faked.
20
+
21
+ const SERVICE = 'web-panel-test'
22
+ const API = `${SERVICE}-api`
23
+
24
+ const alias = {
25
+ dash: `${SERVICE}:web:dash`,
26
+ reports: `${SERVICE}:web:reports`,
27
+ reportsIndex: `${SERVICE}:web:reports-index`,
28
+ reportDetail: `${SERVICE}:web:report-detail`,
29
+ prefs: `${SERVICE}:web:prefs`,
30
+ login: `${SERVICE}:web:login`,
31
+ }
32
+
33
+ const navConfig: PanelNavConfig = {
34
+ sections: [
35
+ // Two screens — the side menu shows.
36
+ {
37
+ name: 'work', label: 'Work', items: [
38
+ { alias: alias.dash, label: 'Dashboard' },
39
+ { alias: alias.reports, label: 'Reports' },
40
+ ]
41
+ },
42
+ // One screen — the side menu must not render at all.
43
+ { name: 'settings', label: 'Settings', items: [{ alias: alias.prefs, label: 'Preferences' }] },
44
+ // No literal labels: exercises the humanized-alias fallback with no i18n present.
45
+ { name: 'extra', items: [{ alias: HOME }] },
46
+ ],
47
+ }
48
+
49
+ const footerLinks: PanelNavLink[] = [
50
+ { alias: alias.dash, label: 'Dashboard' },
51
+ { href: 'https://owlmeans.com', label: 'OwlMeans', open: true },
52
+ ]
53
+
54
+ const screen = (id: string, text: string): FC => () => <div id={id}>{text}</div>
55
+
56
+ /**
57
+ * What the sign-in screen offers in the harness.
58
+ *
59
+ * Real methods, with real `start` handlers that record they were called — the one thing the
60
+ * screen must never do is start one on its own, and a method that only pretended to start could
61
+ * not prove it.
62
+ */
63
+ const started: string[] = []
64
+ ;(globalThis as unknown as { __loginStarted: string[] }).__loginStarted = started
65
+
66
+ const method = (id: string, over?: Partial<LoginMethod>): LoginMethod => ({
67
+ id,
68
+ order: 10,
69
+ start: async () => { started.push(id); return LoginOutcome.Handled },
70
+ ...over,
71
+ })
72
+
73
+ /** The sign-in screen, with the terms confirmation the configuration requires. */
74
+ const LoginHarness: FC = () => <LoginScreen
75
+ Logo={() => <span id="login-logo">logo</span>}
76
+ translate={(_key, defaultValue) => defaultValue}
77
+ />
78
+
79
+ const Layout: FC<PropsWithChildren> = ({ children }) => <>
80
+ <NavLayout
81
+ nav={navConfig}
82
+ title="Harness"
83
+ actions={<button id="action-slot">action</button>}
84
+ footer={footerLinks}
85
+ // A DARK APPLICATION SHELL, which is what a themed app does to the root: a contrasting
86
+ // surface pair, both halves correct. The header paints its own opaque background, so it is
87
+ // a different surface, and everything in it must stay legible against `--background`
88
+ // rather than against this. The harness carries it permanently so every navigation test
89
+ // runs against the hostile case instead of a default-coloured page.
90
+ className="bg-primary text-primary-foreground"
91
+ // A WIDTH-ONLY rhythm override, which is what a design pass writes when it wants a wider
92
+ // page. It names the width and nothing else, so the centring and the side padding must
93
+ // survive it — substituting this for the default is a page running flush to the window edge.
94
+ containerClassName="max-w-[1280px]"
95
+ >{children}</NavLayout>
96
+ {/* Mounted ONCE, in the layout — exactly where an application mounts it. */}
97
+ <Toaster />
98
+ </>
99
+
100
+ /** The toast surface's exercise: a screen action that reports its outcome. */
101
+ const PrefsScreen: FC = () => <div id="prefs">
102
+ prefs-screen
103
+ <button id="fire-toast" onClick={() => toast.success('preferences saved')}>save</button>
104
+ {/* Outlives any assertion — a theme check must not race the 5s default dismissal. */}
105
+ <button id="fire-sticky" onClick={() => toast.error('sticky failure', { duration: 600_000 })}>fail</button>
106
+ </div>
107
+
108
+ /** A grouping screen — it renders whichever child the router matched. */
109
+ const ReportsGroup: FC<PropsWithChildren> = ({ children }) => <div id="reports-group">{children}</div>
110
+
111
+ /**
112
+ * The section's landing screen. Its button navigates to a screen the MENU DOES NOT LIST,
113
+ * which is the only way to exercise the parent-chain walk: an in-app navigation puts the
114
+ * unlisted alias into the router state, and the active section has to be found from it.
115
+ */
116
+ const ReportsIndex: FC = () => {
117
+ const nav = useNavigate()
118
+
119
+ return <div id="reports">
120
+ reports-screen
121
+ <button id="to-detail" onClick={nav.press(alias.reportDetail)}>detail</button>
122
+ </div>
123
+ }
124
+
125
+ // The panel context registers the api-config middleware, which resolves the advertise
126
+ // entrypoint during init — that entrypoint needs a declared backend service route, so both
127
+ // sides are declared here exactly as a real app declares them.
128
+ const base = service({ type: AppType.Frontend, service: SERVICE, host: 'localhost', port: 5173 })
129
+ service({ type: AppType.Backend, service: API, host: 'localhost', port: 5174, base: 'api' }, base)
130
+ base.security = {
131
+ unsecure: true,
132
+ auth: {
133
+ login: {
134
+ // Confirmation required, which is the case that matters: a method must be blocked until it
135
+ // is given, and blocking must SAY so rather than swallow the click.
136
+ terms: { required: true, terms: 'https://example.test/terms', privacy: 'https://example.test/privacy' },
137
+ credit: { poweredBy: true, product: 'Harness', organization: 'Acme' },
138
+ },
139
+ },
140
+ }
141
+
142
+ // `ready` stays false: the Router compiles the entrypoint tree into routes ONLY while the
143
+ // context is un-initialized, so a pre-readied context renders a blank page.
144
+ const cfg = config(SERVICE, base as never)
145
+ const context = makeContext(cfg as never)
146
+ context.serviceRoute(SERVICE, true)
147
+ context.serviceRoute(API, true)
148
+
149
+ ensureLoginService(context as never).registerMethodSource({
150
+ alias: 'harness',
151
+ list: () => [
152
+ method('primary', { emphasis: 'primary', icon: 'google', label: 'Continue with Google' }),
153
+ method('secondary', { order: 20, label: 'Sign in with a key' }),
154
+ method('operator', { order: 900, restricted: true, label: 'Secret key' }),
155
+ ],
156
+ })
157
+
158
+ const modules = [
159
+ // The framework's own entrypoints come first — the api-config middleware the panel context
160
+ // registers resolves one of them during init, and without them init throws before any route
161
+ // is compiled.
162
+ ...baseModules,
163
+ entrypoint(route(BASE, '/', frontend()), handler(Layout)),
164
+ entrypoint(route(HOME, '/', frontend({ default: true, parent: BASE })), handler(screen('home', 'home-screen'))),
165
+ entrypoint(route(alias.dash, '/dash', frontend({ parent: BASE })), handler(screen('dash', 'dash-screen'))),
166
+ // A screen that has children needs a `default: true` child of its own — without one its own
167
+ // path matches nothing and the page renders blank.
168
+ entrypoint(route(alias.reports, '/reports', frontend({ parent: BASE })), handler(ReportsGroup)),
169
+ entrypoint(
170
+ route(alias.reportsIndex, '/', frontend({ default: true, parent: alias.reports })),
171
+ handler(ReportsIndex)
172
+ ),
173
+ entrypoint(
174
+ route(alias.reportDetail, '/detail', frontend({ parent: alias.reports })),
175
+ handler(screen('detail', 'detail-screen'))
176
+ ),
177
+ entrypoint(route(alias.prefs, '/prefs', frontend({ parent: BASE })), handler(PrefsScreen)),
178
+ entrypoint(route(alias.login, '/login', frontend({ parent: BASE })), handler(LoginHarness)),
179
+ ]
180
+
181
+ context.registerEntrypoints(modules)
182
+
183
+ createRoot(document.getElementById('root')!).render(<PanelApp context={context as never} />)
@@ -0,0 +1,115 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
3
+ import { HARNESS_URL } from './context.js'
4
+
5
+ const TIMEOUT = 30_000
6
+
7
+ afterAll(async () => {
8
+ await closeBrowser()
9
+ })
10
+
11
+ const open = async () => {
12
+ const mounted = await mountComponent({ url: `${HARNESS_URL}login` })
13
+ await mounted.page.waitForSelector('[data-login-method]')
14
+ // A fresh acceptance for every test: the confirmation is remembered per browser, and a test
15
+ // that inherited the previous one would assert the unblocked path while claiming the other.
16
+ await mounted.page.evaluate(() => { window.localStorage.clear() })
17
+ await mounted.page.reload()
18
+ await mounted.page.waitForSelector('[data-login-method]')
19
+
20
+ return mounted
21
+ }
22
+
23
+ describe('@owlmeans/web-panel — the sign-in screen', () => {
24
+ test('offers one control per method, and starts NOTHING on its own', async () => {
25
+ const { page, close } = await open()
26
+ try {
27
+ // `operator` is restricted and the configuration never named it, so it must not appear.
28
+ expect(await page.locator('[data-login-method]').count()).toBe(2)
29
+ expect(await page.locator('[data-login-method="primary"]').count()).toBe(1)
30
+ expect(await page.locator('[data-login-method="operator"]').count()).toBe(0)
31
+
32
+ // Nothing left the document. This is requirement one of the whole feature.
33
+ expect(await page.evaluate(() => (window as never as { __loginStarted: string[] }).__loginStarted))
34
+ .toEqual([])
35
+ } finally {
36
+ await close()
37
+ }
38
+ }, TIMEOUT)
39
+
40
+ test('renders the logo the app supplied and the credit line', async () => {
41
+ const { page, close } = await open()
42
+ try {
43
+ expect(await page.locator('#login-logo').count()).toBe(1)
44
+ const credit = await page.locator('[data-login-credit]').textContent()
45
+ expect(credit).toContain('Powered by OwlMeans')
46
+ expect(credit).toContain('Harness')
47
+ expect(credit).toContain('Acme')
48
+ } finally {
49
+ await close()
50
+ }
51
+ }, TIMEOUT)
52
+
53
+ test('a click while the terms are unconfirmed starts nothing AND says why', async () => {
54
+ const { page, close } = await open()
55
+ try {
56
+ // `force`, because the control reports itself `aria-disabled` and Playwright's actionability
57
+ // check honours that — which is the point: the button IS clickable (it keeps its pointer
58
+ // events precisely so the refusal can explain itself), it merely announces that acting on it
59
+ // will not proceed. A real user's click lands here too.
60
+ await page.locator('[data-login-method="primary"]').click({ force: true })
61
+
62
+ // Nothing started…
63
+ expect(await page.evaluate(() => (window as never as { __loginStarted: string[] }).__loginStarted))
64
+ .toEqual([])
65
+ // …and the reason is announced, rather than the button silently doing nothing. This is why
66
+ // the control carries `aria-disabled` and not `disabled`: a disabled button eats the click.
67
+ expect(await page.locator('[role="alert"]').count()).toBe(1)
68
+ expect(await page.locator('[data-login-method="primary"]').getAttribute('aria-disabled'))
69
+ .toBe('true')
70
+ } finally {
71
+ await close()
72
+ }
73
+ }, TIMEOUT)
74
+
75
+ test('confirming the terms unblocks the methods', async () => {
76
+ const { page, close } = await open()
77
+ try {
78
+ await page.locator('[data-login-terms]').check()
79
+ await page.locator('[data-login-method="primary"]').click()
80
+
81
+ expect(await page.evaluate(() => (window as never as { __loginStarted: string[] }).__loginStarted))
82
+ .toEqual(['primary'])
83
+ } finally {
84
+ await close()
85
+ }
86
+ }, TIMEOUT)
87
+
88
+ test('the confirmation is remembered across a reload', async () => {
89
+ const { page, close } = await open()
90
+ try {
91
+ await page.locator('[data-login-terms]').check()
92
+ await page.reload()
93
+ await page.waitForSelector('[data-login-method]')
94
+
95
+ expect(await page.locator('[data-login-terms]').isChecked()).toBe(true)
96
+ expect(await page.locator('[data-login-method="primary"]').getAttribute('aria-disabled'))
97
+ .toBe('false')
98
+ } finally {
99
+ await close()
100
+ }
101
+ }, TIMEOUT)
102
+
103
+ test('the terms links point where the configuration says', async () => {
104
+ const { page, close } = await open()
105
+ try {
106
+ const hrefs = await page.locator('a[target="_blank"]').evaluateAll(
107
+ nodes => nodes.map(node => (node as HTMLAnchorElement).href)
108
+ )
109
+ expect(hrefs).toContain('https://example.test/terms')
110
+ expect(hrefs).toContain('https://example.test/privacy')
111
+ } finally {
112
+ await close()
113
+ }
114
+ }, TIMEOUT)
115
+ })
@@ -0,0 +1,252 @@
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 — two-layer navigation', () => {
15
+ test('a deep link resolves the active section with no history state', async () => {
16
+ // The hard-load case: `location.state` is null here, so the section can only come from
17
+ // the pathname. A menu that highlights nothing on a deep link is the bug this pins.
18
+ const { page, close } = await open('/dash')
19
+ try {
20
+ await page.waitForSelector('#dash')
21
+ expect(await page.locator('header nav').first().isVisible()).toBe(true)
22
+ const active = page.locator('[data-slot="navigation-menu-link"][data-active]')
23
+ expect(await active.textContent()).toBe('Work')
24
+ // Two screens in the section — the side menu renders.
25
+ expect(await page.locator('aside nav button').count()).toBe(2)
26
+ } finally {
27
+ await close()
28
+ }
29
+ }, TIMEOUT)
30
+
31
+ test('a one-screen section renders no side menu', async () => {
32
+ const { page, close } = await open('/prefs')
33
+ try {
34
+ await page.waitForSelector('#prefs')
35
+ expect(await page.locator('aside').count()).toBe(0)
36
+ } finally {
37
+ await close()
38
+ }
39
+ }, TIMEOUT)
40
+
41
+ test('pressing a section navigates to its first screen', async () => {
42
+ const { page, close } = await open('/prefs')
43
+ try {
44
+ await page.waitForSelector('#prefs')
45
+ await page.getByRole('link', { name: 'Work' }).click()
46
+ await page.waitForSelector('#dash')
47
+ expect(new URL(page.url()).pathname).toBe('/dash')
48
+ expect(await page.locator('aside').count()).toBe(1)
49
+ } finally {
50
+ await close()
51
+ }
52
+ }, TIMEOUT)
53
+
54
+ test('the side menu navigates and marks the current screen', async () => {
55
+ const { page, close } = await open('/dash')
56
+ try {
57
+ await page.waitForSelector('#dash')
58
+ expect(await page.locator('aside button[aria-current="page"]').textContent()).toBe('Dashboard')
59
+ await page.locator('aside').getByRole('button', { name: 'Reports' }).click()
60
+ await page.waitForSelector('#reports')
61
+ expect(await page.locator('aside button[aria-current="page"]').textContent()).toBe('Reports')
62
+ } finally {
63
+ await close()
64
+ }
65
+ }, TIMEOUT)
66
+
67
+ test('an unlisted nested screen keeps its ancestor section', async () => {
68
+ const { page, close } = await open('/reports')
69
+ try {
70
+ await page.waitForSelector('#reports')
71
+ // Navigating in-app puts an alias the menu does not list into the router state; the
72
+ // section must still resolve, by walking up to the ancestor that IS listed.
73
+ await page.locator('#to-detail').click()
74
+ await page.waitForSelector('#detail')
75
+ const active = page.locator('[data-slot="navigation-menu-link"][data-active]')
76
+ expect(await active.textContent()).toBe('Work')
77
+ expect(await page.locator('aside button[aria-current="page"]').count()).toBe(0)
78
+ } finally {
79
+ await close()
80
+ }
81
+ }, TIMEOUT)
82
+
83
+ test('the footer resolves aliases, keeps literal hrefs, and never prints a raw key', async () => {
84
+ const { page, close } = await open('/dash')
85
+ try {
86
+ await page.waitForSelector('#dash')
87
+ const links = page.locator('footer a')
88
+ expect(await links.count()).toBe(2)
89
+ expect(await links.nth(0).getAttribute('href')).toBe('/dash')
90
+ expect(await links.nth(1).getAttribute('href')).toBe('https://owlmeans.com')
91
+ expect(await links.nth(1).getAttribute('target')).toBe('_blank')
92
+ // No i18n instance is mounted: an unlabelled section falls back to a humanized alias.
93
+ expect(await page.getByRole('link', { name: 'Extra' }).count()).toBe(1)
94
+ expect(await page.locator('footer').textContent()).not.toContain('modules.')
95
+ } finally {
96
+ await close()
97
+ }
98
+ }, TIMEOUT)
99
+ })
100
+
101
+ /**
102
+ * Perceptual lightness of a computed colour, 0 (black) to 1 (white).
103
+ *
104
+ * The theme is authored in `oklch(L C H)` and Chromium hands that back from
105
+ * `getComputedStyle` unchanged — it does not convert to `rgb()`, and neither does a canvas
106
+ * `fillStyle` round-trip — so L is read straight off. `rgb()` still turns up for the
107
+ * transparent case, and is approximated through its luminance.
108
+ *
109
+ * Comparing lightness rather than colour strings is the point: the failure being pinned is
110
+ * not a wrong colour, it is two colours too CLOSE to tell apart, which equality never sees.
111
+ */
112
+ const lightness = (value: string): number => {
113
+ const oklch = /^oklch\(\s*([\d.]+%?)/i.exec(value.trim())
114
+ if (oklch != null) {
115
+ const raw = oklch[1]
116
+
117
+ return raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw)
118
+ }
119
+
120
+ const [r, g, b] = (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
121
+ const channel = (c: number): number => {
122
+ const v = c / 255
123
+
124
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
125
+ }
126
+
127
+ // The cube root maps luminance onto roughly the same perceptual scale as OKLab's L.
128
+ return (0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)) ** (1 / 3)
129
+ }
130
+
131
+ /** The gap below which text stops being readable on its surface — as `ensureReadableTheme`. */
132
+ const READABLE = 0.35
133
+
134
+ describe('@owlmeans/web-panel — the header is its own surface', () => {
135
+ // The harness root carries `bg-primary text-primary-foreground`: a dark application shell,
136
+ // both halves of the pair correct. The header paints `bg-background` over it and is
137
+ // therefore a DIFFERENT surface — a near-white foreground inherited from the root lands on
138
+ // a near-white bar, and every child stating no colour of its own goes invisible. Nothing
139
+ // fails at build or at run time, so this is pinned from outside, on rendered pixels.
140
+ test('header content stays legible when the shell around it is dark', async () => {
141
+ const { page, close } = await open('/dash')
142
+ try {
143
+ await page.waitForSelector('#dash')
144
+ const style = async (selector: string, property: 'color' | 'backgroundColor') =>
145
+ page.locator(selector).first().evaluate(
146
+ (el, prop) => window.getComputedStyle(el)[prop as 'color'], property
147
+ )
148
+
149
+ const surface = lightness(await style('header', 'backgroundColor'))
150
+ const legible = async (selector: string) =>
151
+ Math.abs(lightness(await style(selector, 'color')) - surface) >= READABLE
152
+
153
+ // The brand and the action slot state no colour of their own, so they are exactly the
154
+ // elements that inherit across the boundary. The action slot is the reported case: a
155
+ // ghost-variant button rendered white on a white bar.
156
+ expect({
157
+ brand: await legible('a.brand'),
158
+ actions: await legible('#action-slot'),
159
+ section: await legible('[data-slot="navigation-menu-link"]'),
160
+ }).toEqual({ brand: true, actions: true, section: true })
161
+ } finally {
162
+ await close()
163
+ }
164
+ }, TIMEOUT)
165
+
166
+ test('the header keeps an opaque background of its own', async () => {
167
+ // This is what makes it a separate surface, and therefore what makes the pairing above
168
+ // mandatory rather than redundant: it is sticky, so content scrolls beneath it and a
169
+ // transparent bar would show that content through the menu.
170
+ const { page, close } = await open('/dash')
171
+ try {
172
+ await page.waitForSelector('#dash')
173
+ const background = await page.locator('header').first()
174
+ .evaluate(el => window.getComputedStyle(el).backgroundColor)
175
+
176
+ expect(background).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
177
+ } finally {
178
+ await close()
179
+ }
180
+ }, TIMEOUT)
181
+ })
182
+
183
+ describe('@owlmeans/web-panel — layout rhythm and link styling', () => {
184
+ test('header, content and footer share one horizontal rhythm', async () => {
185
+ // A content area with a width of its own sits visibly inset from a full-width header, which
186
+ // is what "the screen padding is much narrower than the header" looks like from outside.
187
+ const { page, close } = await open('/prefs')
188
+ try {
189
+ await page.waitForSelector('#prefs')
190
+ const box = async (sel: string) => {
191
+ const b = await page.locator(sel).first().boundingBox()
192
+ if (b == null) throw new Error(`no box for ${sel}`)
193
+ return { x: Math.round(b.x), w: Math.round(b.width) }
194
+ }
195
+ const header = await box('header > div')
196
+ const content = await box('main > div')
197
+ const footer = await box('footer > div')
198
+
199
+ expect({ ...content, of: 'content' }).toEqual({ ...header, of: 'content' })
200
+ expect({ ...footer, of: 'footer' }).toEqual({ ...header, of: 'footer' })
201
+ } finally {
202
+ await close()
203
+ }
204
+ }, TIMEOUT)
205
+
206
+ test('a width-only rhythm override keeps the side padding', async () => {
207
+ // The harness passes `containerClassName="max-w-[1280px]"` — a width and nothing else.
208
+ // `containerClassName` is merged over the shell's default rhythm rather than substituted
209
+ // for it, so `px-4` and `mx-auto` survive an override that never mentioned them. Replacing
210
+ // instead produced a header, content and footer all flush to the window edge.
211
+ const { page, close } = await open('/prefs')
212
+ try {
213
+ await page.waitForSelector('#prefs')
214
+ const padding = async (selector: string) => page.locator(selector).first().evaluate(el => {
215
+ const style = window.getComputedStyle(el)
216
+
217
+ return [parseFloat(style.paddingLeft), parseFloat(style.paddingRight)]
218
+ })
219
+
220
+ const [header, content, footer] = await Promise.all([
221
+ padding('header > div'), padding('main > div'), padding('footer > div'),
222
+ ])
223
+
224
+ expect({
225
+ header: header.every(value => value > 0),
226
+ content: content.every(value => value > 0),
227
+ footer: footer.every(value => value > 0),
228
+ }).toEqual({ header: true, content: true, footer: true })
229
+ } finally {
230
+ await close()
231
+ }
232
+ }, TIMEOUT)
233
+
234
+ test('section entries render as links, not buttons', async () => {
235
+ const { page, close } = await open('/dash')
236
+ try {
237
+ await page.waitForSelector('#dash')
238
+ const active = page.locator('[data-slot="navigation-menu-link"][data-active]')
239
+ const style = await active.evaluate(el => {
240
+ const s = window.getComputedStyle(el)
241
+ return { bg: s.backgroundColor, radius: s.borderTopLeftRadius, deco: s.textDecorationLine }
242
+ })
243
+ // A filled background plus a radius is the tile look the shadcn primitive ships; a link
244
+ // carries neither, and marks the current section with an underline instead.
245
+ expect(style.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
246
+ expect(style.radius).toBe('0px')
247
+ expect(style.deco).toBe('underline')
248
+ } finally {
249
+ await close()
250
+ }
251
+ }, TIMEOUT)
252
+ })
@@ -8,6 +8,10 @@ const harness = `data:text/html,${encodeURIComponent(`
8
8
  </body></html>
9
9
  `)}`
10
10
 
11
+ // Browser work does not fit the 5s default — and the nav suite boots a Vite harness in the same
12
+ // process, which the first test to run pays for.
13
+ const TIMEOUT = 30_000
14
+
11
15
  afterAll(async () => {
12
16
  await closeBrowser()
13
17
  })
@@ -20,5 +24,5 @@ describe('@owlmeans/web-panel — Playwright-library smoke', () => {
20
24
  } finally {
21
25
  await close()
22
26
  }
23
- })
27
+ }, TIMEOUT)
24
28
  })
@@ -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
+ })