cursedbelt 5.9.0 → 5.10.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.
@@ -0,0 +1,65 @@
1
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
2
+ /** What an `<a>` in the bar is handed — `href`, `onClick`, whatever the app's router needs. */
3
+ export type TopBarLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children' | 'className' | 'aria-label' | 'href'>;
4
+ export interface TopBarItem {
5
+ /** Stable key. */
6
+ id: string;
7
+ /** The place's name. It is the accessible name and the hover hint — it is NOT painted. */
8
+ label: string;
9
+ /** The glyph that IS the item. Required: an item without one cannot be drawn in this bar. */
10
+ icon: ReactNode;
11
+ /** The place the user is in. Lit, and `aria-current="page"`. */
12
+ active?: boolean;
13
+ /** Where it goes. A real link, so it still copies and middle-clicks. */
14
+ href: string;
15
+ /** `onClick` and whatever else the app's router needs on the `<a>`. */
16
+ linkProps?: TopBarLinkProps;
17
+ /**
18
+ * 🔴 The purposefully made exception: paint `label` beside the icon. Takes the REASON,
19
+ * not a boolean — the owner's rule (2026-09-25) is icons, not words, "unless there is a
20
+ * purposefully made exception", and a `showLabel` flag is an exception nobody had to make.
21
+ * Even then the words drop below `sm`, because a phone is where they overflowed.
22
+ */
23
+ wordsBecause?: string;
24
+ }
25
+ export interface TopBarProps {
26
+ /** The app's mark. Its `label` is painted from `md` up and is the accessible name below it. */
27
+ brand: {
28
+ icon: ReactNode;
29
+ label: string;
30
+ href: string;
31
+ linkProps?: TopBarLinkProps;
32
+ };
33
+ /** The places. Icons only — see {@link TopBarItem.wordsBecause} for the one way out. */
34
+ items: readonly TopBarItem[];
35
+ /** The trailing cluster: theme toggle, account menu. Never shrinks. */
36
+ trailing?: ReactNode;
37
+ /** The nav landmark's name. Default `'Places'`. */
38
+ navLabel?: string;
39
+ className?: string;
40
+ }
41
+ /**
42
+ * THE top bar: brand, places, trailing controls — one row that can never be wider than the
43
+ * screen.
44
+ *
45
+ * ── Why it exists (owner, 2026-09-25) ───────────────────────────────────────────────────
46
+ * *"the music app header on iphone goes off the right side of the screen. It should use
47
+ * icons instead of words and it should not be allowed to make the screen have a wide
48
+ * scrollbar … cb should enforce this via a top bar component so each app cannot do it wrong
49
+ * with separately made components."* Every app had hand-rolled its own `<header><nav>`, and
50
+ * music's — "♪ Music · Library · Visualize · YouTube · Add music · Downloads" plus a theme
51
+ * toggle and an avatar — was ~560px of words on a 390px phone, so the whole page scrolled
52
+ * sideways.
53
+ *
54
+ * Two properties are built in rather than asked for:
55
+ * · **Icons, not words.** An item cannot be declared without an icon, and its label is
56
+ * the accessible name + hover hint, never painted — unless `wordsBecause` says why.
57
+ * · **It cannot widen the page.** The row is `min-w-0 max-w-full`; the places take the
58
+ * remaining width and, if a phone is narrower still, scroll INSIDE their own strip.
59
+ * Brand and trailing controls never shrink, so the account menu is always reachable.
60
+ *
61
+ * `guardrails no-raw-nav` is the other half: a hand-rolled `<nav>` in app source fails the
62
+ * app's gate, so the next header is this one.
63
+ */
64
+ export declare function TopBar({ brand, items, trailing, navLabel, className }: TopBarProps): import("react").JSX.Element;
65
+ //# sourceMappingURL=TopBar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopBar.d.ts","sourceRoot":"","sources":["../../../src/react/nav/TopBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAO7D,+FAA+F;AAC/F,MAAM,MAAM,eAAe,GAAG,IAAI,CAChC,oBAAoB,CAAC,iBAAiB,CAAC,EACvC,UAAU,GAAG,WAAW,GAAG,YAAY,GAAG,MAAM,CACjD,CAAC;AAEF,MAAM,WAAW,UAAU;IACzB,kBAAkB;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC;IACd,6FAA6F;IAC7F,IAAI,EAAE,SAAS,CAAC;IAChB,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,+FAA+F;IAC/F,KAAK,EAAE;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,eAAe,CAAA;KAAE,CAAC;IACrF,wFAAwF;IACxF,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;IAC7B,uEAAuE;IACvE,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAmB,EAAE,SAAS,EAAE,EAAE,WAAW,+BAiD7F"}
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ScrollArea } from '../components/ScrollArea.js';
3
+ import { Tooltip } from '../components/Tooltip.js';
4
+ import { useScrollEdgeFade } from '../hooks/useScrollEdgeFade.js';
5
+ import { cn } from '../lib/cn.js';
6
+ import { TAP_TARGET_SQUARE } from '../lib/tapTarget.js';
7
+ /**
8
+ * THE top bar: brand, places, trailing controls — one row that can never be wider than the
9
+ * screen.
10
+ *
11
+ * ── Why it exists (owner, 2026-09-25) ───────────────────────────────────────────────────
12
+ * *"the music app header on iphone goes off the right side of the screen. It should use
13
+ * icons instead of words and it should not be allowed to make the screen have a wide
14
+ * scrollbar … cb should enforce this via a top bar component so each app cannot do it wrong
15
+ * with separately made components."* Every app had hand-rolled its own `<header><nav>`, and
16
+ * music's — "♪ Music · Library · Visualize · YouTube · Add music · Downloads" plus a theme
17
+ * toggle and an avatar — was ~560px of words on a 390px phone, so the whole page scrolled
18
+ * sideways.
19
+ *
20
+ * Two properties are built in rather than asked for:
21
+ * · **Icons, not words.** An item cannot be declared without an icon, and its label is
22
+ * the accessible name + hover hint, never painted — unless `wordsBecause` says why.
23
+ * · **It cannot widen the page.** The row is `min-w-0 max-w-full`; the places take the
24
+ * remaining width and, if a phone is narrower still, scroll INSIDE their own strip.
25
+ * Brand and trailing controls never shrink, so the account menu is always reachable.
26
+ *
27
+ * `guardrails no-raw-nav` is the other half: a hand-rolled `<nav>` in app source fails the
28
+ * app's gate, so the next header is this one.
29
+ */
30
+ export function TopBar({ brand, items, trailing, navLabel = 'Places', className }) {
31
+ const fade = useScrollEdgeFade();
32
+ return (_jsxs("header", { "data-cb-topbar": '', className: cn('flex w-full min-w-0 max-w-full items-center gap-2 px-3 py-1.5 md:gap-3 md:px-6', className), children: [_jsxs("a", { ...brand.linkProps, href: brand.href, "aria-label": brand.label, "data-cb-topbar-brand": '', className: cn('flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 font-semibold text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', TAP_TARGET_SQUARE, 'md:pointer-coarse:w-auto'), children: [_jsx("span", { "aria-hidden": 'true', className: 'grid size-6 place-items-center [&_svg]:size-5', children: brand.icon }), _jsx("span", { "aria-hidden": 'true', className: 'hidden md:inline', children: brand.label })] }), _jsx("nav", { "aria-label": navLabel, "data-cb-topbar-nav": '', className: 'min-w-0 flex-1', style: fade.maskStyle, children: _jsx(ScrollArea, { axis: 'x', space: 'none', safeArea: false, scrollRef: fade.ref, onScroll: fade.onScroll, className: 'w-full [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', children: _jsx("div", { className: 'flex w-max items-center gap-1', children: items.map((item) => (_jsx(TopBarLink, { item: item }, item.id))) }) }) }), trailing ? _jsx("div", { className: 'flex shrink-0 items-center gap-1', children: trailing }) : null] }));
33
+ }
34
+ function TopBarLink({ item }) {
35
+ const words = Boolean(item.wordsBecause?.trim());
36
+ return (_jsx(Tooltip, { content: item.label, side: 'bottom', children: _jsxs("a", { ...item.linkProps, href: item.href, "aria-label": item.label, "aria-current": item.active ? 'page' : undefined, "data-active": item.active ? '' : undefined, className: cn('relative flex h-9 min-w-9 shrink-0 items-center justify-center gap-2 rounded-md px-2 transition-colors', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', TAP_TARGET_SQUARE, words && 'sm:pointer-coarse:w-auto', item.active
37
+ ? 'bg-muted text-foreground'
38
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx("span", { "aria-hidden": 'true', className: 'grid size-5 place-items-center [&_svg]:size-5', children: item.icon }), words ? (_jsx("span", { "aria-hidden": 'true', className: 'hidden text-sm font-medium sm:inline', children: item.label })) : null] }) }));
39
+ }
40
+ //# sourceMappingURL=TopBar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopBar.js","sourceRoot":"","sources":["../../../src/react/nav/TopBar.tsx"],"names":[],"mappings":"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"}
@@ -16,4 +16,5 @@ export { type IconResolver, isPathWithin, NavLink, type NavLinkProps, type NavNa
16
16
  export { PlaceBar, type PlaceBarProps } from './PlaceBar.js';
17
17
  export { type SearchResultGroup, type SearchResultRow, SearchResults, type SearchResultsProps, searchOptionId, } from './SearchResults.js';
18
18
  export { SideNav, type SideNavProps } from './SideNav.js';
19
+ export { TopBar, type TopBarItem, type TopBarLinkProps, type TopBarProps } from './TopBar.js';
19
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/nav/index.ts"],"names":[],"mappings":"AAQA,cAAc,4BAA4B,CAAC;AAC3C,OAAO,EACL,WAAW,EACX,KAAK,gBAAgB,EACrB,eAAe,EACf,KAAK,oBAAoB,EACzB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,kBAAkB,EAClB,KAAK,uBAAuB,EAC5B,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,KAAK,mBAAmB,GACzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,KAAK,eAAe,EACpB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,aAAa,EACb,aAAa,EACb,KAAK,qBAAqB,EAC1B,QAAQ,EACR,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,YAAY,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EACL,KAAK,YAAY,EACjB,YAAY,EACZ,OAAO,EACP,KAAK,YAAY,EACjB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,aAAa,EACb,KAAK,kBAAkB,EACvB,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/nav/index.ts"],"names":[],"mappings":"AAQA,cAAc,4BAA4B,CAAC;AAC3C,OAAO,EACL,WAAW,EACX,KAAK,gBAAgB,EACrB,eAAe,EACf,KAAK,oBAAoB,EACzB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,kBAAkB,EAClB,KAAK,uBAAuB,EAC5B,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,KAAK,mBAAmB,GACzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,KAAK,eAAe,EACpB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,aAAa,EACb,aAAa,EACb,KAAK,qBAAqB,EAC1B,QAAQ,EACR,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,YAAY,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EACL,KAAK,YAAY,EACjB,YAAY,EACZ,OAAO,EACP,KAAK,YAAY,EACjB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,aAAa,EACb,KAAK,kBAAkB,EACvB,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC"}
@@ -23,4 +23,5 @@ export { isPathWithin, NavLink, } from './navShared.js';
23
23
  export { PlaceBar } from './PlaceBar.js';
24
24
  export { SearchResults, searchOptionId, } from './SearchResults.js';
25
25
  export { SideNav } from './SideNav.js';
26
+ export { TopBar } from './TopBar.js';
26
27
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/nav/index.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,iGAAiG;AACjG,sFAAsF;AACtF,mFAAmF;AACnF,mFAAmF;AACnF,oFAAoF;AACpF,6FAA6F;AAE7F,cAAc,4BAA4B,CAAC;AAC3C,OAAO,EACL,WAAW,EAEX,eAAe,EAEf,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,kBAAkB,EAElB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,GAEpB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,qBAAqB,GAEtB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAKL,aAAa,EACb,aAAa,EAEb,QAAQ,EAGR,YAAY,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAAE,UAAU,EAAwB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,YAAY,EAA0B,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,WAAW,EAAyB,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAA4B,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAyB,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAEL,YAAY,EACZ,OAAO,GAGR,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAsB,MAAM,eAAe,CAAC;AAC7D,OAAO,EAGL,aAAa,EAEb,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAqB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/nav/index.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,iGAAiG;AACjG,sFAAsF;AACtF,mFAAmF;AACnF,mFAAmF;AACnF,oFAAoF;AACpF,6FAA6F;AAE7F,cAAc,4BAA4B,CAAC;AAC3C,OAAO,EACL,WAAW,EAEX,eAAe,EAEf,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EACL,kBAAkB,EAElB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,GAEpB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,qBAAqB,GAEtB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAKL,aAAa,EACb,aAAa,EAEb,QAAQ,EAGR,YAAY,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAA6B,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAAE,UAAU,EAAwB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,YAAY,EAA0B,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,WAAW,EAAyB,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAA4B,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAyB,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAEL,YAAY,EACZ,OAAO,GAGR,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAsB,MAAM,eAAe,CAAC;AAC7D,OAAO,EAGL,aAAa,EAEb,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAqB,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,MAAM,EAA2D,MAAM,aAAa,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AA8LA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AA+4BD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAEpE;AAghBD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAmBtC,CAAC;AASF,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAmGhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
1
+ {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AAkMA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AA+4BD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAEpE;AAgkBD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAoBtC,CAAC;AASF,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAmGhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
@@ -18,14 +18,15 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 99 candidate(s).
21
+ * 107 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("-mt-5 -top-1 [&>*]:min-w-0 [&_svg]:size-3.5 bg-accent/15 border-border/40 border-dashed border-l");
28
- @source inline("border-r collapse cursor-grab data-[state=checked]:ring-2 data-[state=checked]:ring-foreground");
27
+ @source inline("-mt-5 -top-1 [&::-webkit-scrollbar]:hidden [&>*]:min-w-0 [&_svg]:size-3.5 [scrollbar-width:none]");
28
+ @source inline("bg-accent/15 border-border/40 border-dashed border-l border-r collapse cursor-grab");
29
+ @source inline("data-[state=checked]:ring-2 data-[state=checked]:ring-foreground");
29
30
  @source inline("data-[state=checked]:ring-offset-1 data-[state=closed]:slide-out-to-bottom");
30
31
  @source inline("data-[state=closed]:slide-out-to-left data-[state=closed]:slide-out-to-right");
31
32
  @source inline("data-[state=open]:slide-in-from-bottom data-[state=open]:slide-in-from-left");
@@ -36,8 +37,9 @@
36
37
  @source inline("items-stretch justify-around justify-start lead leading-4 left-0 left-3 lg:flex lg:flex-col");
37
38
  @source inline("lg:hidden lg:shrink-0 line-clamp-2 max-h-72 max-h-[40dvh] max-h-[65dvh] max-h-[85dvh]");
38
39
  @source inline("max-h-[inherit] max-sm:flex-none max-sm:justify-center max-sm:px-0 max-sm:w-10 max-w max-w-64");
39
- @source inline("max-w-[12rem] max-w-[20rem] md:flex md:flex-col md:hidden md:shrink-0 min-h-12 min-h-dvh");
40
- @source inline("min-w-3.5 min-w-8 mix-blend-difference mt-auto opacity-55 opacity-80 pb-1 pb-2");
41
- @source inline("pb-[env(safe-area-inset-bottom,0px)] pl-9 place-items-center pr-9 pt-1 px-0.5 right-1");
42
- @source inline("rounded-l-xl rounded-r-xl rounded-t-xl shadow-lg size-7 size-9 sm:inline sm:inline-flex");
43
- @source inline("space-y-0.5 space-y-2 text-[0.625rem] top-2 w-0.5 w-52 w-56 w-60");
40
+ @source inline("max-w-[12rem] max-w-[20rem] md:flex md:flex-col md:gap-3 md:hidden md:pointer-coarse:w-auto");
41
+ @source inline("md:px-6 md:shrink-0 min-h-12 min-h-dvh min-w-3.5 min-w-8 min-w-9 mix-blend-difference mt-auto");
42
+ @source inline("opacity-55 opacity-80 pb-1 pb-2 pb-[env(safe-area-inset-bottom,0px)] pl-9 place-items-center");
43
+ @source inline("pr-9 pt-1 px-0.5 right-1 rounded-l-xl rounded-r-xl rounded-t-xl shadow-lg size-7 size-9");
44
+ @source inline("sm:inline sm:inline-flex sm:pointer-coarse:w-auto space-y-0.5 space-y-2 text-[0.625rem] top-2");
45
+ @source inline("w-0.5 w-52 w-56 w-60 w-max");
@@ -1015,6 +1015,9 @@
1015
1015
  .min-w-8 {
1016
1016
  min-width: calc(var(--spacing) * 8);
1017
1017
  }
1018
+ .min-w-9 {
1019
+ min-width: calc(var(--spacing) * 9);
1020
+ }
1018
1021
  .min-w-10 {
1019
1022
  min-width: calc(var(--spacing) * 10);
1020
1023
  }
@@ -4080,6 +4083,9 @@
4080
4083
  .md\:flex-row {
4081
4084
  flex-direction: row;
4082
4085
  }
4086
+ .md\:gap-3 {
4087
+ gap: calc(var(--spacing) * 3);
4088
+ }
4083
4089
  :where(.md\:divide-x > :not(:last-child)) {
4084
4090
  --tw-divide-x-reverse: 0;
4085
4091
  border-inline-style: var(--tw-border-style);
@@ -4089,6 +4095,9 @@
4089
4095
  :where(.md\:divide-border > :not(:last-child)) {
4090
4096
  border-color: var(--border);
4091
4097
  }
4098
+ .md\:px-6 {
4099
+ padding-inline: calc(var(--spacing) * 6);
4100
+ }
4092
4101
  }
4093
4102
  @media (width >= 64rem) {
4094
4103
  .lg\:inset-8 {
@@ -4313,6 +4322,20 @@
4313
4322
  translate: var(--tw-translate-x) var(--tw-translate-y);
4314
4323
  }
4315
4324
  }
4325
+ @media (width >= 40rem) {
4326
+ @media (pointer: coarse) {
4327
+ .sm\:pointer-coarse\:w-auto {
4328
+ width: auto;
4329
+ }
4330
+ }
4331
+ }
4332
+ @media (width >= 48rem) {
4333
+ @media (pointer: coarse) {
4334
+ .md\:pointer-coarse\:w-auto {
4335
+ width: auto;
4336
+ }
4337
+ }
4338
+ }
4316
4339
  .\[\&_\.alert-icon\]\:text-accent .alert-icon {
4317
4340
  color: var(--accent);
4318
4341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.9.0",
3
+ "version": "5.10.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -692,8 +692,8 @@
692
692
  "@types/react-dom": "^19.2.3",
693
693
  "@vidstack/react": "1.15.6",
694
694
  "@vitejs/plugin-react": "^5.2.0",
695
- "cursedbelt-server": "^4.13.0",
696
- "cursedops": "^0.8.1",
695
+ "cursedbelt-server": "^4.32.1",
696
+ "cursedops": "^0.10.7",
697
697
  "diff": "^9.0.0",
698
698
  "emoji-mart": "^5.6.0",
699
699
  "hls.js": "^1.5.0",
@@ -1219,3 +1219,27 @@ const html = \`<input type="password">\`;`,
1219
1219
  expect(check('no-raw-password', `// never write type="password" by hand`)).toHaveLength(0);
1220
1220
  });
1221
1221
  });
1222
+
1223
+ describe('no-raw-nav', () => {
1224
+ test('🔴 flags a hand-rolled <nav> — the header music shipped on 2026-09-25', () => {
1225
+ expect(
1226
+ check('no-raw-nav', `<header><nav className="app-header__nav"><a href="/">Library</a></nav></header>`),
1227
+ ).toHaveLength(1);
1228
+ });
1229
+
1230
+ test('passes TopBar, and a nav with a stated reason in the block above', () => {
1231
+ expect(check('no-raw-nav', `<TopBar brand={brand} items={items} />`)).toHaveLength(0);
1232
+ expect(
1233
+ check(
1234
+ 'no-raw-nav',
1235
+ `{/* guardrails-ignore no-raw-nav: the library rail is a side list of folders,
1236
+ not a top bar */}
1237
+ <nav aria-label="Library">`,
1238
+ ),
1239
+ ).toHaveLength(0);
1240
+ });
1241
+
1242
+ test("a comment that merely mentions <nav> is not a violation", () => {
1243
+ expect(check('no-raw-nav', `// replacing the <nav> would delete the landmark`)).toHaveLength(0);
1244
+ });
1245
+ });
@@ -126,6 +126,10 @@
126
126
  // `type="password"` on ANY tag or props object (the `<Input type="password">` form that
127
127
  // `no-raw-inputs` cannot see) and in template-literal HTML. Use `SecretInput`.
128
128
  //
129
+ // no-raw-nav — owner, 2026-09-25: no hand-rolled `<nav>` in app source. An app's top bar
130
+ // is `TopBar` (icons, not words, and it cannot widen the page); the other landmarks are
131
+ // `SideNav`, `PlaceBar`, `BreadcrumbTrail`. Anything else takes the opt-out with a reason.
132
+ //
129
133
  // Opt-out (mirrors this repo's existing `biome-ignore lint/<rule>: <reason>` convention —
130
134
  // see e.g. packages/cursedbelt/src/react/components/ButtonGroup.tsx): put a same-shaped
131
135
  // line comment as the FIRST thing inside the tag's attribute list (for `no-raw-action-button`
@@ -1670,6 +1674,54 @@ const noRawPassword: Rule = {
1670
1674
  },
1671
1675
  };
1672
1676
 
1677
+ // ── no-raw-nav ──────────────────────────────────────────────────────────────
1678
+ // The owner, 2026-09-25: *"the music app header on iphone goes off the right side of the
1679
+ // screen. It should use icons instead of words … cb should enforce this via a top bar
1680
+ // component so each app cannot do it wrong with separately made components."*
1681
+ //
1682
+ // Every app had hand-rolled its own `<header><nav>`, and each one re-decided — usually
1683
+ // wrongly — whether its places were words, how wide they could get, and what happens on a
1684
+ // 390px phone. `TopBar` (cursedbelt/react) decides all three once: icons only unless an item
1685
+ // states `wordsBecause`, clamped to the screen, the places scrolling inside their own strip.
1686
+ // `SideNav`, `PlaceBar` and `BreadcrumbTrail` are the belt's other navigation landmarks.
1687
+ //
1688
+ // The rule is on the ELEMENT, for the reason `no-raw-data-table` gives: "is this hand-rolled
1689
+ // nav icon-only and clamped?" is not decidable by a scanner, and a rule that guesses is a
1690
+ // rule people route around. A nav that genuinely is not a top bar (a library rail, a
1691
+ // settings section list) takes the opt-out with its reason in the comment block above.
1692
+ // cursedbelt's own `react/nav/` subtree emits the element and is exempt by construction.
1693
+ const RAW_NAV_RE = /<nav\b/g;
1694
+ const RAW_NAV_IGNORE_RE = /guardrails-ignore\s+no-raw-nav\s*:/;
1695
+
1696
+ const noRawNav: Rule = {
1697
+ name: "no-raw-nav",
1698
+ description:
1699
+ "Owner, 2026-09-25: no hand-rolled <nav> — an app's top bar is cursedbelt's `TopBar` (icons, not words; it cannot widen the page), and its other landmarks are `SideNav`, `PlaceBar` or `BreadcrumbTrail`. A nav that is none of those takes the opt-out with its reason.",
1700
+ check(filePath, source) {
1701
+ const normalized = filePath.replace(/\\/g, "/");
1702
+ if (normalized.includes("react/nav/")) return [];
1703
+ if (isTestSource(normalized)) return [];
1704
+ const violations: Violation[] = [];
1705
+ const scannable = blankComments(source);
1706
+ const lines = source.split("\n");
1707
+ RAW_NAV_RE.lastIndex = 0;
1708
+ for (const match of scannable.matchAll(RAW_NAV_RE)) {
1709
+ const line = lineOf(source, match.index ?? 0);
1710
+ // The contiguous comment block above, same as no-raw-data-table: a real reason runs
1711
+ // to several lines and the marker sits on its first.
1712
+ const block: string[] = [];
1713
+ for (let i = line - 2; i >= 0 && block.length < 12; i--) {
1714
+ const raw = lines[i] ?? "";
1715
+ if (raw.trim() === "") break;
1716
+ block.push(raw);
1717
+ }
1718
+ if (RAW_NAV_IGNORE_RE.test(block.join("\n")) || RAW_NAV_IGNORE_RE.test(lines[line - 1] ?? "")) continue;
1719
+ violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
1720
+ }
1721
+ return violations;
1722
+ },
1723
+ };
1724
+
1673
1725
  export const RULES: Record<string, Rule> = {
1674
1726
  [noRawActionButton.name]: noRawActionButton,
1675
1727
  [noRawOverflow.name]: noRawOverflow,
@@ -1689,6 +1741,7 @@ export const RULES: Record<string, Rule> = {
1689
1741
  [noNativeDialog.name]: noNativeDialog,
1690
1742
  [noFastTooltip.name]: noFastTooltip,
1691
1743
  [noRawPassword.name]: noRawPassword,
1744
+ [noRawNav.name]: noRawNav,
1692
1745
  };
1693
1746
 
1694
1747
  // ── File-scoped allowances ──────────────────────────────────────────────────────
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { TopBar, type TopBarItem } from './TopBar.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const Glyph = ({ name }: { name: string }) => <svg data-glyph={name} />;
8
+
9
+ const items: TopBarItem[] = [
10
+ { id: 'library', label: 'Library', icon: <Glyph name='library' />, active: true, href: '/' },
11
+ { id: 'downloads', label: 'Downloads', icon: <Glyph name='downloads' />, href: '/downloads' },
12
+ ];
13
+
14
+ const brand = { icon: <Glyph name='brand' />, label: 'Music', href: '/' };
15
+
16
+ describe('TopBar — icons, not words (owner, 2026-09-25)', () => {
17
+ test('🔴 an item paints its ICON and no words; the label is its accessible name', () => {
18
+ const { getByRole } = render(<TopBar brand={brand} items={items} />);
19
+ const link = getByRole('link', { name: 'Downloads' });
20
+ expect(link.querySelector('[data-glyph="downloads"]')).not.toBeNull();
21
+ // No text at all inside the link — the word lives only in aria-label.
22
+ expect(link.textContent).toBe('');
23
+ expect(link.getAttribute('href')).toBe('/downloads');
24
+ });
25
+
26
+ test('the active place is lit and announced as the current page', () => {
27
+ const { getByRole } = render(<TopBar brand={brand} items={items} />);
28
+ expect(getByRole('link', { name: 'Library' }).getAttribute('aria-current')).toBe('page');
29
+ expect(getByRole('link', { name: 'Downloads' }).getAttribute('aria-current')).toBeNull();
30
+ });
31
+
32
+ test('words appear ONLY with a stated reason, and even then drop below sm', () => {
33
+ const withReason: TopBarItem[] = [{ ...items[1], wordsBecause: 'two identical glyphs side by side' }];
34
+ const { getByRole } = render(<TopBar brand={brand} items={withReason} />);
35
+ const words = Array.from(getByRole('link', { name: 'Downloads' }).querySelectorAll('span')).find(
36
+ (s) => s.textContent === 'Downloads',
37
+ );
38
+ expect(words?.className).toContain('hidden');
39
+ expect(words?.className).toContain('sm:inline');
40
+ const blank: TopBarItem[] = [{ ...items[1], wordsBecause: ' ' }];
41
+ cleanup();
42
+ const again = render(<TopBar brand={brand} items={blank} />);
43
+ expect(again.getByRole('link', { name: 'Downloads' }).textContent).toBe('');
44
+ });
45
+
46
+ test('🔴 the row cannot widen the page: clamped, and the places scroll inside themselves', () => {
47
+ const { container } = render(<TopBar brand={brand} items={items} trailing={<span data-trailing=''>x</span>} />);
48
+ const bar = container.querySelector('[data-cb-topbar]') as HTMLElement;
49
+ expect(bar.className).toContain('min-w-0');
50
+ expect(bar.className).toContain('max-w-full');
51
+ const nav = container.querySelector('[data-cb-topbar-nav]') as HTMLElement;
52
+ expect(nav.className).toContain('min-w-0');
53
+ // The places sit in a sideways ScrollArea inside the nav — the strip scrolls, never the page.
54
+ expect(nav.querySelector('[data-cb-scrollarea]')).not.toBeNull();
55
+ // Brand and trailing controls never shrink — the account menu stays reachable.
56
+ const brandLink = container.querySelector('[data-cb-topbar-brand]') as HTMLElement;
57
+ expect(brandLink.className).toContain('shrink-0');
58
+ expect((nav.nextElementSibling as HTMLElement).className).toContain('shrink-0');
59
+ });
60
+
61
+ test('the brand is a link named by its label; its word is painted only from md up', () => {
62
+ const { getByRole } = render(<TopBar brand={brand} items={items} />);
63
+ const link = getByRole('link', { name: 'Music' });
64
+ const word = Array.from(link.querySelectorAll('span')).find((s) => s.textContent === 'Music');
65
+ expect(word?.className).toContain('hidden md:inline');
66
+ });
67
+
68
+ test('link props pass through — an SPA router can intercept the click', () => {
69
+ let clicked = '';
70
+ const routed: TopBarItem[] = [
71
+ { ...items[1], href: '/downloads', linkProps: { onClick: (e) => { e.preventDefault(); clicked = 'downloads'; } } },
72
+ ];
73
+ const { getByRole } = render(<TopBar brand={brand} items={routed} />);
74
+ fireEvent.click(getByRole('link', { name: 'Downloads' }));
75
+ expect(clicked).toBe('downloads');
76
+ });
77
+ });
@@ -0,0 +1,153 @@
1
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
2
+ import { ScrollArea } from '../components/ScrollArea.js';
3
+ import { Tooltip } from '../components/Tooltip.js';
4
+ import { useScrollEdgeFade } from '../hooks/useScrollEdgeFade.js';
5
+ import { cn } from '../lib/cn.js';
6
+ import { TAP_TARGET_SQUARE } from '../lib/tapTarget.js';
7
+
8
+ /** What an `<a>` in the bar is handed — `href`, `onClick`, whatever the app's router needs. */
9
+ export type TopBarLinkProps = Omit<
10
+ AnchorHTMLAttributes<HTMLAnchorElement>,
11
+ 'children' | 'className' | 'aria-label' | 'href'
12
+ >;
13
+
14
+ export interface TopBarItem {
15
+ /** Stable key. */
16
+ id: string;
17
+ /** The place's name. It is the accessible name and the hover hint — it is NOT painted. */
18
+ label: string;
19
+ /** The glyph that IS the item. Required: an item without one cannot be drawn in this bar. */
20
+ icon: ReactNode;
21
+ /** The place the user is in. Lit, and `aria-current="page"`. */
22
+ active?: boolean;
23
+ /** Where it goes. A real link, so it still copies and middle-clicks. */
24
+ href: string;
25
+ /** `onClick` and whatever else the app's router needs on the `<a>`. */
26
+ linkProps?: TopBarLinkProps;
27
+ /**
28
+ * 🔴 The purposefully made exception: paint `label` beside the icon. Takes the REASON,
29
+ * not a boolean — the owner's rule (2026-09-25) is icons, not words, "unless there is a
30
+ * purposefully made exception", and a `showLabel` flag is an exception nobody had to make.
31
+ * Even then the words drop below `sm`, because a phone is where they overflowed.
32
+ */
33
+ wordsBecause?: string;
34
+ }
35
+
36
+ export interface TopBarProps {
37
+ /** The app's mark. Its `label` is painted from `md` up and is the accessible name below it. */
38
+ brand: { icon: ReactNode; label: string; href: string; linkProps?: TopBarLinkProps };
39
+ /** The places. Icons only — see {@link TopBarItem.wordsBecause} for the one way out. */
40
+ items: readonly TopBarItem[];
41
+ /** The trailing cluster: theme toggle, account menu. Never shrinks. */
42
+ trailing?: ReactNode;
43
+ /** The nav landmark's name. Default `'Places'`. */
44
+ navLabel?: string;
45
+ className?: string;
46
+ }
47
+
48
+ /**
49
+ * THE top bar: brand, places, trailing controls — one row that can never be wider than the
50
+ * screen.
51
+ *
52
+ * ── Why it exists (owner, 2026-09-25) ───────────────────────────────────────────────────
53
+ * *"the music app header on iphone goes off the right side of the screen. It should use
54
+ * icons instead of words and it should not be allowed to make the screen have a wide
55
+ * scrollbar … cb should enforce this via a top bar component so each app cannot do it wrong
56
+ * with separately made components."* Every app had hand-rolled its own `<header><nav>`, and
57
+ * music's — "♪ Music · Library · Visualize · YouTube · Add music · Downloads" plus a theme
58
+ * toggle and an avatar — was ~560px of words on a 390px phone, so the whole page scrolled
59
+ * sideways.
60
+ *
61
+ * Two properties are built in rather than asked for:
62
+ * · **Icons, not words.** An item cannot be declared without an icon, and its label is
63
+ * the accessible name + hover hint, never painted — unless `wordsBecause` says why.
64
+ * · **It cannot widen the page.** The row is `min-w-0 max-w-full`; the places take the
65
+ * remaining width and, if a phone is narrower still, scroll INSIDE their own strip.
66
+ * Brand and trailing controls never shrink, so the account menu is always reachable.
67
+ *
68
+ * `guardrails no-raw-nav` is the other half: a hand-rolled `<nav>` in app source fails the
69
+ * app's gate, so the next header is this one.
70
+ */
71
+ export function TopBar({ brand, items, trailing, navLabel = 'Places', className }: TopBarProps) {
72
+ const fade = useScrollEdgeFade();
73
+ return (
74
+ <header
75
+ data-cb-topbar=''
76
+ className={cn('flex w-full min-w-0 max-w-full items-center gap-2 px-3 py-1.5 md:gap-3 md:px-6', className)}
77
+ >
78
+ <a
79
+ {...brand.linkProps}
80
+ href={brand.href}
81
+ aria-label={brand.label}
82
+ data-cb-topbar-brand=''
83
+ className={cn(
84
+ 'flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 font-semibold text-foreground',
85
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
86
+ TAP_TARGET_SQUARE,
87
+ 'md:pointer-coarse:w-auto',
88
+ )}
89
+ >
90
+ <span aria-hidden='true' className='grid size-6 place-items-center [&_svg]:size-5'>
91
+ {brand.icon}
92
+ </span>
93
+ <span aria-hidden='true' className='hidden md:inline'>
94
+ {brand.label}
95
+ </span>
96
+ </a>
97
+
98
+ <nav aria-label={navLabel} data-cb-topbar-nav='' className='min-w-0 flex-1' style={fade.maskStyle}>
99
+ {/* The places scroll INSIDE their own strip, so the page never does — with the
100
+ belt's one edge-fade cue saying there is more, as TagStrip and DataTable do. */}
101
+ <ScrollArea
102
+ axis='x'
103
+ space='none'
104
+ safeArea={false}
105
+ scrollRef={fade.ref}
106
+ onScroll={fade.onScroll}
107
+ className='w-full [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
108
+ >
109
+ <div className='flex w-max items-center gap-1'>
110
+ {items.map((item) => (
111
+ <TopBarLink key={item.id} item={item} />
112
+ ))}
113
+ </div>
114
+ </ScrollArea>
115
+ </nav>
116
+
117
+ {trailing ? <div className='flex shrink-0 items-center gap-1'>{trailing}</div> : null}
118
+ </header>
119
+ );
120
+ }
121
+
122
+ function TopBarLink({ item }: { item: TopBarItem }) {
123
+ const words = Boolean(item.wordsBecause?.trim());
124
+ return (
125
+ <Tooltip content={item.label} side='bottom'>
126
+ <a
127
+ {...item.linkProps}
128
+ href={item.href}
129
+ aria-label={item.label}
130
+ aria-current={item.active ? 'page' : undefined}
131
+ data-active={item.active ? '' : undefined}
132
+ className={cn(
133
+ 'relative flex h-9 min-w-9 shrink-0 items-center justify-center gap-2 rounded-md px-2 transition-colors',
134
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
135
+ TAP_TARGET_SQUARE,
136
+ words && 'sm:pointer-coarse:w-auto',
137
+ item.active
138
+ ? 'bg-muted text-foreground'
139
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
140
+ )}
141
+ >
142
+ <span aria-hidden='true' className='grid size-5 place-items-center [&_svg]:size-5'>
143
+ {item.icon}
144
+ </span>
145
+ {words ? (
146
+ <span aria-hidden='true' className='hidden text-sm font-medium sm:inline'>
147
+ {item.label}
148
+ </span>
149
+ ) : null}
150
+ </a>
151
+ </Tooltip>
152
+ );
153
+ }
@@ -71,3 +71,4 @@ export {
71
71
  searchOptionId,
72
72
  } from './SearchResults.js';
73
73
  export { SideNav, type SideNavProps } from './SideNav.js';
74
+ export { TopBar, type TopBarItem, type TopBarLinkProps, type TopBarProps } from './TopBar.js';
@@ -18,14 +18,15 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 99 candidate(s).
21
+ * 107 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("-mt-5 -top-1 [&>*]:min-w-0 [&_svg]:size-3.5 bg-accent/15 border-border/40 border-dashed border-l");
28
- @source inline("border-r collapse cursor-grab data-[state=checked]:ring-2 data-[state=checked]:ring-foreground");
27
+ @source inline("-mt-5 -top-1 [&::-webkit-scrollbar]:hidden [&>*]:min-w-0 [&_svg]:size-3.5 [scrollbar-width:none]");
28
+ @source inline("bg-accent/15 border-border/40 border-dashed border-l border-r collapse cursor-grab");
29
+ @source inline("data-[state=checked]:ring-2 data-[state=checked]:ring-foreground");
29
30
  @source inline("data-[state=checked]:ring-offset-1 data-[state=closed]:slide-out-to-bottom");
30
31
  @source inline("data-[state=closed]:slide-out-to-left data-[state=closed]:slide-out-to-right");
31
32
  @source inline("data-[state=open]:slide-in-from-bottom data-[state=open]:slide-in-from-left");
@@ -36,8 +37,9 @@
36
37
  @source inline("items-stretch justify-around justify-start lead leading-4 left-0 left-3 lg:flex lg:flex-col");
37
38
  @source inline("lg:hidden lg:shrink-0 line-clamp-2 max-h-72 max-h-[40dvh] max-h-[65dvh] max-h-[85dvh]");
38
39
  @source inline("max-h-[inherit] max-sm:flex-none max-sm:justify-center max-sm:px-0 max-sm:w-10 max-w max-w-64");
39
- @source inline("max-w-[12rem] max-w-[20rem] md:flex md:flex-col md:hidden md:shrink-0 min-h-12 min-h-dvh");
40
- @source inline("min-w-3.5 min-w-8 mix-blend-difference mt-auto opacity-55 opacity-80 pb-1 pb-2");
41
- @source inline("pb-[env(safe-area-inset-bottom,0px)] pl-9 place-items-center pr-9 pt-1 px-0.5 right-1");
42
- @source inline("rounded-l-xl rounded-r-xl rounded-t-xl shadow-lg size-7 size-9 sm:inline sm:inline-flex");
43
- @source inline("space-y-0.5 space-y-2 text-[0.625rem] top-2 w-0.5 w-52 w-56 w-60");
40
+ @source inline("max-w-[12rem] max-w-[20rem] md:flex md:flex-col md:gap-3 md:hidden md:pointer-coarse:w-auto");
41
+ @source inline("md:px-6 md:shrink-0 min-h-12 min-h-dvh min-w-3.5 min-w-8 min-w-9 mix-blend-difference mt-auto");
42
+ @source inline("opacity-55 opacity-80 pb-1 pb-2 pb-[env(safe-area-inset-bottom,0px)] pl-9 place-items-center");
43
+ @source inline("pr-9 pt-1 px-0.5 right-1 rounded-l-xl rounded-r-xl rounded-t-xl shadow-lg size-7 size-9");
44
+ @source inline("sm:inline sm:inline-flex sm:pointer-coarse:w-auto space-y-0.5 space-y-2 text-[0.625rem] top-2");
45
+ @source inline("w-0.5 w-52 w-56 w-60 w-max");
@@ -1015,6 +1015,9 @@
1015
1015
  .min-w-8 {
1016
1016
  min-width: calc(var(--spacing) * 8);
1017
1017
  }
1018
+ .min-w-9 {
1019
+ min-width: calc(var(--spacing) * 9);
1020
+ }
1018
1021
  .min-w-10 {
1019
1022
  min-width: calc(var(--spacing) * 10);
1020
1023
  }
@@ -4080,6 +4083,9 @@
4080
4083
  .md\:flex-row {
4081
4084
  flex-direction: row;
4082
4085
  }
4086
+ .md\:gap-3 {
4087
+ gap: calc(var(--spacing) * 3);
4088
+ }
4083
4089
  :where(.md\:divide-x > :not(:last-child)) {
4084
4090
  --tw-divide-x-reverse: 0;
4085
4091
  border-inline-style: var(--tw-border-style);
@@ -4089,6 +4095,9 @@
4089
4095
  :where(.md\:divide-border > :not(:last-child)) {
4090
4096
  border-color: var(--border);
4091
4097
  }
4098
+ .md\:px-6 {
4099
+ padding-inline: calc(var(--spacing) * 6);
4100
+ }
4092
4101
  }
4093
4102
  @media (width >= 64rem) {
4094
4103
  .lg\:inset-8 {
@@ -4313,6 +4322,20 @@
4313
4322
  translate: var(--tw-translate-x) var(--tw-translate-y);
4314
4323
  }
4315
4324
  }
4325
+ @media (width >= 40rem) {
4326
+ @media (pointer: coarse) {
4327
+ .sm\:pointer-coarse\:w-auto {
4328
+ width: auto;
4329
+ }
4330
+ }
4331
+ }
4332
+ @media (width >= 48rem) {
4333
+ @media (pointer: coarse) {
4334
+ .md\:pointer-coarse\:w-auto {
4335
+ width: auto;
4336
+ }
4337
+ }
4338
+ }
4316
4339
  .\[\&_\.alert-icon\]\:text-accent .alert-icon {
4317
4340
  color: var(--accent);
4318
4341
  }