@boost33/ui 0.8.0-next.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +616 -543
- package/README.md +49 -0
- package/dist/buttons.module.css.js +4 -4
- package/dist/components/AppShell/AppShell.d.ts +20 -6
- package/dist/components/AppShell/AppShell.js +52 -50
- package/dist/components/AppShell/AppShell.js.map +1 -1
- package/dist/components/AppSwitcher/AppSwitcher.d.ts +6 -0
- package/dist/components/AppSwitcher/AppSwitcher.js.map +1 -1
- package/dist/components/NavItem/NavItem.d.ts +18 -1
- package/dist/components/NavItem/NavItem.js +17 -13
- package/dist/components/NavItem/NavItem.js.map +1 -1
- package/dist/components/NotificationButton/NotificationButton.d.ts +26 -2
- package/dist/components/NotificationButton/NotificationButton.js +27 -15
- package/dist/components/NotificationButton/NotificationButton.js.map +1 -1
- package/dist/components/NotificationButton/labels.d.ts +19 -2
- package/dist/components/NotificationButton/labels.js +11 -9
- package/dist/components/NotificationButton/labels.js.map +1 -1
- package/dist/components/WalletChip/WalletChip.d.ts +6 -0
- package/dist/components/WalletChip/WalletChip.js.map +1 -1
- package/dist/components/WalletChip/format.d.ts +2 -2
- package/dist/components/WalletChip/format.js +23 -20
- package/dist/components/WalletChip/format.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,55 @@ Watch for real test output in the log, not the agent's summary of it.
|
|
|
45
45
|
The Wallet migrates first, Score second. **No 1.0 until both render from this
|
|
46
46
|
package** — until then it has one consumer, which is a fork with a version number.
|
|
47
47
|
|
|
48
|
+
### Fonts: map the hooks on `:root`, not `body`
|
|
49
|
+
|
|
50
|
+
The kit's faces read three hooks, `--boost33-font-display`, `--boost33-font-sans`
|
|
51
|
+
and `--boost33-font-mono`, and `tokens.css` resolves them **on `:root`**
|
|
52
|
+
(`--font-sans: var(--boost33-font-sans, Inter), …`). A custom property resolves
|
|
53
|
+
where it is declared, so a hook set only on `body` never reaches the kit, and
|
|
54
|
+
the kit silently falls back to Inter, Outfit and Geist Mono.
|
|
55
|
+
|
|
56
|
+
With `next/font`, put each font's `.variable` class on `<html>`, not `<body>`,
|
|
57
|
+
and map the hooks on `:root`:
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
:root {
|
|
61
|
+
--boost33-font-display: var(--font-outfit);
|
|
62
|
+
--boost33-font-sans: var(--font-inter);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To check it, inspect any kit element (a nav row) and read its computed
|
|
67
|
+
`font-family`. One false alarm: on a dev run where Google Fonts cannot be
|
|
68
|
+
reached, `next/font` substitutes its own `'Inter Fallback'`, and the dev log says
|
|
69
|
+
``Failed to download `Inter` ``. That is the network, not the mapping (found by
|
|
70
|
+
Outbound).
|
|
71
|
+
|
|
72
|
+
### Server Components: never pass a function to the kit's client components
|
|
73
|
+
|
|
74
|
+
Three modules in this package are client components (`'use client'`):
|
|
75
|
+
**`AppSwitcher`**, **`WalletChip`** and the internal `NavHeightBudgetWarning`.
|
|
76
|
+
Everything else -- `AppShell`, `TopBar`, `Sidebar`, `NavItem`, `NavSection`,
|
|
77
|
+
`NotificationButton`, `PageHeader`, `Wordmark` and the marks -- renders on the
|
|
78
|
+
server.
|
|
79
|
+
|
|
80
|
+
A Server Component cannot pass a function or a component to a client component:
|
|
81
|
+
props cross that boundary serialised, and Next refuses anything that is not
|
|
82
|
+
plain data. So **from a Server Component, never pass `linkComponent`,
|
|
83
|
+
`onTopUp`, `onOpenChange` or any other function to `AppSwitcher` or
|
|
84
|
+
`WalletChip`.** Next throws ("Functions cannot be passed directly to Client
|
|
85
|
+
Components") and every signed-in render returns a 500 (found by Passport).
|
|
86
|
+
`renderToStaticMarkup` tests do not catch it: they render one tree with no
|
|
87
|
+
boundary.
|
|
88
|
+
|
|
89
|
+
Build the two instead inside one small `'use client'` file of your own, which
|
|
90
|
+
imports your router's `Link` and renders `<AppSwitcher linkComponent={Link} …/>`
|
|
91
|
+
and `<WalletChip onTopUp={…} …/>`, and pass that file's components into
|
|
92
|
+
AppShell's `switcher` and `wallet` slots. Serialisable props (`products`,
|
|
93
|
+
`labels`, `locale`, the balances) can still come from the server through it.
|
|
94
|
+
`AppShell` and `NavItem` take `linkComponent` safely: they are server modules,
|
|
95
|
+
so the function never crosses a boundary.
|
|
96
|
+
|
|
48
97
|
## The launch screen
|
|
49
98
|
|
|
50
99
|
`LaunchLoader` is presentational. The page that renders it owns the timing and
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
const t = "
|
|
1
|
+
const t = "_primary_1nkom_26", o = "_quiet_1nkom_84", r = {
|
|
2
2
|
primary: t,
|
|
3
|
-
quiet:
|
|
3
|
+
quiet: o
|
|
4
4
|
};
|
|
5
5
|
export {
|
|
6
|
-
|
|
6
|
+
r as default,
|
|
7
7
|
t as primary,
|
|
8
|
-
|
|
8
|
+
o as quiet
|
|
9
9
|
};
|
|
10
10
|
//# sourceMappingURL=buttons.module.css.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ElementType, ReactNode } from 'react';
|
|
2
|
+
import { NavItemAttrs } from '../NavItem/NavItem';
|
|
2
3
|
import { TopBarProps } from '../TopBar/TopBar';
|
|
3
4
|
/** One nav row, described as data. The consumer never hands the shell markup. */
|
|
4
5
|
export interface AppShellNavItem {
|
|
@@ -14,20 +15,23 @@ export interface AppShellNavItem {
|
|
|
14
15
|
countUrgent?: boolean;
|
|
15
16
|
/** A trailing attention dot; the string is what a screen reader hears. Ignored when `count` is set. */
|
|
16
17
|
dot?: string;
|
|
18
|
+
/** What a screen reader hears for `count`, e.g. "3 to open". See `NavItem.countLabel`. */
|
|
19
|
+
countLabel?: string;
|
|
20
|
+
/** `data-*` attributes for the row, e.g. a product tour's `data-tour`. See `NavItem.attrs`. */
|
|
21
|
+
attrs?: NavItemAttrs;
|
|
17
22
|
/** Where the row goes. A nav row's destination is data; the shell renders it as a link. */
|
|
18
23
|
href?: string;
|
|
19
24
|
}
|
|
20
|
-
/** One nav section: an optional header over a run of rows. */
|
|
21
|
-
/**
|
|
22
|
-
* The product's main action, drawn as a button at the foot of the sidebar,
|
|
23
|
-
* above the footer's divider (B33-1713, board 20's "New scan"). Data, like a
|
|
24
|
-
* nav row: the shell renders it, in the primary tier's ink tone (ADR-0016).
|
|
25
|
-
*/
|
|
26
25
|
/**
|
|
27
26
|
* A named width for `AppShell.chromeFrom`, with Tailwind v4's default values:
|
|
28
27
|
* `md` 48rem (768px), `lg` 64rem (1024px), `xl` 80rem (1280px).
|
|
29
28
|
*/
|
|
30
29
|
export type AppShellBreakpoint = 'md' | 'lg' | 'xl';
|
|
30
|
+
/**
|
|
31
|
+
* The product's main action, drawn as a button at the foot of the sidebar,
|
|
32
|
+
* above the footer's divider (B33-1713, board 20's "New scan"). Data, like a
|
|
33
|
+
* nav row: the shell renders it, in the primary tier's ink tone (ADR-0016).
|
|
34
|
+
*/
|
|
31
35
|
export interface AppShellAction {
|
|
32
36
|
/** The button's words, in the consumer's language -- "New scan". */
|
|
33
37
|
label: string;
|
|
@@ -37,7 +41,17 @@ export interface AppShellAction {
|
|
|
37
41
|
href?: string;
|
|
38
42
|
/** What pressing it does. Renders a `<button>`. */
|
|
39
43
|
onClick?: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* The action's own page is the current one -- "New scan" while on the new
|
|
46
|
+
* scan screen (0.8.0-next.5, Off Market). It is not a nav row, so nothing
|
|
47
|
+
* else in the sidebar says "you are here" there. The link form carries
|
|
48
|
+
* `aria-current="page"`, and both forms take the tier's pressed ground
|
|
49
|
+
* (`--color-neutral-800`, the primary ink tone's hover) so it reads as
|
|
50
|
+
* already chosen.
|
|
51
|
+
*/
|
|
52
|
+
active?: boolean;
|
|
40
53
|
}
|
|
54
|
+
/** One nav section: an optional header over a run of rows. */
|
|
41
55
|
export interface AppShellNavSection {
|
|
42
56
|
/** The section header. Omit for a headerless section (the nav's first section has none). On the rail every header drops. */
|
|
43
57
|
label?: string;
|
|
@@ -1,84 +1,86 @@
|
|
|
1
|
-
import { jsxs as
|
|
1
|
+
import { jsxs as i, jsx as r, Fragment as b } from "react/jsx-runtime";
|
|
2
2
|
import { Sidebar as A } from "../Sidebar/Sidebar.js";
|
|
3
|
-
import { NavSection as
|
|
4
|
-
import { NavItem as
|
|
3
|
+
import { NavSection as j } from "../NavSection/NavSection.js";
|
|
4
|
+
import { NavItem as C } from "../NavItem/NavItem.js";
|
|
5
5
|
import { TopBar as U } from "../TopBar/TopBar.js";
|
|
6
6
|
import { Wordmark as $ } from "../Wordmark/Wordmark.js";
|
|
7
7
|
import B from "../../buttons.module.css.js";
|
|
8
|
-
import
|
|
8
|
+
import e from "./AppShell.module.css.js";
|
|
9
9
|
function F({
|
|
10
|
-
label:
|
|
11
|
-
appName:
|
|
12
|
-
navLabel:
|
|
13
|
-
nav:
|
|
14
|
-
topBar:
|
|
15
|
-
mark:
|
|
16
|
-
hubHref:
|
|
17
|
-
org:
|
|
10
|
+
label: a,
|
|
11
|
+
appName: n,
|
|
12
|
+
navLabel: u,
|
|
13
|
+
nav: c,
|
|
14
|
+
topBar: d,
|
|
15
|
+
mark: o,
|
|
16
|
+
hubHref: s,
|
|
17
|
+
org: m,
|
|
18
18
|
switcher: S,
|
|
19
19
|
wallet: k,
|
|
20
20
|
action: y,
|
|
21
|
-
footer:
|
|
22
|
-
collapsed:
|
|
21
|
+
footer: v,
|
|
22
|
+
collapsed: t = !1,
|
|
23
23
|
chromeFrom: f,
|
|
24
|
-
phoneChrome:
|
|
24
|
+
phoneChrome: p,
|
|
25
25
|
linkComponent: h,
|
|
26
26
|
children: g
|
|
27
27
|
}) {
|
|
28
|
-
return /* @__PURE__ */
|
|
29
|
-
f != null &&
|
|
28
|
+
return /* @__PURE__ */ i("div", { className: e.shell, "data-chrome-from": f, children: [
|
|
29
|
+
f != null && p != null && /* @__PURE__ */ r("div", { className: e.phoneChrome, children: p }),
|
|
30
30
|
/* @__PURE__ */ r(
|
|
31
31
|
U,
|
|
32
32
|
{
|
|
33
|
-
...
|
|
34
|
-
start: /* @__PURE__ */
|
|
35
|
-
/* @__PURE__ */ r($, { mark:
|
|
36
|
-
|
|
33
|
+
...d,
|
|
34
|
+
start: /* @__PURE__ */ i(b, { children: [
|
|
35
|
+
/* @__PURE__ */ r($, { mark: o, collapsed: !0, hubHref: s, linkComponent: h }),
|
|
36
|
+
m
|
|
37
37
|
] }),
|
|
38
38
|
switcher: S,
|
|
39
39
|
tokens: k
|
|
40
40
|
}
|
|
41
41
|
),
|
|
42
|
-
/* @__PURE__ */
|
|
43
|
-
/* @__PURE__ */
|
|
44
|
-
|
|
45
|
-
/* @__PURE__ */ r("nav", { "aria-label":
|
|
42
|
+
/* @__PURE__ */ i("div", { className: e.body, children: [
|
|
43
|
+
/* @__PURE__ */ i(A, { landmarkLabel: a, collapsed: t, children: [
|
|
44
|
+
n != null && !t && /* @__PURE__ */ r("p", { className: e.appName, children: n }),
|
|
45
|
+
/* @__PURE__ */ r("nav", { "aria-label": u ?? a, className: e.nav, children: c.map((N, L) => (
|
|
46
46
|
// No room for a header at 56px, so on the rail every section renders
|
|
47
47
|
// headerless -- its rows collapse to their icons and line up.
|
|
48
|
-
/* @__PURE__ */ r(
|
|
49
|
-
|
|
48
|
+
/* @__PURE__ */ r(j, { label: t ? void 0 : N.label, children: N.items.map((l, x) => /* @__PURE__ */ r(
|
|
49
|
+
C,
|
|
50
50
|
{
|
|
51
|
-
icon:
|
|
52
|
-
active:
|
|
53
|
-
collapsed:
|
|
54
|
-
count:
|
|
55
|
-
countUrgent:
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
icon: l.icon,
|
|
52
|
+
active: l.active,
|
|
53
|
+
collapsed: t,
|
|
54
|
+
count: l.count,
|
|
55
|
+
countUrgent: l.countUrgent,
|
|
56
|
+
countLabel: l.countLabel,
|
|
57
|
+
dot: l.dot,
|
|
58
|
+
attrs: l.attrs,
|
|
59
|
+
href: l.href,
|
|
58
60
|
linkComponent: h,
|
|
59
|
-
children:
|
|
61
|
+
children: l.label
|
|
60
62
|
},
|
|
61
|
-
|
|
62
|
-
)) },
|
|
63
|
+
x
|
|
64
|
+
)) }, L)
|
|
63
65
|
)) }),
|
|
64
|
-
/* @__PURE__ */ r(I, { action: y, collapsed:
|
|
65
|
-
|
|
66
|
+
/* @__PURE__ */ r(I, { action: y, collapsed: t, linkComponent: h }),
|
|
67
|
+
v != null && /* @__PURE__ */ r("div", { className: e.foot, children: v })
|
|
66
68
|
] }),
|
|
67
|
-
/* @__PURE__ */ r("div", { className:
|
|
69
|
+
/* @__PURE__ */ r("div", { className: e.main, children: /* @__PURE__ */ r("main", { className: e.content, children: g }) })
|
|
68
70
|
] })
|
|
69
71
|
] });
|
|
70
72
|
}
|
|
71
|
-
function I({ action:
|
|
72
|
-
if (
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
/* @__PURE__ */ r("span", { className:
|
|
76
|
-
] }),
|
|
77
|
-
if (
|
|
78
|
-
const m =
|
|
79
|
-
return /* @__PURE__ */ r("div", { className:
|
|
73
|
+
function I({ action: a, collapsed: n, linkComponent: u }) {
|
|
74
|
+
if (a === void 0 || a.href === void 0 && a.onClick === void 0 || n && a.icon == null) return null;
|
|
75
|
+
const c = /* @__PURE__ */ i(b, { children: [
|
|
76
|
+
a.icon != null && /* @__PURE__ */ r("span", { "aria-hidden": "true", children: a.icon }),
|
|
77
|
+
/* @__PURE__ */ r("span", { className: n ? e.srOnly : void 0, children: a.label })
|
|
78
|
+
] }), d = `${B.primary} ${e.action}`, o = n ? "true" : void 0, s = a.active ? "true" : void 0;
|
|
79
|
+
if (a.href !== void 0) {
|
|
80
|
+
const m = u ?? "a";
|
|
81
|
+
return /* @__PURE__ */ r("div", { className: e.actionSlot, children: /* @__PURE__ */ r(m, { className: d, href: a.href, "aria-current": a.active ? "page" : void 0, "data-collapsed": o, "data-active": s, children: c }) });
|
|
80
82
|
}
|
|
81
|
-
return /* @__PURE__ */ r("div", { className:
|
|
83
|
+
return /* @__PURE__ */ r("div", { className: e.actionSlot, children: /* @__PURE__ */ r("button", { type: "button", className: d, onClick: a.onClick, "data-collapsed": o, "data-active": s, children: c }) });
|
|
82
84
|
}
|
|
83
85
|
export {
|
|
84
86
|
F as AppShell
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppShell.js","sources":["../../../src/components/AppShell/AppShell.tsx"],"sourcesContent":["import type { ElementType, ReactNode } from 'react'\nimport { Sidebar } from '../Sidebar/Sidebar'\nimport { NavSection } from '../NavSection/NavSection'\nimport { NavItem } from '../NavItem/NavItem'\nimport { TopBar, type TopBarProps } from '../TopBar/TopBar'\nimport { Wordmark } from '../Wordmark/Wordmark'\nimport buttons from '../../buttons.module.css'\nimport styles from './AppShell.module.css'\n\n/** One nav row, described as data. The consumer never hands the shell markup. */\nexport interface AppShellNavItem {\n /** The row's name. Truncates rather than wrapping; on the rail it collapses to the accessibility tree. */\n label: string\n /** The leading icon. Decorative -- the label carries the name. On the rail it is all that shows. */\n icon?: ReactNode\n /** Marks this row as the current page. Sets `aria-current=\"page\"`. */\n active?: boolean\n /** Trailing-slot count. Omit to leave the slot empty; it still reserves its width so counts line up. */\n count?: number\n /** Draws `count` as the filled \"needs you\" pill instead of muted text. */\n countUrgent?: boolean\n /** A trailing attention dot; the string is what a screen reader hears. Ignored when `count` is set. */\n dot?: string\n /** Where the row goes. A nav row's destination is data; the shell renders it as a link. */\n href?: string\n}\n\n/** One nav section: an optional header over a run of rows. */\n/**\n * The product's main action, drawn as a button at the foot of the sidebar,\n * above the footer's divider (B33-1713, board 20's \"New scan\"). Data, like a\n * nav row: the shell renders it, in the primary tier's ink tone (ADR-0016).\n */\n/**\n * A named width for `AppShell.chromeFrom`, with Tailwind v4's default values:\n * `md` 48rem (768px), `lg` 64rem (1024px), `xl` 80rem (1280px).\n */\nexport type AppShellBreakpoint = 'md' | 'lg' | 'xl'\n\nexport interface AppShellAction {\n /** The button's words, in the consumer's language -- \"New scan\". */\n label: string\n /** A leading icon, decorative. On the 56px rail it is all that shows. */\n icon?: ReactNode\n /** Where it goes. Renders a link through `linkComponent`. Wins over `onClick`. */\n href?: string\n /** What pressing it does. Renders a `<button>`. */\n onClick?: () => void\n}\n\nexport interface AppShellNavSection {\n /** The section header. Omit for a headerless section (the nav's first section has none). On the rail every header drops. */\n label?: string\n /** The section's rows, as data. */\n items: AppShellNavItem[]\n}\n\nexport interface AppShellProps {\n /**\n * The accessible name of the sidebar landmark. A display string in the\n * consumer's own language -- usually the product's name -- that reaches\n * `aria-label` on the `<aside>` and is never rendered as text.\n *\n * **It is an accessible name, not product-awareness.** This prop was called\n * `product` and its comment claimed it was \"the whole of the shell's\n * product-awareness\" (B33-1272). Nothing branches on it, nothing is looked up\n * with it, and no artwork or copy changes because of it -- so the comment\n * described a mechanism that did not exist and sent the next reader looking\n * for the rest of it. The shell is product-aware through `nav`, `switcher`,\n * `wallet` and `footer`, which are the product's own data.\n *\n * **Not the Identity slug.** `AppSwitcher.currentSlug` takes that\n * (`\"wallet\"`, `\"boostscore\"`); this takes what a person would read\n * (\"Wallet\"). The shell carries two names for one product and nothing in the\n * types connects them, so check which kind of string each prop wants.\n */\n label: string\n /**\n * The accessible name of the navigation landmark inside the sidebar.\n * **Defaults to `label`, which is today's behaviour and not the fix.**\n *\n * Pass a distinct string here. Without one the `<aside>` and the `<nav>`\n * nested inside it carry the same accessible name, and a screen-reader user\n * moving by landmark hears \"Wallet complementary\" then \"Wallet navigation\" --\n * the name doing none of the distinguishing that is the one job a landmark\n * label has (B33-1272). Something like \"Wallet sections\" or \"Wallet pages\",\n * in your language, resolves it.\n *\n * **The package cannot default this to anything better, which is why the\n * fallback is the default.** A sensible default would have to compose a word\n * -- \"navigation\", \"sections\" -- and that word is user-facing text a screen\n * reader reads aloud, so it would have to be in the consumer's language. Both\n * consumers default to French. This package hardcodes no user-facing English\n * (`tests/no-hardcoded-strings.test.ts`), and a composed English default would\n * ship an untranslatable word into assistive output for every consumer that\n * never noticed the prop. Repeating `label` is at least a name the consumer\n * chose and wrote in their own language. So the duplicate name is only fully\n * resolved by a consumer passing this.\n */\n navLabel?: string\n /**\n * The product's name, drawn as plain text at the top of the sidebar (B33-1711).\n * The product name only -- \"Boost Score\", in the consumer's language -- with\n * no icon beside it: the product mark belongs in the app switcher, not next to\n * the name. Omit it and nothing is drawn there.\n *\n * It is visible text and nothing else. It names no landmark (that is `label`)\n * and nothing branches on it. Often the same string as `label`; it is a\n * separate prop because one is read aloud as a landmark's name and the other\n * is read on screen, and a consumer may want them to differ.\n *\n * Hidden on the 56px rail, where there is no room for a word.\n */\n appName?: string\n /** The navigation, as data. No product hardcodes rows; the shell composes them. */\n nav: AppShellNavSection[]\n /**\n * The top bar's own content: its `actions` on the right (the notification\n * button and the avatar) and its `labels`. The shell fills the rest of the bar\n * itself -- the mark and `org` on the left, `switcher` and `wallet` in the\n * centre -- so those are props of the shell, not of this object.\n *\n * `breadcrumb` is still accepted and no longer drawn (B33-1712): the redesign\n * puts the mark and the organisation where it was. A sidebar toggle kept\n * inside it goes in `topBar.sidebarToggle` instead, at the far left of the\n * bar, or the rail loses its toggle.\n */\n topBar?: Omit<TopBarProps, 'start' | 'switcher' | 'tokens'>\n /**\n * The Boost33 mark, at the left of the top bar (B33-1712; it was the\n * sidebar's wordmark lockup until 0.8.0). Passed in, the way Wordmark takes\n * it. Pass it unlinked -- `hubHref` links it. The word \"Boost33\" is not drawn\n * beside it; it stays as the link's accessible name.\n */\n mark?: ReactNode\n /**\n * Where the hub lives. Set it and the mark at the left of the top bar becomes\n * a link home, rendered through `linkComponent` (B33-1699). The consumer\n * supplies the URL -- Identity owns where the hub is and it differs per\n * environment, so the package never hardcodes it. See `Wordmark.hubHref`.\n */\n hubHref?: string\n /**\n * The organisation, beside the mark at the left of the top bar (B33-1712,\n * board 20: \"AC Acme Coaching ⌄\"). A slot -- the product's own pill. The\n * package draws no organisation menu: that is AccountMenu (B33-493), which has\n * not passed its gate.\n */\n org?: ReactNode\n /**\n * The app switcher, at the centre of the top bar after its \"Switch Tools →\"\n * label (B33-1712; it sat under the wordmark in the sidebar until 0.8.0). A\n * slot: pass `AppSwitcher`. The registry it draws is served by Identity\n * (ADR-0003), not owned here. It no longer has a rail form to render -- the\n * top bar does not collapse.\n */\n switcher?: ReactNode\n /**\n * The token chip, at the centre of the top bar after the switcher and its\n * \"Check Your Tokens →\" label (B33-1712; it was pinned above the sidebar's\n * footer until 0.8.0). A slot: pass `WalletChip` with `placement=\"topbar\"`.\n * The balance comes from `@boost33/ledger-client`, never read here.\n */\n wallet?: ReactNode\n /**\n * The product's main action, at the foot of the sidebar directly above the\n * footer's divider (B33-1713). Omit it and nothing is drawn. Given neither\n * `href` nor `onClick`, it is not drawn either: a button that does nothing is\n * a control that lies (B33-1270). On the rail it shows its icon alone, or\n * nothing if it has none.\n */\n action?: AppShellAction\n /**\n * The sidebar footer, at the foot of the sidebar, under a divider -- Settings\n * and Help on board 20. A slot -- the product's own.\n */\n footer?: ReactNode\n /**\n * The sidebar collapsed to the 56px rail. A state of the shell: the sidebar\n * narrows to icon-only nav and every piece it owns collapses off this one\n * flag. The footer slot renders its own collapsed form. The switcher and the\n * token chip live in the top bar since 0.8.0 (B33-1712) and do not collapse;\n * the rail has no redesign of its own and keeps working as it did.\n */\n collapsed?: boolean\n /**\n * The width from which the shell draws its own chrome -- the top bar and the\n * sidebar. Below it the shell hides both and the page body takes the full\n * width, so a product renders its own phone header and navigation around the\n * shell. Omit it and the chrome is drawn at every width, as before.\n *\n * Not a mobile shell: nothing is redesigned below the breakpoint, the shell\n * only gets out of the way. AppShell has no phone layout of its own, and the\n * 0.8 top bar runs off a 390px screen.\n *\n * Done in the shell's own CSS, `@media (width < …)`, so the first paint is\n * already right and a server-rendered page does not flash the desktop chrome\n * on a phone, which a `matchMedia` switch in the product does. That is also\n * why it is a name and not a number: a media query cannot read a value passed\n * at runtime. The names and widths are Tailwind v4's defaults, so `lg` here\n * and `lg:` in a product's own markup flip at the same pixel:\n * `md` 48rem (768px), `lg` 64rem (1024px), `xl` 80rem (1280px).\n *\n * Below the breakpoint the page scrolls the document instead of the shell's\n * content box, so a product header above the shell scrolls with the page. The\n * `<main>` landmark stays; the hidden chrome is `display: none`, so it leaves\n * the accessibility tree too.\n */\n chromeFrom?: AppShellBreakpoint\n /**\n * The product's own phone chrome -- its header, its navigation -- drawn by\n * the shell above the page body and shown only below `chromeFrom`, the\n * inverse of the top bar and sidebar. The shell's own CSS does both halves,\n * so a product passes its phone header here and writes no selector and no\n * `lg:hidden` of its own.\n *\n * Above `<main>`, not inside it: \"jump to main\" skips the chrome on a phone\n * the way it skips the top bar and sidebar on a desktop. A slot -- the\n * product's own markup; the shell draws no phone design.\n *\n * Ignored without `chromeFrom`: with no breakpoint there is no width at which\n * it would show.\n */\n phoneChrome?: ReactNode\n /**\n * The element every linked nav row renders through. Defaults to a plain `a`;\n * a consumer whose router owns navigation passes its own here and the shell\n * threads it to every row of `nav`, and to the wordmark when `hubHref` links\n * it, so the whole nav goes client-side with the locale prefix intact rather\n * than full-page-reloading (B33-847). This is the shell's one nod to how a\n * consumer routes -- it renders the element, never knowing what it is. See\n * docs/adr/0011.\n *\n * **It reaches the rows of `nav` and the wordmark, and nothing else.** The\n * slots (`switcher`, `wallet`, `footer`, `topBar.actions`) are `ReactNode`s\n * the consumer has already created, so the shell cannot put a prop on what is\n * inside one. A\n * consumer composing a link-bearing component into a slot -- `AppSwitcher`,\n * `AccountMenu`, `NotificationCenter` -- passes `linkComponent` to that\n * component as well, or its rows stay bare anchors and reload the page. The\n * Wallet shipped exactly that defect (B33-1281) by reading this prop as the\n * single place, which is the 2026-10-06 correction on docs/adr/0011.\n */\n linkComponent?: ElementType\n /**\n * The page body. Sits on white, below the top bar, and the shell renders it\n * inside the page's `<main>` -- so a consumer adds no wrapper of its own.\n *\n * **The page's title goes here too.** A screen with a title renders\n * `PageHeader` as the first thing in its own body; a screen with none renders\n * nothing and the top bar's hairline sits straight on the content, which is\n * what the Wallet's overview does and what its 9 Sep baseline shows. The\n * shell used to take a `pageHeader` prop and draw the header itself, above\n * this; B33-1267 removed it, because no consumer ever passed it and Score\n * forbids it by test (the page below owns the sole `h1`).\n *\n * **Everything here scrolls.** This box is the shell's one scrolling element,\n * so the sidebar and top bar stay pinned and the page header goes up with the\n * body. The removed prop sat outside the scroll box and pinned the header\n * instead -- the one real difference between the two spellings, and the\n * reason the choice was worth collapsing to one rather than leaving to a\n * consumer to discover on a long page.\n */\n children: ReactNode\n}\n\n// The shell every authenticated Boost33 screen renders inside. It composes the\n// pieces built in the earlier tickets -- sidebar, wordmark, switcher, nav,\n// wallet chip, top bar -- into one thing a product imports instead of building a\n// sidebar. Everything that differs between products is the product's own data --\n// `nav`, `switcher`, `wallet`, `footer` -- and nothing above the nav (the Boost33\n// wordmark and brand) changes at all. `label` and `navLabel` name the two\n// landmarks and decide nothing; B33-1272 corrected the claim that `label` (then\n// `product`) was the whole of the shell's product-awareness.\n//\n// `PageHeader` is deliberately not in that list. The shell drew it from a\n// `pageHeader` prop until B33-1267; a page's title is the page's, rendered at\n// the top of `children`, which is what both consumers already did.\nexport function AppShell({\n label,\n appName,\n navLabel,\n nav,\n topBar,\n mark,\n hubHref,\n org,\n switcher,\n wallet,\n action,\n footer,\n collapsed = false,\n chromeFrom,\n phoneChrome,\n linkComponent,\n children,\n}: AppShellProps) {\n return (\n <div className={styles.shell} data-chrome-from={chromeFrom}>\n {chromeFrom != null && phoneChrome != null && <div className={styles.phoneChrome}>{phoneChrome}</div>}\n {/* The bar across the full width, above the sidebar and the product's\n column alike (board 20). First in the DOM as it is first on screen, so\n the tab order runs mark, switcher, token chip, bell, avatar, then the\n nav, then the page. */}\n <TopBar\n {...topBar}\n start={\n <>\n {/* The mark alone, as board 20 draws it: Wordmark's collapsed form\n keeps \"Boost33\" in the accessibility tree, so the link home is\n named, and draws only the mark. */}\n <Wordmark mark={mark} collapsed hubHref={hubHref} linkComponent={linkComponent} />\n {org}\n </>\n }\n switcher={switcher}\n tokens={wallet}\n />\n <div className={styles.body}>\n <Sidebar landmarkLabel={label} collapsed={collapsed}>\n {appName != null && !collapsed && <p className={styles.appName}>{appName}</p>}\n {/* Falls back to the sidebar's name rather than composing one. The\n composed string would be a word a screen reader reads aloud, so it\n would have to be in the consumer's language, and this package\n hardcodes no user-facing English. Falling back repeats a name the\n consumer wrote; it does not invent one. See `navLabel`. */}\n <nav aria-label={navLabel ?? label} className={styles.nav}>\n {nav.map((section, sectionIndex) => (\n // No room for a header at 56px, so on the rail every section renders\n // headerless -- its rows collapse to their icons and line up.\n <NavSection key={sectionIndex} label={collapsed ? undefined : section.label}>\n {section.items.map((item, itemIndex) => (\n <NavItem\n key={itemIndex}\n icon={item.icon}\n active={item.active}\n collapsed={collapsed}\n count={item.count}\n countUrgent={item.countUrgent}\n dot={item.dot}\n href={item.href}\n linkComponent={linkComponent}\n >\n {item.label}\n </NavItem>\n ))}\n </NavSection>\n ))}\n </nav>\n <SidebarAction action={action} collapsed={collapsed} linkComponent={linkComponent} />\n {footer != null && <div className={styles.foot}>{footer}</div>}\n </Sidebar>\n {/* `.main` is the product's column, under the bar and beside the\n sidebar. The landmark is the page body inside it: jumping to main\n has to skip the chrome. */}\n <div className={styles.main}>\n {/* The page's main landmark, and the shell's to supply. This was a plain\n div, so no route of any consumer had one unless that consumer\n remembered a wrapper of its own -- the Wallet did\n (`boostledger/src/app/(wallet)/layout.tsx`), and three products each\n having to remember it is three chances to ship without one. What the\n absence costs a screen-reader user is the first move they make on an\n unfamiliar screen: jump to main and start reading. Without one they\n land at the top of the document and walk the mark, the switcher, the\n token chip, the bell, the avatar, every nav row, the footer links and\n the sidebar toggle before the first word of the page -- on\n every navigation, because the shell is a layout and does not remount.\n\n The landmark is the body alone, which is the half that matters: a\n <main> drawn around the whole shell would satisfy any \"has a\n landmark\" check and buy the reader nothing, because jumping to it\n would land them back on the top bar.\n\n A consumer that already wraps `children` now has two, which is worse\n than none -- B33-1265 drops the Wallet's as it takes this version,\n and the CHANGELOG entry tells both consumers to.\n\n An element swap and nothing else: `main` and `div` are both\n `display: block`, the class and every declaration behind it are\n untouched, and all 27 gated screenshot states came back at the ratios\n they held before. */}\n <main className={styles.content}>{children}</main>\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * The sidebar's action button (B33-1713). The primary tier in its ink tone,\n * full width -- board 20's near-black \"New scan\", not the brand orange, which\n * the package keeps for the money moment (ADR-0016). Pushed to the foot of the\n * column with the footer: the nav above takes the slack.\n */\nfunction SidebarAction({ action, collapsed, linkComponent }: { action: AppShellAction | undefined; collapsed: boolean; linkComponent: ElementType | undefined }) {\n if (action === undefined || (action.href === undefined && action.onClick === undefined)) return null\n if (collapsed && action.icon == null) return null\n\n const content = (\n <>\n {action.icon != null && <span aria-hidden=\"true\">{action.icon}</span>}\n {/* On the rail the label leaves the screen and stays the button's name. */}\n <span className={collapsed ? styles.srOnly : undefined}>{action.label}</span>\n </>\n )\n const className = `${buttons.primary} ${styles.action}`\n const dataCollapsed = collapsed ? 'true' : undefined\n\n if (action.href !== undefined) {\n const Link = linkComponent ?? 'a'\n return (\n <div className={styles.actionSlot}>\n <Link className={className} href={action.href} data-collapsed={dataCollapsed}>\n {content}\n </Link>\n </div>\n )\n }\n return (\n <div className={styles.actionSlot}>\n <button type=\"button\" className={className} onClick={action.onClick} data-collapsed={dataCollapsed}>\n {content}\n </button>\n </div>\n )\n}\n"],"names":["AppShell","label","appName","navLabel","nav","topBar","mark","hubHref","org","switcher","wallet","action","footer","collapsed","chromeFrom","phoneChrome","linkComponent","children","styles","jsx","TopBar","jsxs","Fragment","Wordmark","Sidebar","section","sectionIndex","NavSection","item","itemIndex","NavItem","SidebarAction","content","className","buttons","dataCollapsed","Link"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppShell.js","sources":["../../../src/components/AppShell/AppShell.tsx"],"sourcesContent":["import type { ElementType, ReactNode } from 'react'\nimport { Sidebar } from '../Sidebar/Sidebar'\nimport { NavSection } from '../NavSection/NavSection'\nimport { NavItem, type NavItemAttrs } from '../NavItem/NavItem'\nimport { TopBar, type TopBarProps } from '../TopBar/TopBar'\nimport { Wordmark } from '../Wordmark/Wordmark'\nimport buttons from '../../buttons.module.css'\nimport styles from './AppShell.module.css'\n\n/** One nav row, described as data. The consumer never hands the shell markup. */\nexport interface AppShellNavItem {\n /** The row's name. Truncates rather than wrapping; on the rail it collapses to the accessibility tree. */\n label: string\n /** The leading icon. Decorative -- the label carries the name. On the rail it is all that shows. */\n icon?: ReactNode\n /** Marks this row as the current page. Sets `aria-current=\"page\"`. */\n active?: boolean\n /** Trailing-slot count. Omit to leave the slot empty; it still reserves its width so counts line up. */\n count?: number\n /** Draws `count` as the filled \"needs you\" pill instead of muted text. */\n countUrgent?: boolean\n /** A trailing attention dot; the string is what a screen reader hears. Ignored when `count` is set. */\n dot?: string\n /** What a screen reader hears for `count`, e.g. \"3 to open\". See `NavItem.countLabel`. */\n countLabel?: string\n /** `data-*` attributes for the row, e.g. a product tour's `data-tour`. See `NavItem.attrs`. */\n attrs?: NavItemAttrs\n /** Where the row goes. A nav row's destination is data; the shell renders it as a link. */\n href?: string\n}\n\n/**\n * A named width for `AppShell.chromeFrom`, with Tailwind v4's default values:\n * `md` 48rem (768px), `lg` 64rem (1024px), `xl` 80rem (1280px).\n */\nexport type AppShellBreakpoint = 'md' | 'lg' | 'xl'\n\n/**\n * The product's main action, drawn as a button at the foot of the sidebar,\n * above the footer's divider (B33-1713, board 20's \"New scan\"). Data, like a\n * nav row: the shell renders it, in the primary tier's ink tone (ADR-0016).\n */\nexport interface AppShellAction {\n /** The button's words, in the consumer's language -- \"New scan\". */\n label: string\n /** A leading icon, decorative. On the 56px rail it is all that shows. */\n icon?: ReactNode\n /** Where it goes. Renders a link through `linkComponent`. Wins over `onClick`. */\n href?: string\n /** What pressing it does. Renders a `<button>`. */\n onClick?: () => void\n /**\n * The action's own page is the current one -- \"New scan\" while on the new\n * scan screen (0.8.0-next.5, Off Market). It is not a nav row, so nothing\n * else in the sidebar says \"you are here\" there. The link form carries\n * `aria-current=\"page\"`, and both forms take the tier's pressed ground\n * (`--color-neutral-800`, the primary ink tone's hover) so it reads as\n * already chosen.\n */\n active?: boolean\n}\n\n/** One nav section: an optional header over a run of rows. */\nexport interface AppShellNavSection {\n /** The section header. Omit for a headerless section (the nav's first section has none). On the rail every header drops. */\n label?: string\n /** The section's rows, as data. */\n items: AppShellNavItem[]\n}\n\nexport interface AppShellProps {\n /**\n * The accessible name of the sidebar landmark. A display string in the\n * consumer's own language -- usually the product's name -- that reaches\n * `aria-label` on the `<aside>` and is never rendered as text.\n *\n * **It is an accessible name, not product-awareness.** This prop was called\n * `product` and its comment claimed it was \"the whole of the shell's\n * product-awareness\" (B33-1272). Nothing branches on it, nothing is looked up\n * with it, and no artwork or copy changes because of it -- so the comment\n * described a mechanism that did not exist and sent the next reader looking\n * for the rest of it. The shell is product-aware through `nav`, `switcher`,\n * `wallet` and `footer`, which are the product's own data.\n *\n * **Not the Identity slug.** `AppSwitcher.currentSlug` takes that\n * (`\"wallet\"`, `\"boostscore\"`); this takes what a person would read\n * (\"Wallet\"). The shell carries two names for one product and nothing in the\n * types connects them, so check which kind of string each prop wants.\n */\n label: string\n /**\n * The accessible name of the navigation landmark inside the sidebar.\n * **Defaults to `label`, which is today's behaviour and not the fix.**\n *\n * Pass a distinct string here. Without one the `<aside>` and the `<nav>`\n * nested inside it carry the same accessible name, and a screen-reader user\n * moving by landmark hears \"Wallet complementary\" then \"Wallet navigation\" --\n * the name doing none of the distinguishing that is the one job a landmark\n * label has (B33-1272). Something like \"Wallet sections\" or \"Wallet pages\",\n * in your language, resolves it.\n *\n * **The package cannot default this to anything better, which is why the\n * fallback is the default.** A sensible default would have to compose a word\n * -- \"navigation\", \"sections\" -- and that word is user-facing text a screen\n * reader reads aloud, so it would have to be in the consumer's language. Both\n * consumers default to French. This package hardcodes no user-facing English\n * (`tests/no-hardcoded-strings.test.ts`), and a composed English default would\n * ship an untranslatable word into assistive output for every consumer that\n * never noticed the prop. Repeating `label` is at least a name the consumer\n * chose and wrote in their own language. So the duplicate name is only fully\n * resolved by a consumer passing this.\n */\n navLabel?: string\n /**\n * The product's name, drawn as plain text at the top of the sidebar (B33-1711).\n * The product name only -- \"Boost Score\", in the consumer's language -- with\n * no icon beside it: the product mark belongs in the app switcher, not next to\n * the name. Omit it and nothing is drawn there.\n *\n * It is visible text and nothing else. It names no landmark (that is `label`)\n * and nothing branches on it. Often the same string as `label`; it is a\n * separate prop because one is read aloud as a landmark's name and the other\n * is read on screen, and a consumer may want them to differ.\n *\n * Hidden on the 56px rail, where there is no room for a word.\n */\n appName?: string\n /** The navigation, as data. No product hardcodes rows; the shell composes them. */\n nav: AppShellNavSection[]\n /**\n * The top bar's own content: its `actions` on the right (the notification\n * button and the avatar) and its `labels`. The shell fills the rest of the bar\n * itself -- the mark and `org` on the left, `switcher` and `wallet` in the\n * centre -- so those are props of the shell, not of this object.\n *\n * `breadcrumb` is still accepted and no longer drawn (B33-1712): the redesign\n * puts the mark and the organisation where it was. A sidebar toggle kept\n * inside it goes in `topBar.sidebarToggle` instead, at the far left of the\n * bar, or the rail loses its toggle.\n */\n topBar?: Omit<TopBarProps, 'start' | 'switcher' | 'tokens'>\n /**\n * The Boost33 mark, at the left of the top bar (B33-1712; it was the\n * sidebar's wordmark lockup until 0.8.0). Passed in, the way Wordmark takes\n * it. Pass it unlinked -- `hubHref` links it. The word \"Boost33\" is not drawn\n * beside it; it stays as the link's accessible name.\n */\n mark?: ReactNode\n /**\n * Where the hub lives. Set it and the mark at the left of the top bar becomes\n * a link home, rendered through `linkComponent` (B33-1699). The consumer\n * supplies the URL -- Identity owns where the hub is and it differs per\n * environment, so the package never hardcodes it. See `Wordmark.hubHref`.\n */\n hubHref?: string\n /**\n * The organisation, beside the mark at the left of the top bar (B33-1712,\n * board 20: \"AC Acme Coaching ⌄\"). A slot -- the product's own pill. The\n * package draws no organisation menu: that is AccountMenu (B33-493), which has\n * not passed its gate.\n */\n org?: ReactNode\n /**\n * The app switcher, at the centre of the top bar after its \"Switch Tools →\"\n * label (B33-1712; it sat under the wordmark in the sidebar until 0.8.0). A\n * slot: pass `AppSwitcher`. The registry it draws is served by Identity\n * (ADR-0003), not owned here. It no longer has a rail form to render -- the\n * top bar does not collapse.\n */\n switcher?: ReactNode\n /**\n * The token chip, at the centre of the top bar after the switcher and its\n * \"Check Your Tokens →\" label (B33-1712; it was pinned above the sidebar's\n * footer until 0.8.0). A slot: pass `WalletChip` with `placement=\"topbar\"`.\n * The balance comes from `@boost33/ledger-client`, never read here.\n */\n wallet?: ReactNode\n /**\n * The product's main action, at the foot of the sidebar directly above the\n * footer's divider (B33-1713). Omit it and nothing is drawn. Given neither\n * `href` nor `onClick`, it is not drawn either: a button that does nothing is\n * a control that lies (B33-1270). On the rail it shows its icon alone, or\n * nothing if it has none.\n */\n action?: AppShellAction\n /**\n * The sidebar footer, at the foot of the sidebar, under a divider -- Settings\n * and Help on board 20. A slot -- the product's own.\n */\n footer?: ReactNode\n /**\n * The sidebar collapsed to the 56px rail. A state of the shell: the sidebar\n * narrows to icon-only nav and every piece it owns collapses off this one\n * flag. The footer slot renders its own collapsed form. The switcher and the\n * token chip live in the top bar since 0.8.0 (B33-1712) and do not collapse;\n * the rail has no redesign of its own and keeps working as it did.\n */\n collapsed?: boolean\n /**\n * The width from which the shell draws its own chrome -- the top bar and the\n * sidebar. Below it the shell hides both and the page body takes the full\n * width, so a product renders its own phone header and navigation around the\n * shell. Omit it and the chrome is drawn at every width, as before.\n *\n * Not a mobile shell: nothing is redesigned below the breakpoint, the shell\n * only gets out of the way. AppShell has no phone layout of its own, and the\n * 0.8 top bar runs off a 390px screen.\n *\n * Done in the shell's own CSS, `@media (width < …)`, so the first paint is\n * already right and a server-rendered page does not flash the desktop chrome\n * on a phone, which a `matchMedia` switch in the product does. That is also\n * why it is a name and not a number: a media query cannot read a value passed\n * at runtime. The names and widths are Tailwind v4's defaults, so `lg` here\n * and `lg:` in a product's own markup flip at the same pixel:\n * `md` 48rem (768px), `lg` 64rem (1024px), `xl` 80rem (1280px).\n *\n * Below the breakpoint the page scrolls the document instead of the shell's\n * content box, so a product header above the shell scrolls with the page. The\n * `<main>` landmark stays; the hidden chrome is `display: none`, so it leaves\n * the accessibility tree too.\n */\n chromeFrom?: AppShellBreakpoint\n /**\n * The product's own phone chrome -- its header, its navigation -- drawn by\n * the shell above the page body and shown only below `chromeFrom`, the\n * inverse of the top bar and sidebar. The shell's own CSS does both halves,\n * so a product passes its phone header here and writes no selector and no\n * `lg:hidden` of its own.\n *\n * Above `<main>`, not inside it: \"jump to main\" skips the chrome on a phone\n * the way it skips the top bar and sidebar on a desktop. A slot -- the\n * product's own markup; the shell draws no phone design.\n *\n * Ignored without `chromeFrom`: with no breakpoint there is no width at which\n * it would show.\n */\n phoneChrome?: ReactNode\n /**\n * The element every linked nav row renders through. Defaults to a plain `a`;\n * a consumer whose router owns navigation passes its own here and the shell\n * threads it to every row of `nav`, and to the wordmark when `hubHref` links\n * it, so the whole nav goes client-side with the locale prefix intact rather\n * than full-page-reloading (B33-847). This is the shell's one nod to how a\n * consumer routes -- it renders the element, never knowing what it is. See\n * docs/adr/0011.\n *\n * **It reaches the rows of `nav` and the wordmark, and nothing else.** The\n * slots (`switcher`, `wallet`, `footer`, `topBar.actions`) are `ReactNode`s\n * the consumer has already created, so the shell cannot put a prop on what is\n * inside one. A\n * consumer composing a link-bearing component into a slot -- `AppSwitcher`,\n * `AccountMenu`, `NotificationCenter` -- passes `linkComponent` to that\n * component as well, or its rows stay bare anchors and reload the page. The\n * Wallet shipped exactly that defect (B33-1281) by reading this prop as the\n * single place, which is the 2026-10-06 correction on docs/adr/0011.\n */\n linkComponent?: ElementType\n /**\n * The page body. Sits on white, below the top bar, and the shell renders it\n * inside the page's `<main>` -- so a consumer adds no wrapper of its own.\n *\n * **The page's title goes here too.** A screen with a title renders\n * `PageHeader` as the first thing in its own body; a screen with none renders\n * nothing and the top bar's hairline sits straight on the content, which is\n * what the Wallet's overview does and what its 9 Sep baseline shows. The\n * shell used to take a `pageHeader` prop and draw the header itself, above\n * this; B33-1267 removed it, because no consumer ever passed it and Score\n * forbids it by test (the page below owns the sole `h1`).\n *\n * **Everything here scrolls.** This box is the shell's one scrolling element,\n * so the sidebar and top bar stay pinned and the page header goes up with the\n * body. The removed prop sat outside the scroll box and pinned the header\n * instead -- the one real difference between the two spellings, and the\n * reason the choice was worth collapsing to one rather than leaving to a\n * consumer to discover on a long page.\n */\n children: ReactNode\n}\n\n// The shell every authenticated Boost33 screen renders inside. It composes the\n// pieces built in the earlier tickets -- sidebar, wordmark, switcher, nav,\n// wallet chip, top bar -- into one thing a product imports instead of building a\n// sidebar. Everything that differs between products is the product's own data --\n// `nav`, `switcher`, `wallet`, `footer` -- and nothing above the nav (the Boost33\n// wordmark and brand) changes at all. `label` and `navLabel` name the two\n// landmarks and decide nothing; B33-1272 corrected the claim that `label` (then\n// `product`) was the whole of the shell's product-awareness.\n//\n// `PageHeader` is deliberately not in that list. The shell drew it from a\n// `pageHeader` prop until B33-1267; a page's title is the page's, rendered at\n// the top of `children`, which is what both consumers already did.\nexport function AppShell({\n label,\n appName,\n navLabel,\n nav,\n topBar,\n mark,\n hubHref,\n org,\n switcher,\n wallet,\n action,\n footer,\n collapsed = false,\n chromeFrom,\n phoneChrome,\n linkComponent,\n children,\n}: AppShellProps) {\n return (\n <div className={styles.shell} data-chrome-from={chromeFrom}>\n {chromeFrom != null && phoneChrome != null && <div className={styles.phoneChrome}>{phoneChrome}</div>}\n {/* The bar across the full width, above the sidebar and the product's\n column alike (board 20). First in the DOM as it is first on screen, so\n the tab order runs mark, switcher, token chip, bell, avatar, then the\n nav, then the page. */}\n <TopBar\n {...topBar}\n start={\n <>\n {/* The mark alone, as board 20 draws it: Wordmark's collapsed form\n keeps \"Boost33\" in the accessibility tree, so the link home is\n named, and draws only the mark. */}\n <Wordmark mark={mark} collapsed hubHref={hubHref} linkComponent={linkComponent} />\n {org}\n </>\n }\n switcher={switcher}\n tokens={wallet}\n />\n <div className={styles.body}>\n <Sidebar landmarkLabel={label} collapsed={collapsed}>\n {appName != null && !collapsed && <p className={styles.appName}>{appName}</p>}\n {/* Falls back to the sidebar's name rather than composing one. The\n composed string would be a word a screen reader reads aloud, so it\n would have to be in the consumer's language, and this package\n hardcodes no user-facing English. Falling back repeats a name the\n consumer wrote; it does not invent one. See `navLabel`. */}\n <nav aria-label={navLabel ?? label} className={styles.nav}>\n {nav.map((section, sectionIndex) => (\n // No room for a header at 56px, so on the rail every section renders\n // headerless -- its rows collapse to their icons and line up.\n <NavSection key={sectionIndex} label={collapsed ? undefined : section.label}>\n {section.items.map((item, itemIndex) => (\n <NavItem\n key={itemIndex}\n icon={item.icon}\n active={item.active}\n collapsed={collapsed}\n count={item.count}\n countUrgent={item.countUrgent}\n countLabel={item.countLabel}\n dot={item.dot}\n attrs={item.attrs}\n href={item.href}\n linkComponent={linkComponent}\n >\n {item.label}\n </NavItem>\n ))}\n </NavSection>\n ))}\n </nav>\n <SidebarAction action={action} collapsed={collapsed} linkComponent={linkComponent} />\n {footer != null && <div className={styles.foot}>{footer}</div>}\n </Sidebar>\n {/* `.main` is the product's column, under the bar and beside the\n sidebar. The landmark is the page body inside it: jumping to main\n has to skip the chrome. */}\n <div className={styles.main}>\n {/* The page's main landmark, and the shell's to supply. This was a plain\n div, so no route of any consumer had one unless that consumer\n remembered a wrapper of its own -- the Wallet did\n (`boostledger/src/app/(wallet)/layout.tsx`), and three products each\n having to remember it is three chances to ship without one. What the\n absence costs a screen-reader user is the first move they make on an\n unfamiliar screen: jump to main and start reading. Without one they\n land at the top of the document and walk the mark, the switcher, the\n token chip, the bell, the avatar, every nav row, the footer links and\n the sidebar toggle before the first word of the page -- on\n every navigation, because the shell is a layout and does not remount.\n\n The landmark is the body alone, which is the half that matters: a\n <main> drawn around the whole shell would satisfy any \"has a\n landmark\" check and buy the reader nothing, because jumping to it\n would land them back on the top bar.\n\n A consumer that already wraps `children` now has two, which is worse\n than none -- B33-1265 drops the Wallet's as it takes this version,\n and the CHANGELOG entry tells both consumers to.\n\n An element swap and nothing else: `main` and `div` are both\n `display: block`, the class and every declaration behind it are\n untouched, and all 27 gated screenshot states came back at the ratios\n they held before. */}\n <main className={styles.content}>{children}</main>\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * The sidebar's action button (B33-1713). The primary tier in its ink tone,\n * full width -- board 20's near-black \"New scan\", not the brand orange, which\n * the package keeps for the money moment (ADR-0016). Pushed to the foot of the\n * column with the footer: the nav above takes the slack.\n */\nfunction SidebarAction({ action, collapsed, linkComponent }: { action: AppShellAction | undefined; collapsed: boolean; linkComponent: ElementType | undefined }) {\n if (action === undefined || (action.href === undefined && action.onClick === undefined)) return null\n if (collapsed && action.icon == null) return null\n\n const content = (\n <>\n {action.icon != null && <span aria-hidden=\"true\">{action.icon}</span>}\n {/* On the rail the label leaves the screen and stays the button's name. */}\n <span className={collapsed ? styles.srOnly : undefined}>{action.label}</span>\n </>\n )\n const className = `${buttons.primary} ${styles.action}`\n const dataCollapsed = collapsed ? 'true' : undefined\n const dataActive = action.active ? 'true' : undefined\n\n if (action.href !== undefined) {\n const Link = linkComponent ?? 'a'\n return (\n <div className={styles.actionSlot}>\n <Link className={className} href={action.href} aria-current={action.active ? 'page' : undefined} data-collapsed={dataCollapsed} data-active={dataActive}>\n {content}\n </Link>\n </div>\n )\n }\n return (\n <div className={styles.actionSlot}>\n <button type=\"button\" className={className} onClick={action.onClick} data-collapsed={dataCollapsed} data-active={dataActive}>\n {content}\n </button>\n </div>\n )\n}\n"],"names":["AppShell","label","appName","navLabel","nav","topBar","mark","hubHref","org","switcher","wallet","action","footer","collapsed","chromeFrom","phoneChrome","linkComponent","children","styles","jsx","TopBar","jsxs","Fragment","Wordmark","Sidebar","section","sectionIndex","NavSection","item","itemIndex","NavItem","SidebarAction","content","className","buttons","dataCollapsed","dataActive","Link"],"mappings":"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"}
|
|
@@ -169,6 +169,12 @@ export interface AppSwitcherProps {
|
|
|
169
169
|
* The element the product and hub rows render through. Defaults to a plain
|
|
170
170
|
* `a`; a router-owning consumer passes its own so the rows route client-side
|
|
171
171
|
* with the locale prefix intact (ADR-0011, B33-847).
|
|
172
|
+
*
|
|
173
|
+
* **Pass it from a client component only.** This is a `'use client'` module,
|
|
174
|
+
* and a Server Component cannot hand a function across that boundary: Next
|
|
175
|
+
* throws and the render 500s (Passport). Render this component inside a
|
|
176
|
+
* `'use client'` file of your own and pass that into AppShell's slot. See the
|
|
177
|
+
* README, "Server Components".
|
|
172
178
|
*/
|
|
173
179
|
linkComponent?: ElementType;
|
|
174
180
|
/** The 56px rail. The trigger collapses to the current product's mark alone. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppSwitcher.js","sources":["../../../src/components/AppSwitcher/AppSwitcher.tsx"],"sourcesContent":["'use client'\n\n// A client module: the switcher holds open/closed state and wires clicks, focus\n// and Escape through useAnchoredOverlay, none of which a Server Component can\n// carry. The rest of the shell stays on the server -- see WalletChip, Sidebar\n// and B33-846. AppShell renders this as a slot (`switcher`), so it imports no\n// client code by taking it.\n\nimport { useId, type ElementType } from 'react'\nimport { useAnchoredOverlay } from '../../anchored-overlay'\nimport { warnDeprecatedProp } from '../../deprecated-prop-warning'\nimport { ProductMark } from '../ProductMark/ProductMark'\nimport { Check, Chevron, ChevronRight, HubMark } from './icons'\nimport { resolveAppSwitcherLabels, type AppSwitcherLabels } from './labels'\nimport styles from './AppSwitcher.module.css'\n\n/**\n * One product the switcher can jump to. It is Identity's registry row, supplied\n * by the consuming app (ADR-0003) -- the switcher fetches nothing and hardcodes\n * no list. Every product that exists is rendered, entitled or not: hiding an\n * unowned one means nobody learns the family exists (B33-479), and it tells a\n * client who could buy a product that the product is not there.\n *\n * A product that does not exist yet is not rendered (B33-1711). Until 0.8 it\n * drew with a \"Soon\" tag; the navigation redesign of 3 Nov 2026 took unbuilt\n * products off the switcher, the same way they came off the hub. See `live`.\n */\nexport interface AppSwitcherProduct {\n /** Identity's product slug, which selects the ProductMark artwork. */\n slug: string\n /** The product's display name, e.g. \"Boost Score\". Identity's, not the slug. */\n name: string\n /** Where selecting this product goes -- the product's own URL, never the hub. */\n href: string\n /**\n * Whether the account holds this product. Defaults to `true`. `false` dims the\n * mark and shows the \"Not on plan\" tag, but the row stays an ordinary focusable\n * link: entitlement is a rendering hint, and the switcher is not the thing\n * deciding what someone may open (ADR-0010, \"Not entitled is not disabled\").\n */\n entitled?: boolean\n /**\n * Whether the product exists yet. Defaults to `true`. `false` leaves the row\n * out of the panel entirely (B33-1711), unless it is the product this shell is\n * rendering for -- the current product is always listed, since you are in it.\n *\n * **Only this hides a row. `entitled: false` never does.** The two answer\n * different questions, and the difference is commercial: a product the account\n * could buy is real, and leaving it out tells a paying client it does not\n * exist. That rule is the hub's as well (boost33-hub CLAUDE.md, `cardState`),\n * and the switcher follows it.\n *\n * Until 0.8 an unbuilt row was drawn with a \"Soon\" tag. The 3 Nov 2026\n * navigation redesign (B33-1710) removed unbuilt products from the switcher,\n * which overrules the Vocabulary document's \"the switcher shows every product\n * regardless of state\"; that document is amended alongside this change.\n *\n * What follows is why `live` is a second field rather than a third value of\n * `entitled`, which still holds.\n *\n * **A different question from `entitled`, which is why it is a second field\n * and not a third value of the first.** `live` is global: the same answer for\n * every account, because either the product has shipped or it has not.\n * `entitled` is per account, read from the grant. Identity serves both for\n * exactly this reason (B33-561, and the `live` note in\n * `boost33-identity/src/lib/productRegistry.ts`).\n *\n * Until 0.4 this interface had nowhere to put it, so both consumers read the\n * field off the registry and dropped it on the floor -- each with a comment\n * saying so and calling it worth a package ticket. The cost was a row that\n * told a client they had not bought Boost Voice, when nobody has: the row read\n * \"Not on plan\" and pointed at a product with no code behind it.\n *\n * Hiding the row is a rendering decision, not an access one. The switcher\n * still does not decide what anyone may open (ADR-0010), and `url` is still\n * served for every slug, because `?app=` has to resolve for a campaign link.\n */\n live?: boolean\n /**\n * The heading of the section this row sits under, in the consumer's language\n * -- \"Your tools\", \"Included with every account\" (B33-1711, board 21). Rows\n * are grouped by this label in the order the labels first appear, and each\n * group gets a header. Leave it off every row and the panel is one plain list\n * with no headers; rows without one, in a panel where others have one, form a\n * headerless group of their own.\n *\n * Data the consumer is served, never decided here: which products are \"your\n * tools\" is the account's grant and Identity's registry, and the package\n * knows neither (CLAUDE.md -- no component knows what a product means).\n */\n group?: string\n /**\n * One thing at the right of the row (B33-1711, board 21) -- a figure such as\n * the Wallet's balance, or a dot saying there is something new. The consumer\n * supplies it already formatted; the switcher fetches nothing and formats\n * nothing. The current product's check takes the same slot, so a trailing on\n * the current row is not drawn.\n */\n trailing?: AppSwitcherTrailing\n}\n\n/**\n * The right-hand slot of a product row, in its two shapes.\n *\n * - `value` -- a short preformatted figure, drawn muted in the mono face:\n * `{ kind: 'value', value: '8,940' }`.\n * - `dot` -- a small primary dot meaning \"something new here\". A dot is a\n * colour and nothing else, so `label` is required: it is what a screen reader\n * hears instead (\"2 new\"), in the consumer's language.\n */\nexport type AppSwitcherTrailing = { kind: 'value'; value: string } | { kind: 'dot'; label: string }\n\n/** The \"all products\" row at the top of the panel. Its blurb and URL are the consumer's. */\nexport interface AppSwitcherHub {\n /** The row's name, e.g. \"Hub\". */\n name: string\n /** The line beneath it, e.g. \"All Boost33 products\". */\n description: string\n /** The hub URL. */\n href: string\n}\n\n/**\n * The account/workspace row that sat at the foot of the panel until 0.8.0.\n *\n * **Not drawn since 0.8.0.** The 3 Nov 2026 redesign (B33-1710, board 21) took\n * the account row off the switcher: the organisation now lives in the top bar's\n * left-hand slot, as the consumer's own pill (B33-1712). The type stays so a\n * consumer's existing `account` object still compiles; nothing reads it.\n *\n * @deprecated Rendered nowhere since 0.8.0 (B33-1711). Stop passing `account`;\n * in development, passing it logs a console warning once.\n */\nexport interface AppSwitcherAccount {\n /** The organisation's name, e.g. \"Acme Coaching\". */\n name: string\n /** The avatar's initials, e.g. \"AC\". */\n initials: string\n /** The line beneath the name, preformatted, e.g. \"Admin · 2 workspaces\". */\n description: string\n /** Was: chosen -- opens the workspace panel. Never called since 0.8.0. */\n onSelect?: () => void\n}\n\nexport interface AppSwitcherProps {\n /**\n * The products, in the order Identity serves them. Pass the registry whole:\n * the switcher leaves out the rows with `live: false` itself (B33-1711), so a\n * consumer does not filter, and every product that exists is drawn whether\n * the account holds it or not.\n */\n products: AppSwitcherProduct[]\n /**\n * The slug of the product this shell is rendering for. It names the trigger\n * and is the row that carries the check -- the product you are in.\n *\n * A slug matching nothing in `products` is a supported state, not a caller\n * error: the registry is Identity's and can run ahead of this package\n * (ADR-0003). The mark degrades to a bare tile and the trigger falls back to\n * `labels.switchProduct` for its name (B33-919).\n */\n currentSlug: string\n /** The \"all products\" row above the products. */\n hub: AppSwitcherHub\n /**\n * Was the account/workspace row below the products. Optional and unread since\n * 0.8.0, when the redesign took that row off the panel (B33-1711).\n *\n * @deprecated Rendered nowhere since 0.8.0. The organisation goes in the top\n * bar's slot instead (B33-1712). In development, passing it logs a console\n * warning once.\n */\n account?: AppSwitcherAccount\n /**\n * Every user-facing string that is not row data. Each field defaults to\n * English, and a field left out -- or supplied as `undefined`, which is what\n * a missed next-intl key looks like -- keeps its default (B33-873).\n */\n labels?: Partial<AppSwitcherLabels>\n /**\n * The element the product and hub rows render through. Defaults to a plain\n * `a`; a router-owning consumer passes its own so the rows route client-side\n * with the locale prefix intact (ADR-0011, B33-847).\n */\n linkComponent?: ElementType\n /** The 56px rail. The trigger collapses to the current product's mark alone. */\n collapsed?: boolean\n /** Render the panel open on first paint -- the specimen's Stage-A open state. */\n defaultOpen?: boolean\n /** Every open and close, so a consumer can un-clip an ancestor's overflow while the panel overhangs. */\n onOpenChange?: (open: boolean) => void\n}\n\n/** The panel's product tile, in px: board 21's 28px row tile. */\nconst ROW_MARK_SIZE = 28\n\n/**\n * Consecutive rows that share a `group`, in the order the rows arrive. A run\n * whose rows carry no group has no label and draws no header, so a list with no\n * groups at all is one headerless run -- the plain list.\n *\n * Runs, not buckets: two rows with the same label separated by a different one\n * draw two sections with the same header. The order is Identity's, and\n * regrouping would silently reorder what the consumer was served.\n */\nfunction groupRuns(products: AppSwitcherProduct[]): { label: string | undefined; products: AppSwitcherProduct[] }[] {\n const runs: { label: string | undefined; products: AppSwitcherProduct[] }[] = []\n for (const product of products) {\n const last = runs[runs.length - 1]\n if (last !== undefined && last.label === product.group) last.products.push(product)\n else runs.push({ label: product.group, products: [product] })\n }\n return runs\n}\n\n/** A row's right-hand figure or dot. A dot carries its words for a screen reader. */\nfunction Trailing({ trailing }: { trailing: AppSwitcherTrailing | undefined }) {\n if (trailing === undefined) return null\n if (trailing.kind === 'value') return <span className={styles.trailingValue}>{trailing.value}</span>\n return (\n <>\n <span className={styles.trailingDot} aria-hidden=\"true\" />\n <span className={styles.srOnly}>{trailing.label}</span>\n </>\n )\n}\n\n/**\n * The product switcher. It opens, lists every Boost33 product that exists with\n * the current one checked, and takes you straight into the one you pick --\n * the real switcher the Wallet's chevron-that-opens-nothing never was (B33-479).\n *\n * A disclosure, not a menu (ADR-0010): the panel carries no widget role, the\n * rows are the real links and buttons they already are, and Tab is the\n * document's own tab order -- so tabbing through the products does not close the\n * panel on the first row. Dismissal, focus restoration and trigger ARIA are the\n * shared anchored-overlay contract's (ADR-0009); it never becomes a modal.\n */\nexport function AppSwitcher({\n products,\n currentSlug,\n hub,\n labels,\n linkComponent,\n collapsed = false,\n defaultOpen,\n onOpenChange,\n account,\n}: AppSwitcherProps) {\n if (account != null) warnDeprecatedProp('AppSwitcher.account')\n const overlay = useAnchoredOverlay({ defaultOpen, onOpenChange })\n const panelId = useId()\n const t = resolveAppSwitcherLabels(labels)\n const Link: ElementType = linkComponent ?? 'a'\n const current = products.find((p) => p.slug === currentSlug)\n // Unbuilt products are not drawn (B33-1711) -- except the one you are in.\n // A shell rendering for a product Identity still marks `live: false` is a\n // product in a preview or a soft launch, and a switcher that left out the\n // place you are standing would have nothing to put its check on.\n const listed = products.filter((p) => p.live !== false || p.slug === currentSlug)\n\n // The name the trigger carries, in both its shapes. `current` is `undefined`\n // whenever `currentSlug` matches nothing in the served registry -- a fifth\n // Identity product, or a slug left stale by a rename -- which ADR-0003\n // contemplates and `AppSwitcherProduct.slug` being `string` rather than\n // `ProductSlug` deliberately allows.\n //\n // Degrading the artwork to a bare tile there is right. Degrading the name is\n // not, and that is what happened: the mark is `aria-hidden` unless it is\n // given a title, so a collapsed trigger handed `title={undefined}` had no\n // accessible name at all. A screen reader reached the only control in the\n // 56px rail and announced \"button\" -- no product, no purpose, nothing to act\n // on. Expanded it was the same absence with a visible face: an empty\n // `.triggerName` between a blank tile and a chevron. CLAUDE.md puts labels in\n // the definition of done, so the fallback is a label prop rather than a\n // literal (B33-919, B33-849).\n const triggerName = current?.name ?? t.switchProduct\n\n return (\n <div className={styles.root}>\n <button\n type=\"button\"\n className={collapsed ? styles.railTrigger : styles.trigger}\n onClick={overlay.toggle}\n {...overlay.triggerProps}\n >\n {/* On the rail the mark is the button's only content, so it carries the\n accessible name (title); expanded, the name sits beside it and the\n mark is decorative, or a screen reader says the product twice.\n\n No `size`. ProductMark's default is 20px -- the Wallet's own size at\n its call sites, and the size all three appswitcher-* baselines were\n captured at. This passed 28 expanded and 32 collapsed until B33-479's\n fidelity pass: a mark 8px over budget in every row pushed the account\n row out of the open panel's frame entirely and truncated \"Boost\n Outbound\" to \"Boos...\". The rail is not a larger mark; it is the same\n mark with the label and chevron gone. */}\n <ProductMark slug={currentSlug} title={collapsed ? triggerName : undefined} />\n {!collapsed && (\n <>\n <span className={styles.triggerName}>{triggerName}</span>\n <Chevron className={styles.chevron} />\n </>\n )}\n </button>\n\n {overlay.open && (\n <div {...overlay.panelProps} className={styles.panel} aria-label={t.panel}>\n {/* The way out to every tool, drawn as the panel's one dark row\n (board 21). Its chevron says it leaves this product. */}\n <Link className={styles.hubRow} href={hub.href} onClick={overlay.close}>\n <HubMark className={styles.hubMark} />\n <span className={styles.rowText}>\n <span className={styles.rowName}>{hub.name}</span>\n <span className={styles.rowSub}>{hub.description}</span>\n </span>\n <ChevronRight className={styles.hubChevron} />\n </Link>\n\n {groupRuns(listed).map((run, runIndex) => {\n const headerId = `${panelId}-group-${runIndex}`\n return (\n <div key={runIndex} className={styles.group}>\n {run.label !== undefined && (\n <p id={headerId} className={styles.groupLabel}>\n {run.label}\n </p>\n )}\n <ul className={styles.products} aria-labelledby={run.label !== undefined ? headerId : undefined}>\n {run.products.map((product) => {\n const isCurrent = product.slug === currentSlug\n const notEntitled = product.entitled === false\n // A product that has not shipped never reaches here (see\n // `listed`), so the one tag left is entitlement's. B33-561's\n // problem -- an unbuilt product reading \"Not on plan\" --\n // cannot come back: Identity serves every unbuilt row\n // `entitled: false`, and none of them is drawn.\n const tag = notEntitled ? t.notOnPlan : null\n\n return (\n <li key={product.slug}>\n <Link\n className={styles.productRow}\n href={product.href}\n aria-current={isCurrent ? 'true' : undefined}\n data-current={isCurrent ? 'true' : undefined}\n data-entitled={notEntitled ? 'false' : undefined}\n onClick={overlay.close}\n >\n {/* 28px in the panel (board 21) -- the tile the row is\n built around. The trigger keeps the 20px default. */}\n <ProductMark slug={product.slug} size={ROW_MARK_SIZE} muted={notEntitled} />\n <span className={styles.rowName}>{product.name}</span>\n {tag !== null && <span className={styles.tag}>{tag}</span>}\n <span className={styles.trailing}>\n {isCurrent ? <Check className={styles.check} /> : <Trailing trailing={product.trailing} />}\n </span>\n </Link>\n </li>\n )\n })}\n </ul>\n </div>\n )\n })}\n </div>\n )}\n </div>\n )\n}\n"],"names":["ROW_MARK_SIZE","groupRuns","products","runs","product","last","Trailing","trailing","jsx","styles","jsxs","Fragment","AppSwitcher","currentSlug","hub","labels","linkComponent","collapsed","defaultOpen","onOpenChange","account","warnDeprecatedProp","overlay","useAnchoredOverlay","panelId","useId","t","resolveAppSwitcherLabels","Link","current","p","listed","triggerName","ProductMark","Chevron","HubMark","ChevronRight","run","runIndex","headerId","isCurrent","notEntitled","tag","Check"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppSwitcher.js","sources":["../../../src/components/AppSwitcher/AppSwitcher.tsx"],"sourcesContent":["'use client'\n\n// A client module: the switcher holds open/closed state and wires clicks, focus\n// and Escape through useAnchoredOverlay, none of which a Server Component can\n// carry. The rest of the shell stays on the server -- see WalletChip, Sidebar\n// and B33-846. AppShell renders this as a slot (`switcher`), so it imports no\n// client code by taking it.\n\nimport { useId, type ElementType } from 'react'\nimport { useAnchoredOverlay } from '../../anchored-overlay'\nimport { warnDeprecatedProp } from '../../deprecated-prop-warning'\nimport { ProductMark } from '../ProductMark/ProductMark'\nimport { Check, Chevron, ChevronRight, HubMark } from './icons'\nimport { resolveAppSwitcherLabels, type AppSwitcherLabels } from './labels'\nimport styles from './AppSwitcher.module.css'\n\n/**\n * One product the switcher can jump to. It is Identity's registry row, supplied\n * by the consuming app (ADR-0003) -- the switcher fetches nothing and hardcodes\n * no list. Every product that exists is rendered, entitled or not: hiding an\n * unowned one means nobody learns the family exists (B33-479), and it tells a\n * client who could buy a product that the product is not there.\n *\n * A product that does not exist yet is not rendered (B33-1711). Until 0.8 it\n * drew with a \"Soon\" tag; the navigation redesign of 3 Nov 2026 took unbuilt\n * products off the switcher, the same way they came off the hub. See `live`.\n */\nexport interface AppSwitcherProduct {\n /** Identity's product slug, which selects the ProductMark artwork. */\n slug: string\n /** The product's display name, e.g. \"Boost Score\". Identity's, not the slug. */\n name: string\n /** Where selecting this product goes -- the product's own URL, never the hub. */\n href: string\n /**\n * Whether the account holds this product. Defaults to `true`. `false` dims the\n * mark and shows the \"Not on plan\" tag, but the row stays an ordinary focusable\n * link: entitlement is a rendering hint, and the switcher is not the thing\n * deciding what someone may open (ADR-0010, \"Not entitled is not disabled\").\n */\n entitled?: boolean\n /**\n * Whether the product exists yet. Defaults to `true`. `false` leaves the row\n * out of the panel entirely (B33-1711), unless it is the product this shell is\n * rendering for -- the current product is always listed, since you are in it.\n *\n * **Only this hides a row. `entitled: false` never does.** The two answer\n * different questions, and the difference is commercial: a product the account\n * could buy is real, and leaving it out tells a paying client it does not\n * exist. That rule is the hub's as well (boost33-hub CLAUDE.md, `cardState`),\n * and the switcher follows it.\n *\n * Until 0.8 an unbuilt row was drawn with a \"Soon\" tag. The 3 Nov 2026\n * navigation redesign (B33-1710) removed unbuilt products from the switcher,\n * which overrules the Vocabulary document's \"the switcher shows every product\n * regardless of state\"; that document is amended alongside this change.\n *\n * What follows is why `live` is a second field rather than a third value of\n * `entitled`, which still holds.\n *\n * **A different question from `entitled`, which is why it is a second field\n * and not a third value of the first.** `live` is global: the same answer for\n * every account, because either the product has shipped or it has not.\n * `entitled` is per account, read from the grant. Identity serves both for\n * exactly this reason (B33-561, and the `live` note in\n * `boost33-identity/src/lib/productRegistry.ts`).\n *\n * Until 0.4 this interface had nowhere to put it, so both consumers read the\n * field off the registry and dropped it on the floor -- each with a comment\n * saying so and calling it worth a package ticket. The cost was a row that\n * told a client they had not bought Boost Voice, when nobody has: the row read\n * \"Not on plan\" and pointed at a product with no code behind it.\n *\n * Hiding the row is a rendering decision, not an access one. The switcher\n * still does not decide what anyone may open (ADR-0010), and `url` is still\n * served for every slug, because `?app=` has to resolve for a campaign link.\n */\n live?: boolean\n /**\n * The heading of the section this row sits under, in the consumer's language\n * -- \"Your tools\", \"Included with every account\" (B33-1711, board 21). Rows\n * are grouped by this label in the order the labels first appear, and each\n * group gets a header. Leave it off every row and the panel is one plain list\n * with no headers; rows without one, in a panel where others have one, form a\n * headerless group of their own.\n *\n * Data the consumer is served, never decided here: which products are \"your\n * tools\" is the account's grant and Identity's registry, and the package\n * knows neither (CLAUDE.md -- no component knows what a product means).\n */\n group?: string\n /**\n * One thing at the right of the row (B33-1711, board 21) -- a figure such as\n * the Wallet's balance, or a dot saying there is something new. The consumer\n * supplies it already formatted; the switcher fetches nothing and formats\n * nothing. The current product's check takes the same slot, so a trailing on\n * the current row is not drawn.\n */\n trailing?: AppSwitcherTrailing\n}\n\n/**\n * The right-hand slot of a product row, in its two shapes.\n *\n * - `value` -- a short preformatted figure, drawn muted in the mono face:\n * `{ kind: 'value', value: '8,940' }`.\n * - `dot` -- a small primary dot meaning \"something new here\". A dot is a\n * colour and nothing else, so `label` is required: it is what a screen reader\n * hears instead (\"2 new\"), in the consumer's language.\n */\nexport type AppSwitcherTrailing = { kind: 'value'; value: string } | { kind: 'dot'; label: string }\n\n/** The \"all products\" row at the top of the panel. Its blurb and URL are the consumer's. */\nexport interface AppSwitcherHub {\n /** The row's name, e.g. \"Hub\". */\n name: string\n /** The line beneath it, e.g. \"All Boost33 products\". */\n description: string\n /** The hub URL. */\n href: string\n}\n\n/**\n * The account/workspace row that sat at the foot of the panel until 0.8.0.\n *\n * **Not drawn since 0.8.0.** The 3 Nov 2026 redesign (B33-1710, board 21) took\n * the account row off the switcher: the organisation now lives in the top bar's\n * left-hand slot, as the consumer's own pill (B33-1712). The type stays so a\n * consumer's existing `account` object still compiles; nothing reads it.\n *\n * @deprecated Rendered nowhere since 0.8.0 (B33-1711). Stop passing `account`;\n * in development, passing it logs a console warning once.\n */\nexport interface AppSwitcherAccount {\n /** The organisation's name, e.g. \"Acme Coaching\". */\n name: string\n /** The avatar's initials, e.g. \"AC\". */\n initials: string\n /** The line beneath the name, preformatted, e.g. \"Admin · 2 workspaces\". */\n description: string\n /** Was: chosen -- opens the workspace panel. Never called since 0.8.0. */\n onSelect?: () => void\n}\n\nexport interface AppSwitcherProps {\n /**\n * The products, in the order Identity serves them. Pass the registry whole:\n * the switcher leaves out the rows with `live: false` itself (B33-1711), so a\n * consumer does not filter, and every product that exists is drawn whether\n * the account holds it or not.\n */\n products: AppSwitcherProduct[]\n /**\n * The slug of the product this shell is rendering for. It names the trigger\n * and is the row that carries the check -- the product you are in.\n *\n * A slug matching nothing in `products` is a supported state, not a caller\n * error: the registry is Identity's and can run ahead of this package\n * (ADR-0003). The mark degrades to a bare tile and the trigger falls back to\n * `labels.switchProduct` for its name (B33-919).\n */\n currentSlug: string\n /** The \"all products\" row above the products. */\n hub: AppSwitcherHub\n /**\n * Was the account/workspace row below the products. Optional and unread since\n * 0.8.0, when the redesign took that row off the panel (B33-1711).\n *\n * @deprecated Rendered nowhere since 0.8.0. The organisation goes in the top\n * bar's slot instead (B33-1712). In development, passing it logs a console\n * warning once.\n */\n account?: AppSwitcherAccount\n /**\n * Every user-facing string that is not row data. Each field defaults to\n * English, and a field left out -- or supplied as `undefined`, which is what\n * a missed next-intl key looks like -- keeps its default (B33-873).\n */\n labels?: Partial<AppSwitcherLabels>\n /**\n * The element the product and hub rows render through. Defaults to a plain\n * `a`; a router-owning consumer passes its own so the rows route client-side\n * with the locale prefix intact (ADR-0011, B33-847).\n *\n * **Pass it from a client component only.** This is a `'use client'` module,\n * and a Server Component cannot hand a function across that boundary: Next\n * throws and the render 500s (Passport). Render this component inside a\n * `'use client'` file of your own and pass that into AppShell's slot. See the\n * README, \"Server Components\".\n */\n linkComponent?: ElementType\n /** The 56px rail. The trigger collapses to the current product's mark alone. */\n collapsed?: boolean\n /** Render the panel open on first paint -- the specimen's Stage-A open state. */\n defaultOpen?: boolean\n /** Every open and close, so a consumer can un-clip an ancestor's overflow while the panel overhangs. */\n onOpenChange?: (open: boolean) => void\n}\n\n/** The panel's product tile, in px: board 21's 28px row tile. */\nconst ROW_MARK_SIZE = 28\n\n/**\n * Consecutive rows that share a `group`, in the order the rows arrive. A run\n * whose rows carry no group has no label and draws no header, so a list with no\n * groups at all is one headerless run -- the plain list.\n *\n * Runs, not buckets: two rows with the same label separated by a different one\n * draw two sections with the same header. The order is Identity's, and\n * regrouping would silently reorder what the consumer was served.\n */\nfunction groupRuns(products: AppSwitcherProduct[]): { label: string | undefined; products: AppSwitcherProduct[] }[] {\n const runs: { label: string | undefined; products: AppSwitcherProduct[] }[] = []\n for (const product of products) {\n const last = runs[runs.length - 1]\n if (last !== undefined && last.label === product.group) last.products.push(product)\n else runs.push({ label: product.group, products: [product] })\n }\n return runs\n}\n\n/** A row's right-hand figure or dot. A dot carries its words for a screen reader. */\nfunction Trailing({ trailing }: { trailing: AppSwitcherTrailing | undefined }) {\n if (trailing === undefined) return null\n if (trailing.kind === 'value') return <span className={styles.trailingValue}>{trailing.value}</span>\n return (\n <>\n <span className={styles.trailingDot} aria-hidden=\"true\" />\n <span className={styles.srOnly}>{trailing.label}</span>\n </>\n )\n}\n\n/**\n * The product switcher. It opens, lists every Boost33 product that exists with\n * the current one checked, and takes you straight into the one you pick --\n * the real switcher the Wallet's chevron-that-opens-nothing never was (B33-479).\n *\n * A disclosure, not a menu (ADR-0010): the panel carries no widget role, the\n * rows are the real links and buttons they already are, and Tab is the\n * document's own tab order -- so tabbing through the products does not close the\n * panel on the first row. Dismissal, focus restoration and trigger ARIA are the\n * shared anchored-overlay contract's (ADR-0009); it never becomes a modal.\n */\nexport function AppSwitcher({\n products,\n currentSlug,\n hub,\n labels,\n linkComponent,\n collapsed = false,\n defaultOpen,\n onOpenChange,\n account,\n}: AppSwitcherProps) {\n if (account != null) warnDeprecatedProp('AppSwitcher.account')\n const overlay = useAnchoredOverlay({ defaultOpen, onOpenChange })\n const panelId = useId()\n const t = resolveAppSwitcherLabels(labels)\n const Link: ElementType = linkComponent ?? 'a'\n const current = products.find((p) => p.slug === currentSlug)\n // Unbuilt products are not drawn (B33-1711) -- except the one you are in.\n // A shell rendering for a product Identity still marks `live: false` is a\n // product in a preview or a soft launch, and a switcher that left out the\n // place you are standing would have nothing to put its check on.\n const listed = products.filter((p) => p.live !== false || p.slug === currentSlug)\n\n // The name the trigger carries, in both its shapes. `current` is `undefined`\n // whenever `currentSlug` matches nothing in the served registry -- a fifth\n // Identity product, or a slug left stale by a rename -- which ADR-0003\n // contemplates and `AppSwitcherProduct.slug` being `string` rather than\n // `ProductSlug` deliberately allows.\n //\n // Degrading the artwork to a bare tile there is right. Degrading the name is\n // not, and that is what happened: the mark is `aria-hidden` unless it is\n // given a title, so a collapsed trigger handed `title={undefined}` had no\n // accessible name at all. A screen reader reached the only control in the\n // 56px rail and announced \"button\" -- no product, no purpose, nothing to act\n // on. Expanded it was the same absence with a visible face: an empty\n // `.triggerName` between a blank tile and a chevron. CLAUDE.md puts labels in\n // the definition of done, so the fallback is a label prop rather than a\n // literal (B33-919, B33-849).\n const triggerName = current?.name ?? t.switchProduct\n\n return (\n <div className={styles.root}>\n <button\n type=\"button\"\n className={collapsed ? styles.railTrigger : styles.trigger}\n onClick={overlay.toggle}\n {...overlay.triggerProps}\n >\n {/* On the rail the mark is the button's only content, so it carries the\n accessible name (title); expanded, the name sits beside it and the\n mark is decorative, or a screen reader says the product twice.\n\n No `size`. ProductMark's default is 20px -- the Wallet's own size at\n its call sites, and the size all three appswitcher-* baselines were\n captured at. This passed 28 expanded and 32 collapsed until B33-479's\n fidelity pass: a mark 8px over budget in every row pushed the account\n row out of the open panel's frame entirely and truncated \"Boost\n Outbound\" to \"Boos...\". The rail is not a larger mark; it is the same\n mark with the label and chevron gone. */}\n <ProductMark slug={currentSlug} title={collapsed ? triggerName : undefined} />\n {!collapsed && (\n <>\n <span className={styles.triggerName}>{triggerName}</span>\n <Chevron className={styles.chevron} />\n </>\n )}\n </button>\n\n {overlay.open && (\n <div {...overlay.panelProps} className={styles.panel} aria-label={t.panel}>\n {/* The way out to every tool, drawn as the panel's one dark row\n (board 21). Its chevron says it leaves this product. */}\n <Link className={styles.hubRow} href={hub.href} onClick={overlay.close}>\n <HubMark className={styles.hubMark} />\n <span className={styles.rowText}>\n <span className={styles.rowName}>{hub.name}</span>\n <span className={styles.rowSub}>{hub.description}</span>\n </span>\n <ChevronRight className={styles.hubChevron} />\n </Link>\n\n {groupRuns(listed).map((run, runIndex) => {\n const headerId = `${panelId}-group-${runIndex}`\n return (\n <div key={runIndex} className={styles.group}>\n {run.label !== undefined && (\n <p id={headerId} className={styles.groupLabel}>\n {run.label}\n </p>\n )}\n <ul className={styles.products} aria-labelledby={run.label !== undefined ? headerId : undefined}>\n {run.products.map((product) => {\n const isCurrent = product.slug === currentSlug\n const notEntitled = product.entitled === false\n // A product that has not shipped never reaches here (see\n // `listed`), so the one tag left is entitlement's. B33-561's\n // problem -- an unbuilt product reading \"Not on plan\" --\n // cannot come back: Identity serves every unbuilt row\n // `entitled: false`, and none of them is drawn.\n const tag = notEntitled ? t.notOnPlan : null\n\n return (\n <li key={product.slug}>\n <Link\n className={styles.productRow}\n href={product.href}\n aria-current={isCurrent ? 'true' : undefined}\n data-current={isCurrent ? 'true' : undefined}\n data-entitled={notEntitled ? 'false' : undefined}\n onClick={overlay.close}\n >\n {/* 28px in the panel (board 21) -- the tile the row is\n built around. The trigger keeps the 20px default. */}\n <ProductMark slug={product.slug} size={ROW_MARK_SIZE} muted={notEntitled} />\n <span className={styles.rowName}>{product.name}</span>\n {tag !== null && <span className={styles.tag}>{tag}</span>}\n <span className={styles.trailing}>\n {isCurrent ? <Check className={styles.check} /> : <Trailing trailing={product.trailing} />}\n </span>\n </Link>\n </li>\n )\n })}\n </ul>\n </div>\n )\n })}\n </div>\n )}\n </div>\n )\n}\n"],"names":["ROW_MARK_SIZE","groupRuns","products","runs","product","last","Trailing","trailing","jsx","styles","jsxs","Fragment","AppSwitcher","currentSlug","hub","labels","linkComponent","collapsed","defaultOpen","onOpenChange","account","warnDeprecatedProp","overlay","useAnchoredOverlay","panelId","useId","t","resolveAppSwitcherLabels","Link","current","p","listed","triggerName","ProductMark","Chevron","HubMark","ChevronRight","run","runIndex","headerId","isCurrent","notEntitled","tag","Check"],"mappings":"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"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ElementType, ReactNode } from 'react';
|
|
2
|
+
/** Pass-through attributes for a nav row: `data-*` only. */
|
|
3
|
+
export type NavItemAttrs = Record<`data-${string}`, string>;
|
|
2
4
|
export interface NavItemProps {
|
|
3
5
|
/** The nav label. Truncates with an ellipsis rather than wrapping. */
|
|
4
6
|
children: ReactNode;
|
|
@@ -29,6 +31,21 @@ export interface NavItemProps {
|
|
|
29
31
|
* thing.
|
|
30
32
|
*/
|
|
31
33
|
dot?: string;
|
|
34
|
+
/**
|
|
35
|
+
* What a screen reader hears for `count`, in the consumer's words: "3 to
|
|
36
|
+
* open", "3 à ouvrir" (0.8.0-next.5, Off Market). The bare number on its own
|
|
37
|
+
* says nothing about what is counted. Given, the visible figure is hidden from
|
|
38
|
+
* assistive tech and this is read in its place; omitted, the number is read as
|
|
39
|
+
* before. Ignored without `count`.
|
|
40
|
+
*/
|
|
41
|
+
countLabel?: string;
|
|
42
|
+
/**
|
|
43
|
+
* `data-*` attributes for the row's element, such as a product tour's hook
|
|
44
|
+
* (`{ 'data-tour': 'nav-accounts' }`, Outbound's driver.js). Only `data-*`, so
|
|
45
|
+
* nothing here can change the row's role, name or behaviour, and the row's
|
|
46
|
+
* own `data-active` and `data-collapsed` always win (0.8.0-next.5).
|
|
47
|
+
*/
|
|
48
|
+
attrs?: NavItemAttrs;
|
|
32
49
|
href?: string;
|
|
33
50
|
/**
|
|
34
51
|
* The element the linked form renders through. Defaults to a plain `a`; a
|
|
@@ -42,4 +59,4 @@ export interface NavItemProps {
|
|
|
42
59
|
linkComponent?: ElementType;
|
|
43
60
|
onClick?: () => void;
|
|
44
61
|
}
|
|
45
|
-
export declare function NavItem({ children, icon, active, collapsed, count, countUrgent, dot, href, linkComponent, onClick }: NavItemProps): import("react").JSX.Element;
|
|
62
|
+
export declare function NavItem({ children, icon, active, collapsed, count, countUrgent, countLabel, dot, attrs, href, linkComponent, onClick }: NavItemProps): import("react").JSX.Element;
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import { jsx as
|
|
1
|
+
import { jsx as s, jsxs as n, Fragment as r } from "react/jsx-runtime";
|
|
2
2
|
import a from "./NavItem.module.css.js";
|
|
3
|
-
function b({ children:
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
"data-
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/* @__PURE__ */
|
|
11
|
-
/* @__PURE__ */
|
|
12
|
-
|
|
13
|
-
/* @__PURE__ */
|
|
3
|
+
function b({ children: p, icon: t, active: l = !1, collapsed: N = !1, count: i, countUrgent: f = !1, countLabel: e, dot: c, attrs: g, href: d, linkComponent: v, onClick: o }) {
|
|
4
|
+
const h = {
|
|
5
|
+
...Object.fromEntries(Object.entries(g ?? {}).filter(([m]) => m.startsWith("data-"))),
|
|
6
|
+
"data-active": l ? "true" : void 0,
|
|
7
|
+
"data-collapsed": N ? "true" : void 0,
|
|
8
|
+
"aria-current": l ? "page" : void 0
|
|
9
|
+
}, u = /* @__PURE__ */ n(r, { children: [
|
|
10
|
+
t != null && /* @__PURE__ */ s("span", { className: a.icon, "aria-hidden": "true", children: t }),
|
|
11
|
+
/* @__PURE__ */ s("span", { className: a.label, children: p }),
|
|
12
|
+
/* @__PURE__ */ s("span", { className: a.trailingSlot, children: i != null ? /* @__PURE__ */ n(r, { children: [
|
|
13
|
+
/* @__PURE__ */ s("span", { className: f ? a.countUrgent : a.count, "aria-hidden": e != null ? "true" : void 0, children: i }),
|
|
14
|
+
e != null && /* @__PURE__ */ s("span", { className: a.srOnly, children: e })
|
|
15
|
+
] }) : c != null && /* @__PURE__ */ n(r, { children: [
|
|
16
|
+
/* @__PURE__ */ s("span", { className: a.dot, "aria-hidden": "true" }),
|
|
17
|
+
/* @__PURE__ */ s("span", { className: a.srOnly, children: c })
|
|
14
18
|
] }) })
|
|
15
19
|
] });
|
|
16
|
-
return
|
|
20
|
+
return d ? /* @__PURE__ */ s(v ?? "a", { className: a.row, href: d, onClick: o, ...h, children: u }) : /* @__PURE__ */ s("button", { type: "button", className: a.row, onClick: o, ...h, children: u });
|
|
17
21
|
}
|
|
18
22
|
export {
|
|
19
23
|
b as NavItem
|