@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,110 @@
1
+ import { isValidElement } from 'react'
2
+ import type { FC } from 'react'
3
+ import { Loader2 } from 'lucide-react'
4
+ import { Button } from '@/components/ui/button'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
6
+ import { cn } from '@/lib/utils'
7
+ import { useI18nLib } from '@owlmeans/client-i18n'
8
+ import { useLoginMethods } from '@owlmeans/client-panel/auth'
9
+ import type { LoginMethod, LoginScreenProps } from '@owlmeans/client-auth/login'
10
+ import { LoginMethodIcon } from './icons.js'
11
+ import { LoginTerms } from './terms.js'
12
+ import { LoginCredit } from './credit.js'
13
+
14
+ const VARIANT: Record<string, 'default' | 'outline' | 'link'> = {
15
+ primary: 'default',
16
+ secondary: 'outline',
17
+ link: 'link',
18
+ }
19
+
20
+ /**
21
+ * The sign-in screen: which identity provider, confirmed against which documents.
22
+ *
23
+ * It never starts anything by itself. Every flow leaves from a click — which is the requirement,
24
+ * and also what makes framed sign-in work at all, because the window a framed application has to
25
+ * open can only be opened inside a user gesture.
26
+ *
27
+ * Pure with respect to i18n: `translate` is a prop. {@link LocalizedLoginScreen} is the wrapper
28
+ * that binds it to the app's own resources, so an application mounted without an i18n provider can
29
+ * still render this one with a resolver of its own.
30
+ */
31
+ export const LoginScreen: FC<LoginScreenProps> = props => {
32
+ const t = props.translate ?? ((_key: string, defaultValue: string) => defaultValue)
33
+ const model = useLoginMethods({
34
+ ...(props.config != null ? { config: props.config } : {}),
35
+ ...(props.terms !== undefined ? { terms: props.terms } : {}),
36
+ ...(props.methods != null ? { methods: props.methods } : {}),
37
+ })
38
+
39
+ const Logo = props.Logo
40
+
41
+ return <div className={cn('flex min-h-full w-full items-center justify-center p-4', props.className)}>
42
+ <Card className={cn('w-full max-w-sm', props.containerClassName)}>
43
+ <CardHeader className="items-center gap-2 text-center">
44
+ {Logo != null && <div className="flex justify-center pb-2">
45
+ {isValidElement(Logo) ? Logo : typeof Logo === 'function'
46
+ ? <Logo className="h-10 w-auto" /> : Logo}
47
+ </div>}
48
+ <CardTitle className="text-xl">{props.title ?? t('login.title', 'Sign in')}</CardTitle>
49
+ <CardDescription>
50
+ {props.subtitle ?? t('login.subtitle', 'Choose how you would like to continue.')}
51
+ </CardDescription>
52
+ </CardHeader>
53
+
54
+ <CardContent className="flex flex-col gap-4">
55
+ {model.methods.length < 1
56
+ ? <p role="status" className="text-sm text-muted-foreground text-center">
57
+ {t('login.empty', 'No sign-in method is configured for this application.')}
58
+ </p>
59
+ : <div className="flex flex-col gap-2">
60
+ {model.methods.map((method: LoginMethod) => <Button
61
+ key={method.id}
62
+ type="button"
63
+ data-login-method={method.id}
64
+ variant={VARIANT[method.emphasis ?? 'secondary'] ?? 'outline'}
65
+ // `aria-disabled`, never `disabled`. A disabled button swallows the click, so a user
66
+ // who has not confirmed the terms would press it and be told nothing at all — the
67
+ // screen would simply seem broken. Blocking happens in the handler, which then says
68
+ // why.
69
+ aria-disabled={model.blocked}
70
+ data-blocked={model.blocked ? 'true' : undefined}
71
+ className={cn('w-full justify-center gap-2', model.blocked && 'opacity-60')}
72
+ autoFocus={method.id === model.primary?.id}
73
+ onClick={() => model.select(method)}
74
+ >
75
+ {model.busy === method.id
76
+ ? <Loader2 className="size-4 animate-spin" />
77
+ : <LoginMethodIcon name={method.icon} className="size-4" />}
78
+ {method.label ?? t(`login.method.${method.i18nKey ?? method.id}`, method.id)}
79
+ </Button>)}
80
+ </div>}
81
+
82
+ {model.terms.required && <LoginTerms model={model.terms} translate={t} />}
83
+
84
+ {model.error != null && <p role="alert" className="text-sm text-destructive">
85
+ {model.error}
86
+ </p>}
87
+ </CardContent>
88
+
89
+ <div className="px-6">
90
+ {props.footer ?? <LoginCredit model={model.credit} translate={t} />}
91
+ </div>
92
+ </Card>
93
+ </div>
94
+ }
95
+
96
+ /**
97
+ * {@link LoginScreen} bound to the application's own translations.
98
+ *
99
+ * The split exists because the house rule is that a reusable component takes `translate` as a prop
100
+ * and never reaches for a context — but the ordinary case is an app that has one, and making every
101
+ * app write the resolver would guarantee some of them ship English.
102
+ */
103
+ export const LocalizedLoginScreen: FC<LoginScreenProps> = props => {
104
+ const t = useI18nLib('auth')
105
+
106
+ return <LoginScreen
107
+ {...props}
108
+ translate={props.translate ?? ((key, defaultValue) => t(key, { defaultValue }))}
109
+ />
110
+ }
@@ -0,0 +1,71 @@
1
+ import type { FC, ReactNode } from 'react'
2
+ import { cn } from '@/lib/utils'
3
+ import type { LoginTermsModel } from '@owlmeans/client-panel/auth'
4
+
5
+ export interface LoginTermsProps {
6
+ model: LoginTermsModel
7
+ translate: (key: string, defaultValue: string) => string
8
+ className?: string
9
+ }
10
+
11
+ /**
12
+ * Split a translated sentence around its two link placeholders.
13
+ *
14
+ * The sentence is translated as ONE string with `{{terms}}` and `{{privacy}}` in it, rather than
15
+ * assembled from fragments, because word order differs between languages and an assembled sentence
16
+ * forces every translator into English's. Splitting the already-translated string keeps that
17
+ * freedom while keeping the i18n contract at `(key, defaultValue) => string` — no ReactNode ever
18
+ * comes out of a translation, so an app with no i18n provider still renders a correct sentence.
19
+ */
20
+ const interpolate = (
21
+ sentence: string, slots: Record<string, ReactNode>
22
+ ): ReactNode[] => sentence
23
+ .split(/(\{\{[a-z]+\}\})/i)
24
+ .map((part, index) => {
25
+ const match = /^\{\{([a-z]+)\}\}$/i.exec(part)
26
+
27
+ return match != null
28
+ ? <span key={index}>{slots[match[1]] ?? part}</span>
29
+ : <span key={index}>{part}</span>
30
+ })
31
+
32
+ export const LoginTerms: FC<LoginTermsProps> = ({ model, translate, className }) => {
33
+ const link = (href: string, label: string): ReactNode =>
34
+ <a href={href} target="_blank" rel="noreferrer noopener"
35
+ className="underline underline-offset-2 hover:text-foreground">{label}</a>
36
+
37
+ return <div className={cn('flex flex-col gap-1.5', className)}>
38
+ <label className="flex items-start gap-2 text-sm text-muted-foreground cursor-pointer">
39
+ {/*
40
+ A NATIVE checkbox, deliberately. `web-panel` ships no `checkbox` primitive, and requiring
41
+ every consumer to vendor one plus its Radix peer to render a sign-in screen would be a
42
+ breaking change for every application already on this package. A native control is also the
43
+ most accessible thing available here.
44
+ */}
45
+ <input
46
+ type="checkbox" data-login-terms
47
+ className="mt-0.5 size-4 shrink-0 accent-primary"
48
+ checked={model.accepted}
49
+ aria-invalid={model.attempted && !model.accepted}
50
+ onChange={event => model.accept(event.target.checked)}
51
+ />
52
+ <span>
53
+ {interpolate(
54
+ translate('login.terms.agreement',
55
+ 'I have read and agree to the {{terms}} and the {{privacy}}.'),
56
+ {
57
+ terms: link(model.urls.terms, translate('login.terms.terms', 'Terms & Conditions')),
58
+ privacy: link(model.urls.privacy, translate('login.terms.privacy', 'Privacy Policy')),
59
+ cookies: model.urls.cookies != null
60
+ ? link(model.urls.cookies, translate('login.terms.cookies', 'Cookie Policy'))
61
+ : null,
62
+ }
63
+ )}
64
+ </span>
65
+ </label>
66
+ {model.attempted && !model.accepted && <p role="alert" className="text-sm text-destructive">
67
+ {translate('login.terms.required',
68
+ 'Please confirm the Terms & Conditions and the Privacy Policy to continue.')}
69
+ </p>}
70
+ </div>
71
+ }
@@ -0,0 +1,5 @@
1
+ export type * from './types.js'
2
+
3
+ export * from './layout.js'
4
+ export * from './side.js'
5
+ export * from './top.js'
@@ -0,0 +1,85 @@
1
+ import type { FC } from 'react'
2
+ import { useNavigate } from '@owlmeans/client'
3
+ import { cn } from '@/lib/utils'
4
+ import { Footer } from '../footer/component.js'
5
+
6
+ import { SideNav } from './side.js'
7
+ import { TopNav } from './top.js'
8
+ import type { NavLayoutProps } from './types.js'
9
+
10
+ /**
11
+ * The horizontal rhythm of the whole page, applied identically to the header row, the content
12
+ * and the footer row.
13
+ *
14
+ * It lives in ONE constant because the three regions have to agree: a content area with its own
15
+ * width sits visibly inset from a full-width header, which reads as a mistake rather than as a
16
+ * design. Adjust it through `containerClassName`, which is MERGED over this — never by giving
17
+ * the content a width of its own.
18
+ */
19
+ const CONTAINER = 'mx-auto w-full max-w-6xl px-4'
20
+
21
+ /**
22
+ * The standard two-layer application shell.
23
+ *
24
+ * A layout entrypoint elevates a component that renders this and nothing else — the screen
25
+ * arrives as `children`. The side menu is mounted twice on purpose: one column for wide
26
+ * viewports and one strip for narrow ones. Both render only when the active section has
27
+ * more than one screen, so the single-screen case costs nothing but the elements' absence.
28
+ */
29
+ export const NavLayout: FC<NavLayoutProps> = ({
30
+ nav, translate, title, home, actions, footer, children, className, style,
31
+ headerClassName, contentClassName, containerClassName
32
+ }) => {
33
+ const navigator = useNavigate()
34
+ const brandAlias = home ?? nav.sections.find(section => section.items.length > 0)?.items[0]?.alias
35
+ // MERGED over the default, never substituted for it. `containerClassName` is how a design
36
+ // adjusts ONE aspect of the rhythm — almost always the width — and a caller passing
37
+ // `max-w-[1280px]` means "wider", not "no padding and no centring". Substituting dropped
38
+ // `px-4` and `mx-auto` along with the width it replaced, which is a page whose header,
39
+ // content and footer all run flush to the window edge. tailwind-merge keeps the override
40
+ // winning on the utility it names and leaves the rest of the rhythm standing, so a width-only
41
+ // value stays a width-only change; `px-8` still overrides the padding when that is the intent.
42
+ const container = cn(CONTAINER, containerClassName)
43
+
44
+ return <div
45
+ className={cn('flex min-h-screen flex-col bg-background text-foreground', className)}
46
+ style={style}
47
+ >
48
+ {/*
49
+ * The header is a SURFACE, and it states both halves of one.
50
+ *
51
+ * It has to paint an opaque background — it is sticky, and content scrolls underneath —
52
+ * which makes it a different surface from the root behind it. A colour set on the root
53
+ * (`className="bg-secondary text-secondary-foreground"`, a dark application shell) then
54
+ * inherits INTO this bar while its own `bg-background` stays put, and every child that
55
+ * states no colour of its own — the brand, a ghost-variant action button — is painted in
56
+ * the foreground of a surface it is not on. That is light-on-light, it raises nothing at
57
+ * build or run time, and it is invisible only to whoever opens the page.
58
+ *
59
+ * `text-foreground` is what stops the inheritance at the boundary. It is not decoration
60
+ * and it is not redundant with the root: pairing has to be restated by every element that
61
+ * repaints its own background. `headerClassName` lands after it, so an app that wants a
62
+ * dark bar overrides BOTH halves through tailwind-merge.
63
+ */}
64
+ <header className={cn('sticky top-0 z-40 border-b bg-background text-foreground', headerClassName)}>
65
+ <div className={cn('flex h-14 items-center gap-6', container)}>
66
+ {title != null ? <a
67
+ onClick={brandAlias != null ? navigator.press(brandAlias) : undefined}
68
+ className={cn('brand flex items-center gap-2 text-lg font-semibold', brandAlias != null && 'cursor-pointer')}
69
+ >{title}</a> : null}
70
+ <TopNav config={nav} translate={translate} ariaLabel="Sections" />
71
+ <div className="ml-auto flex items-center gap-2">{actions}</div>
72
+ </div>
73
+ <SideNav config={nav} translate={translate} variant="bar" ariaLabel="Screens" className="md:hidden" />
74
+ </header>
75
+ <div className="flex flex-1">
76
+ <SideNav config={nav} translate={translate} variant="side" ariaLabel="Screens" className="hidden md:block" />
77
+ <main className={cn('flex-1 py-8', contentClassName)}>
78
+ <div className={cn(container)}>{children}</div>
79
+ </main>
80
+ </div>
81
+ {Array.isArray(footer)
82
+ ? <Footer links={footer} translate={translate} containerClassName={container} />
83
+ : footer != null ? <Footer containerClassName={container}>{footer}</Footer> : null}
84
+ </div>
85
+ }
@@ -0,0 +1,52 @@
1
+ import type { FC } from 'react'
2
+ import { defaultNavTranslate, resolveNavLabel, usePanelNav } from '@owlmeans/client-panel'
3
+ import { cn } from '@/lib/utils'
4
+ import { Button } from '@/components/ui/button'
5
+
6
+ import type { SideNavProps } from './types.js'
7
+
8
+ /**
9
+ * The second navigation level — the screens of the active section.
10
+ *
11
+ * It renders NOTHING when the section holds a single screen: a menu offering the page you
12
+ * are already on is noise. The component still exists in the tree, so a section that grows
13
+ * a second screen gains its side menu with no layout change.
14
+ */
15
+ export const SideNav: FC<SideNavProps> = (
16
+ { config, translate = defaultNavTranslate, variant = 'side', ariaLabel, className, style }
17
+ ) => {
18
+ const model = usePanelNav(config)
19
+
20
+ if (!model.showSide || model.active == null) {
21
+ return null
22
+ }
23
+
24
+ const items = model.active.items.map(item => {
25
+ const label = resolveNavLabel(translate, item.label, `modules.${item.alias}`, item.alias)
26
+
27
+ return <Button
28
+ key={item.alias}
29
+ variant="ghost"
30
+ size={variant === 'bar' ? 'sm' : 'default'}
31
+ onClick={model.goItem(item)}
32
+ aria-current={model.isItemActive(item) ? 'page' : undefined}
33
+ className={cn(
34
+ variant === 'bar' ? 'shrink-0' : 'w-full justify-start',
35
+ model.isItemActive(item) && 'bg-accent text-accent-foreground'
36
+ )}
37
+ >
38
+ {item.Icon != null ? <item.Icon className="size-4" /> : null}
39
+ {label}
40
+ </Button>
41
+ })
42
+
43
+ return variant === 'bar'
44
+ ? <nav
45
+ aria-label={ariaLabel}
46
+ className={cn('flex gap-1 overflow-x-auto border-b px-2 py-1', className)}
47
+ style={style}
48
+ >{items}</nav>
49
+ : <aside className={cn('w-56 shrink-0 border-r', className)} style={style}>
50
+ <nav aria-label={ariaLabel} className="flex flex-col gap-1 p-3">{items}</nav>
51
+ </aside>
52
+ }
@@ -0,0 +1,62 @@
1
+ import type { FC } from 'react'
2
+ import { defaultNavTranslate, resolveNavLabel, usePanelNav } from '@owlmeans/client-panel'
3
+ import { cn } from '@/lib/utils'
4
+ import {
5
+ NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList
6
+ } from '@/components/ui/navigation-menu'
7
+
8
+ import type { TopNavProps } from './types.js'
9
+
10
+ /**
11
+ * Section entries are LINKS, not buttons.
12
+ *
13
+ * The shadcn primitive styles its links as menu tiles — a filled hover/active background, a
14
+ * radius and tile padding — which reads as a row of buttons across the top of an application.
15
+ * These classes land after the primitive's, so `cn`'s tailwind-merge drops the conflicting
16
+ * ones; every neutralised utility here has a counterpart in the primitive, and removing one
17
+ * brings the tile back.
18
+ */
19
+ const SECTION_LINK = [
20
+ 'bg-transparent hover:bg-transparent focus:bg-transparent',
21
+ 'rounded-none px-0 py-1',
22
+ 'text-sm font-medium text-muted-foreground',
23
+ 'hover:text-foreground hover:underline underline-offset-4',
24
+ // Radix marks the active link with a VALUELESS `data-active`, so the state has to be matched
25
+ // on the attribute's presence. `data-[active=true]` — which the shadcn primitive itself uses —
26
+ // matches nothing here, which is why its own active styling never showed either.
27
+ 'data-[active]:text-foreground data-[active]:underline data-[active]:bg-transparent',
28
+ 'cursor-pointer',
29
+ ].join(' ')
30
+
31
+ /**
32
+ * The first navigation level — one entry per section.
33
+ *
34
+ * Pressing a section goes to its first screen; the side menu then offers the rest. The
35
+ * viewport is off because no section opens a panel: these are links, not dropdowns.
36
+ */
37
+ export const TopNav: FC<TopNavProps> = ({ config, translate = defaultNavTranslate, ariaLabel, className, style }) => {
38
+ const model = usePanelNav(config)
39
+
40
+ if (model.sections.length < 1) {
41
+ return null
42
+ }
43
+
44
+ return <NavigationMenu viewport={false} aria-label={ariaLabel} className={className} style={style}>
45
+ <NavigationMenuList className="flex-wrap gap-4">
46
+ {model.sections.map(section => {
47
+ const go = model.goSection(section)
48
+
49
+ return <NavigationMenuItem key={section.name}>
50
+ <NavigationMenuLink
51
+ active={model.isSectionActive(section)}
52
+ // A real `href` keeps the entry focusable and openable in a new tab; the click is
53
+ // still handled in-app, so the browser never reloads the whole application.
54
+ href={model.hrefOf(section)}
55
+ onClick={event => { event.preventDefault(); go() }}
56
+ className={cn(SECTION_LINK)}
57
+ >{resolveNavLabel(translate, section.label, `nav.${section.name}`, section.name)}</NavigationMenuLink>
58
+ </NavigationMenuItem>
59
+ })}
60
+ </NavigationMenuList>
61
+ </NavigationMenu>
62
+ }
@@ -0,0 +1,56 @@
1
+ import type { PropsWithChildren, ReactNode } from 'react'
2
+ import type { NavTranslate, PanelNavConfig, PanelNavLink } from '@owlmeans/client-panel'
3
+ import type { StyledProps } from '../types.js'
4
+
5
+ interface NavCommonProps extends StyledProps {
6
+ config: PanelNavConfig
7
+ /** See {@link NavTranslate} — omitted, literal labels and humanized aliases are used. */
8
+ translate?: NavTranslate
9
+ ariaLabel?: string
10
+ }
11
+
12
+ export interface TopNavProps extends NavCommonProps { }
13
+
14
+ export interface SideNavProps extends NavCommonProps {
15
+ /**
16
+ * `side` is the column beside the content; `bar` is the horizontal strip the narrow
17
+ * viewport gets instead. Both render the same items — only one is visible at a time.
18
+ */
19
+ variant?: 'side' | 'bar'
20
+ }
21
+
22
+ export interface NavLayoutProps extends PropsWithChildren<StyledProps> {
23
+ nav: PanelNavConfig
24
+ translate?: NavTranslate
25
+ /** Brand slot — rendered at the far left of the header. */
26
+ title?: ReactNode
27
+ /** Alias the brand navigates to. Defaults to the first section's first item. */
28
+ home?: string
29
+ /** Header right side — sign-in controls, a theme toggle, whatever the app puts there. */
30
+ actions?: ReactNode
31
+ /** Links array renders the standard footer; a node replaces it entirely. */
32
+ footer?: PanelNavLink[] | ReactNode
33
+ /**
34
+ * Styles the HEADER — the sticky bar carrying the brand, the section menu and `actions`.
35
+ *
36
+ * The header is its own SURFACE: it paints an opaque background because content scrolls
37
+ * under it. Give it a background here and you must give it the paired foreground too
38
+ * (`bg-secondary text-secondary-foreground`), exactly as on any other surface — this is the
39
+ * supported way to give an application a dark top bar, and it is why colouring the root
40
+ * instead is not.
41
+ */
42
+ headerClassName?: string
43
+ /** Styles the content area. NOT its width — see `containerClassName`. */
44
+ contentClassName?: string
45
+ /**
46
+ * The page's horizontal rhythm — width and side padding — applied identically to the header
47
+ * row, the content and the footer row. Give the content a width of its own and it sits inset
48
+ * from a full-width header, which reads as a bug rather than as a layout.
49
+ *
50
+ * MERGED over the shell's default (`mx-auto w-full max-w-6xl px-4`), not substituted for it:
51
+ * pass `max-w-[1280px]` and only the width changes, while the centring and the side padding
52
+ * stay. Name the utility you actually want to move — `px-8` widens the gutters — because
53
+ * anything you do not name keeps its default.
54
+ */
55
+ containerClassName?: string
56
+ }
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import { useCallback } from 'react'
2
+ import type { FC } from 'react'
3
+ import { CookieConsent, CookiePolicy, defaultConsentTranslate } from '@owlmeans/web-consent'
4
+ import type { CookieConsentProps, CookiePolicyProps, ConsentLocale } from '@owlmeans/web-consent'
5
+ import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
6
+ import { SUPPORTED_LNGS } from '@owlmeans/i18n'
7
+
8
+ /**
9
+ * Resolve consent copy through the application first, and the packaged bundle second.
10
+ *
11
+ * The chain matters. `@owlmeans/web-consent` takes `translate` as a prop and, once given one,
12
+ * stops consulting its own translations — so a wrapper that forwarded the framework resolver
13
+ * alone would render the ENGLISH default for every key the application had not overridden, in
14
+ * every language. Falling through to the packaged bundle for the current locale means an
15
+ * application overrides what it wants to and inherits seven languages for the rest.
16
+ */
17
+ const useConsentTranslate = (
18
+ locale: string, override?: (key: string, defaultValue: string) => string
19
+ ): ((key: string, defaultValue: string) => string) => {
20
+ const t = useI18nLib('consent')
21
+
22
+ return useCallback((key: string, defaultValue: string) => {
23
+ if (override != null) {
24
+ return override(key, defaultValue)
25
+ }
26
+ const packaged = defaultConsentTranslate(locale)(key, defaultValue)
27
+ // `t` answers with whatever it is given when the key is unknown, so the packaged string is
28
+ // what it is given — an application's override wins, and everything else stays translated.
29
+ return t(key, { defaultValue: packaged })
30
+ }, [t, locale, override])
31
+ }
32
+
33
+ /**
34
+ * The consent dialog, bound to this application's language and translations.
35
+ *
36
+ * `@owlmeans/web-consent` deliberately knows nothing about OwlMeans i18n — one of its consumers is
37
+ * an Astro site with none — so this is where the two meet.
38
+ */
39
+ export const PanelCookieConsent: FC<CookieConsentProps> = props => {
40
+ const [lng] = useLanguage()
41
+ const locale = props.locale ?? lng
42
+ const translate = useConsentTranslate(locale, props.translate)
43
+
44
+ return <CookieConsent {...props} locale={locale} translate={translate} />
45
+ }
46
+
47
+ export const PanelCookiePolicy: FC<CookiePolicyProps> = props => {
48
+ const [lng] = useLanguage()
49
+ const locale = props.locale ?? lng
50
+ const translate = useConsentTranslate(locale, props.translate)
51
+
52
+ return <CookiePolicy {...props} locale={locale} translate={translate} />
53
+ }
54
+
55
+ /**
56
+ * The consent package carries its own locale list because it must build with no dependency on the
57
+ * i18n package at all. This assertion is what keeps the two from drifting: a language added to the
58
+ * framework and not to the bundle fails here, at build time, rather than as a dialog rendering
59
+ * English to the one reader who cannot report it.
60
+ */
61
+ const _localeParity: readonly ConsentLocale[] = SUPPORTED_LNGS as readonly ConsentLocale[]
62
+ void _localeParity
@@ -0,0 +1,11 @@
1
+ export * from './component.js'
2
+ export {
3
+ useConsent, useConsentCategory, consentStore, openConsent, isConsented,
4
+ readConsent, writeConsent, clearConsent, DEFAULT_CONSENT_CATEGORIES,
5
+ CONSENT_KEY, CONSENT_ESSENTIAL, CONSENT_ANALYTICS, CONSENT_MARKETING,
6
+ consentBootstrapScript,
7
+ } from '@owlmeans/web-consent'
8
+ export type {
9
+ ConsentCategory, ConsentOptions, ConsentRecord, ConsentSignal, ConsentState,
10
+ CookieConsentProps, CookiePolicyProps,
11
+ } from '@owlmeans/web-consent'
package/src/context.ts CHANGED
@@ -2,6 +2,7 @@ import { appendFlowService } from '@owlmeans/web-flow'
2
2
  import type { AppConfig , AppContext } from './types.js'
3
3
  import { makeContext as makeClientContext, useContext as useCtx } from '@owlmeans/web-client'
4
4
  import { apiConfigMiddleware } from '@owlmeans/api-config-client'
5
+ import { appendLoginScreen } from './components/login/append.js'
5
6
 
6
7
  export const makeContext = <C extends AppConfig, T extends AppContext<C>>(cfg: C): T => {
7
8
  const context = makeClientContext(cfg) as T
@@ -11,6 +12,10 @@ export const makeContext = <C extends AppConfig, T extends AppContext<C>>(cfg: C
11
12
  appendFlowService<C, T>(context)
12
13
  context.flow = () => context.service('flow')
13
14
 
15
+ // Every app on this package gets the shadcn sign-in screen with no wiring of its own; one that
16
+ // wants its logo on it calls `appendLoginScreen` again, which is idempotent.
17
+ appendLoginScreen<C, T>(context)
18
+
14
19
  return context
15
20
  }
16
21
 
@@ -0,0 +1,43 @@
1
+ import { createServer } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, resolve } from 'node:path'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+
9
+ let url: string | null = null
10
+
11
+ /**
12
+ * Boot a Vite dev server over `tests/harness/` — a real HTTP origin, because the OwlMeans
13
+ * router drives the History API and a `data:` URL has no history to drive.
14
+ *
15
+ * `@` resolves to the package's own dev-only primitive copy, exactly as a consuming app's
16
+ * bundler resolves it to theirs. React is deduped so hooks cross the workspace links.
17
+ */
18
+ export const getHarnessUrl = async (): Promise<string> => {
19
+ if (url != null) return url
20
+ const server = await createServer({
21
+ configFile: false,
22
+ root: resolve(here, './harness'),
23
+ plugins: [react(), tailwindcss()],
24
+ resolve: {
25
+ alias: { '@': resolve(here, '../src/@') },
26
+ dedupe: ['react', 'react-dom'],
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'] },
32
+ server: { port: 0 },
33
+ logLevel: 'warn'
34
+ })
35
+ await server.listen()
36
+ const local = server.resolvedUrls?.local?.[0]
37
+ if (local == null) throw new Error('vite did not expose a local URL')
38
+ url = local
39
+
40
+ return url
41
+ }
42
+
43
+ export const HARNESS_URL = await getHarnessUrl()
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>@owlmeans/web-panel navigation</title>
6
+ </head>
7
+ <body>
8
+ <div id="root"></div>
9
+ <script type="module" src="/mount.tsx"></script>
10
+ </body>
11
+ </html>