@owlmeans/web-panel 0.1.18-rc.11 → 0.1.18-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-panel",
4
- "version": "0.1.18-rc.10",
5
- "generatedAt": "2026-08-25T09:54:15.288Z",
4
+ "version": "0.1.18-rc.13",
5
+ "generatedAt": "2026-08-25T21:16:30.725Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-panel
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.10"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.13"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
@@ -83,11 +83,48 @@ header. Both render the same items; only one is visible at a time.
83
83
 
84
84
  | Component | Props |
85
85
  |---|---|
86
- | `NavLayout` | `nav: PanelNavConfig`, `translate?`, `title?: ReactNode`, `home?: string` (brand target — defaults to the first section's first item), `actions?: ReactNode`, `footer?: PanelNavLink[] \| ReactNode`, `contentClassName?`, `className?`, `style?` |
86
+ | `NavLayout` | `nav: PanelNavConfig`, `translate?`, `title?: ReactNode`, `home?: string` (brand target — defaults to the first section's first item), `actions?: ReactNode`, `footer?: PanelNavLink[] \| ReactNode`, `headerClassName?`, `contentClassName?`, `containerClassName?`, `className?`, `style?` |
87
87
  | `TopNav` | `config: PanelNavConfig`, `translate?`, `ariaLabel?`, `className?`, `style?` |
88
88
  | `SideNav` | the same, plus `variant?: 'side' \| 'bar'` |
89
89
  | `Footer` | `links?: PanelNavLink[]`, `translate?`, `children?`, `className?`, `style?` |
90
90
 
91
+ **The style slots are REGIONS, and each region is its own SURFACE.** `className` is the root —
92
+ the full-height page *behind* the header, side menu and footer. `headerClassName` is the sticky
93
+ top bar. `contentClassName` is the content area. `containerClassName` is width and padding for
94
+ all three at once, never colour.
95
+
96
+ The header paints an opaque background of its own, because it is sticky and content scrolls
97
+ beneath it. That makes it a **different surface from the root**, so it states `text-foreground`
98
+ alongside its `bg-background` — not as decoration, and not redundantly. A root carrying a
99
+ contrasting pair (`className="bg-primary text-primary-foreground"`, an ordinary dark shell)
100
+ otherwise inherits its near-white foreground into a near-white bar, and every header child that
101
+ states no colour of its own — the brand, a ghost-variant action button — is painted in the
102
+ foreground of a surface it is not on. It type-checks, it builds, it renders, and the menu is
103
+ invisible. Pinned by `tests/nav.spec.ts` → "the header is its own surface", which measures
104
+ rendered lightness rather than class names; the harness root carries a dark shell permanently so
105
+ every navigation test runs against that case.
106
+
107
+ **Every style slot is MERGED over its default — none of them substitutes.** `className`,
108
+ `headerClassName`, `contentClassName` and `containerClassName` all go through `cn`, so a caller
109
+ names only the utility it wants to move and tailwind-merge drops just the one it conflicts with.
110
+ This matters most for `containerClassName`, whose default is a four-part rhythm
111
+ (`mx-auto w-full max-w-6xl px-4`): a design asking for a wider page writes `max-w-[1280px]` and
112
+ means *wider*, not *unpadded and uncentred*. Substituting there took `px-4` and `mx-auto` down
113
+ with the width and left the header, content and footer all flush to the window edge. Pinned by
114
+ `nav.spec.ts` → "a width-only rhythm override keeps the side padding"; the harness passes a
115
+ width-only override permanently.
116
+
117
+ **A dark top bar is asked for with `headerClassName`**, giving it both halves
118
+ (`bg-secondary text-secondary-foreground`) — never by colouring the root and expecting the bar
119
+ to follow.
120
+
121
+ **The shell reads exactly five theme variables**: `--background` (page and top bar),
122
+ `--foreground` (active section link), `--muted-foreground` (resting section links) and
123
+ `--accent`/`--accent-foreground` (active side-menu item). It reads **no `--sidebar*` variable at
124
+ all**. So `--muted-foreground` is not merely the text colour of the `--muted` surface — it is
125
+ secondary text sitting directly on `--background`, and a theme that lightens it to suit a dark
126
+ muted panel loses its top menu.
127
+
91
128
  Rules that make the shell behave:
92
129
 
93
130
  - **Labels never reach for i18n implicitly.** `translate` is a **prop** (`NavTranslate`), defaulting
@@ -14,7 +14,7 @@ export const Footer = ({ links, translate = defaultNavTranslate, children, class
14
14
  if ((links == null || links.length < 1) && children == null) {
15
15
  return null;
16
16
  }
17
- return _jsx("footer", { className: cn('border-t py-6', className), style: style, children: _jsxs("div", { className: cn('flex flex-wrap items-center gap-4 text-sm', containerClassName ?? 'px-4'), children: [links?.map((link, idx) => {
17
+ return _jsx("footer", { className: cn('border-t py-6', className), style: style, children: _jsxs("div", { className: cn('flex flex-wrap items-center gap-4 text-sm px-4', containerClassName), children: [links?.map((link, idx) => {
18
18
  const label = resolveNavLabel(translate, link.label, `modules.${link.alias ?? link.href ?? ''}`, link.alias ?? link.href);
19
19
  return link.href != null
20
20
  ? _jsx(Link, { src: link.href, open: link.open, children: label }, `${link.href}:${idx}`)
@@ -1 +1 @@
1
- {"version":3,"file":"component.js","sourceRoot":"","sources":["../../../src/components/footer/component.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7E,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAIjC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,MAAM,GAAoB,CAAC,EACtC,KAAK,EAAE,SAAS,GAAG,mBAAmB,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,kBAAkB,EACvF,EAAE,EAAE;IACH,IAAI,CAAC,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;QAC5D,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,iBAAQ,SAAS,EAAE,EAAE,CAAC,eAAe,EAAE,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,YACpE,eAAK,SAAS,EAAE,EAAE,CAAC,2CAA2C,EAAE,kBAAkB,IAAI,MAAM,CAAC,aAC1F,KAAK,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;oBACxB,MAAM,KAAK,GAAG,eAAe,CAC3B,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,WAAW,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,CAC3F,CAAA;oBAED,OAAO,IAAI,CAAC,IAAI,IAAI,IAAI;wBACtB,CAAC,CAAC,KAAC,IAAI,IAA6B,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,YAAG,KAAK,IAA9D,GAAG,IAAI,CAAC,IAAI,IAAI,GAAG,EAAE,CAAiD;wBACnF,CAAC,CAAC,KAAC,IAAI,IAA8B,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,YAAG,KAAK,IAAnE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,EAAE,CAAqD,CAAA;gBAC5F,CAAC,CAAC,EACD,QAAQ,IACL,GACC,CAAA;AACX,CAAC,CAAA"}
1
+ {"version":3,"file":"component.js","sourceRoot":"","sources":["../../../src/components/footer/component.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7E,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAIjC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,MAAM,GAAoB,CAAC,EACtC,KAAK,EAAE,SAAS,GAAG,mBAAmB,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,kBAAkB,EACvF,EAAE,EAAE;IACH,IAAI,CAAC,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;QAC5D,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,iBAAQ,SAAS,EAAE,EAAE,CAAC,eAAe,EAAE,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,YACpE,eAAK,SAAS,EAAE,EAAE,CAAC,gDAAgD,EAAE,kBAAkB,CAAC,aACrF,KAAK,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;oBACxB,MAAM,KAAK,GAAG,eAAe,CAC3B,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,WAAW,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,CAC3F,CAAA;oBAED,OAAO,IAAI,CAAC,IAAI,IAAI,IAAI;wBACtB,CAAC,CAAC,KAAC,IAAI,IAA6B,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,YAAG,KAAK,IAA9D,GAAG,IAAI,CAAC,IAAI,IAAI,GAAG,EAAE,CAAiD;wBACnF,CAAC,CAAC,KAAC,IAAI,IAA8B,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,YAAG,KAAK,IAAnE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,EAAE,CAAqD,CAAA;gBAC5F,CAAC,CAAC,EACD,QAAQ,IACL,GACC,CAAA;AACX,CAAC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../../src/components/nav/layout.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAO/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAahD;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,EAAE,EAAE,CAAC,cAAc,CAiCxC,CAAA"}
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../../src/components/nav/layout.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAO/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAahD;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,EAAE,EAAE,CAAC,cAAc,CAwDxC,CAAA"}
@@ -10,8 +10,8 @@ import { TopNav } from './top.js';
10
10
  *
11
11
  * It lives in ONE constant because the three regions have to agree: a content area with its own
12
12
  * width sits visibly inset from a full-width header, which reads as a mistake rather than as a
13
- * design. Override it through `containerClassName` — never by giving the content a width of its
14
- * own.
13
+ * design. Adjust it through `containerClassName`, which is MERGED over this — never by giving
14
+ * the content a width of its own.
15
15
  */
16
16
  const CONTAINER = 'mx-auto w-full max-w-6xl px-4';
17
17
  /**
@@ -22,11 +22,18 @@ const CONTAINER = 'mx-auto w-full max-w-6xl px-4';
22
22
  * viewports and one strip for narrow ones. Both render only when the active section has
23
23
  * more than one screen, so the single-screen case costs nothing but the elements' absence.
24
24
  */
25
- export const NavLayout = ({ nav, translate, title, home, actions, footer, children, className, style, contentClassName, containerClassName }) => {
25
+ export const NavLayout = ({ nav, translate, title, home, actions, footer, children, className, style, headerClassName, contentClassName, containerClassName }) => {
26
26
  const navigator = useNavigate();
27
27
  const brandAlias = home ?? nav.sections.find(section => section.items.length > 0)?.items[0]?.alias;
28
- const container = containerClassName ?? CONTAINER;
29
- return _jsxs("div", { className: cn('flex min-h-screen flex-col bg-background text-foreground', className), style: style, children: [_jsxs("header", { className: "sticky top-0 z-40 border-b bg-background", children: [_jsxs("div", { className: cn('flex h-14 items-center gap-6', container), children: [title != null ? _jsx("a", { onClick: brandAlias != null ? navigator.press(brandAlias) : undefined, className: cn('brand flex items-center gap-2 text-lg font-semibold', brandAlias != null && 'cursor-pointer'), children: title }) : null, _jsx(TopNav, { config: nav, translate: translate, ariaLabel: "Sections" }), _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] }), _jsx(SideNav, { config: nav, translate: translate, variant: "bar", ariaLabel: "Screens", className: "md:hidden" })] }), _jsxs("div", { className: "flex flex-1", children: [_jsx(SideNav, { config: nav, translate: translate, variant: "side", ariaLabel: "Screens", className: "hidden md:block" }), _jsx("main", { className: cn('flex-1 py-8', contentClassName), children: _jsx("div", { className: cn(container), children: children }) })] }), Array.isArray(footer)
28
+ // MERGED over the default, never substituted for it. `containerClassName` is how a design
29
+ // adjusts ONE aspect of the rhythm — almost always the width — and a caller passing
30
+ // `max-w-[1280px]` means "wider", not "no padding and no centring". Substituting dropped
31
+ // `px-4` and `mx-auto` along with the width it replaced, which is a page whose header,
32
+ // content and footer all run flush to the window edge. tailwind-merge keeps the override
33
+ // winning on the utility it names and leaves the rest of the rhythm standing, so a width-only
34
+ // value stays a width-only change; `px-8` still overrides the padding when that is the intent.
35
+ const container = cn(CONTAINER, containerClassName);
36
+ return _jsxs("div", { className: cn('flex min-h-screen flex-col bg-background text-foreground', className), style: style, children: [_jsxs("header", { className: cn('sticky top-0 z-40 border-b bg-background text-foreground', headerClassName), children: [_jsxs("div", { className: cn('flex h-14 items-center gap-6', container), children: [title != null ? _jsx("a", { onClick: brandAlias != null ? navigator.press(brandAlias) : undefined, className: cn('brand flex items-center gap-2 text-lg font-semibold', brandAlias != null && 'cursor-pointer'), children: title }) : null, _jsx(TopNav, { config: nav, translate: translate, ariaLabel: "Sections" }), _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] }), _jsx(SideNav, { config: nav, translate: translate, variant: "bar", ariaLabel: "Screens", className: "md:hidden" })] }), _jsxs("div", { className: "flex flex-1", children: [_jsx(SideNav, { config: nav, translate: translate, variant: "side", ariaLabel: "Screens", className: "hidden md:block" }), _jsx("main", { className: cn('flex-1 py-8', contentClassName), children: _jsx("div", { className: cn(container), children: children }) })] }), Array.isArray(footer)
30
37
  ? _jsx(Footer, { links: footer, translate: translate, containerClassName: container })
31
38
  : footer != null ? _jsx(Footer, { containerClassName: container, children: footer }) : null] });
32
39
  };
@@ -1 +1 @@
1
- {"version":3,"file":"layout.js","sourceRoot":"","sources":["../../../src/components/nav/layout.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AAE/C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAGjC;;;;;;;;GAQG;AACH,MAAM,SAAS,GAAG,+BAA+B,CAAA;AAEjD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAuB,CAAC,EAC5C,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EACxE,gBAAgB,EAAE,kBAAkB,EACrC,EAAE,EAAE;IACH,MAAM,SAAS,GAAG,WAAW,EAAE,CAAA;IAC/B,MAAM,UAAU,GAAG,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,CAAA;IAClG,MAAM,SAAS,GAAG,kBAAkB,IAAI,SAAS,CAAA;IAEjD,OAAO,eACL,SAAS,EAAE,EAAE,CAAC,0DAA0D,EAAE,SAAS,CAAC,EACpF,KAAK,EAAE,KAAK,aAEZ,kBAAQ,SAAS,EAAC,0CAA0C,aAC1D,eAAK,SAAS,EAAE,EAAE,CAAC,8BAA8B,EAAE,SAAS,CAAC,aAC1D,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,YACf,OAAO,EAAE,UAAU,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,EACrE,SAAS,EAAE,EAAE,CAAC,qDAAqD,EAAE,UAAU,IAAI,IAAI,IAAI,gBAAgB,CAAC,YAC5G,KAAK,GAAK,CAAC,CAAC,CAAC,IAAI,EACnB,KAAC,MAAM,IAAC,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAC,UAAU,GAAG,EAClE,cAAK,SAAS,EAAC,iCAAiC,YAAE,OAAO,GAAO,IAC5D,EACN,KAAC,OAAO,IAAC,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,OAAO,EAAC,KAAK,EAAC,SAAS,EAAC,SAAS,EAAC,SAAS,EAAC,WAAW,GAAG,IAC/F,EACT,eAAK,SAAS,EAAC,aAAa,aAC1B,KAAC,OAAO,IAAC,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,OAAO,EAAC,MAAM,EAAC,SAAS,EAAC,SAAS,EAAC,SAAS,EAAC,iBAAiB,GAAG,EAC7G,eAAM,SAAS,EAAE,EAAE,CAAC,aAAa,EAAE,gBAAgB,CAAC,YAClD,cAAK,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,YAAG,QAAQ,GAAO,GAC1C,IACH,EACL,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;gBACpB,CAAC,CAAC,KAAC,MAAM,IAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,kBAAkB,EAAE,SAAS,GAAI;gBAChF,CAAC,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,KAAC,MAAM,IAAC,kBAAkB,EAAE,SAAS,YAAG,MAAM,GAAU,CAAC,CAAC,CAAC,IAAI,IAChF,CAAA;AACR,CAAC,CAAA"}
1
+ {"version":3,"file":"layout.js","sourceRoot":"","sources":["../../../src/components/nav/layout.tsx"],"names":[],"mappings":"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"}
@@ -27,12 +27,27 @@ export interface NavLayoutProps extends PropsWithChildren<StyledProps> {
27
27
  actions?: ReactNode;
28
28
  /** Links array renders the standard footer; a node replaces it entirely. */
29
29
  footer?: PanelNavLink[] | ReactNode;
30
+ /**
31
+ * Styles the HEADER — the sticky bar carrying the brand, the section menu and `actions`.
32
+ *
33
+ * The header is its own SURFACE: it paints an opaque background because content scrolls
34
+ * under it. Give it a background here and you must give it the paired foreground too
35
+ * (`bg-secondary text-secondary-foreground`), exactly as on any other surface — this is the
36
+ * supported way to give an application a dark top bar, and it is why colouring the root
37
+ * instead is not.
38
+ */
39
+ headerClassName?: string;
30
40
  /** Styles the content area. NOT its width — see `containerClassName`. */
31
41
  contentClassName?: string;
32
42
  /**
33
43
  * The page's horizontal rhythm — width and side padding — applied identically to the header
34
44
  * row, the content and the footer row. Give the content a width of its own and it sits inset
35
45
  * from a full-width header, which reads as a bug rather than as a layout.
46
+ *
47
+ * MERGED over the shell's default (`mx-auto w-full max-w-6xl px-4`), not substituted for it:
48
+ * pass `max-w-[1280px]` and only the width changes, while the centring and the side padding
49
+ * stay. Name the utility you actually want to move — `px-8` widens the gutters — because
50
+ * anything you do not name keeps its default.
36
51
  */
37
52
  containerClassName?: string;
38
53
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/nav/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACzD,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AACxF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAE9C,UAAU,cAAe,SAAQ,WAAW;IAC1C,MAAM,EAAE,cAAc,CAAA;IACtB,yFAAyF;IACzF,SAAS,CAAC,EAAE,YAAY,CAAA;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,WAAY,SAAQ,cAAc;CAAI;AAEvD,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CACzB;AAED,MAAM,WAAW,cAAe,SAAQ,iBAAiB,CAAC,WAAW,CAAC;IACpE,GAAG,EAAE,cAAc,CAAA;IACnB,SAAS,CAAC,EAAE,YAAY,CAAA;IACxB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yFAAyF;IACzF,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,YAAY,EAAE,GAAG,SAAS,CAAA;IACnC,yEAAyE;IACzE,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/nav/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACzD,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AACxF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAE9C,UAAU,cAAe,SAAQ,WAAW;IAC1C,MAAM,EAAE,cAAc,CAAA;IACtB,yFAAyF;IACzF,SAAS,CAAC,EAAE,YAAY,CAAA;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,WAAY,SAAQ,cAAc;CAAI;AAEvD,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CACzB;AAED,MAAM,WAAW,cAAe,SAAQ,iBAAiB,CAAC,WAAW,CAAC;IACpE,GAAG,EAAE,cAAc,CAAA;IACnB,SAAS,CAAC,EAAE,YAAY,CAAA;IACxB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yFAAyF;IACzF,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,YAAY,EAAE,GAAG,SAAS,CAAA;IACnC;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,yEAAyE;IACzE,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-panel",
3
- "version": "0.1.18-rc.11",
3
+ "version": "0.1.18-rc.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -48,7 +48,7 @@
48
48
  "@owlmeans/client-flow": "^0.1.18-rc.7",
49
49
  "@owlmeans/client-i18n": "^0.1.18-rc.7",
50
50
  "@owlmeans/client-entrypoint": "^0.1.18-rc.7",
51
- "@owlmeans/client-panel": "^0.1.18-rc.11",
51
+ "@owlmeans/client-panel": "^0.1.18-rc.12",
52
52
  "@owlmeans/client-route": "^0.1.18-rc.7",
53
53
  "@owlmeans/config": "^0.1.18-rc.7",
54
54
  "@owlmeans/context": "^0.1.18-rc.6",
@@ -21,7 +21,7 @@ export const Footer: FC<FooterProps> = ({
21
21
  }
22
22
 
23
23
  return <footer className={cn('border-t py-6', className)} style={style}>
24
- <div className={cn('flex flex-wrap items-center gap-4 text-sm', containerClassName ?? 'px-4')}>
24
+ <div className={cn('flex flex-wrap items-center gap-4 text-sm px-4', containerClassName)}>
25
25
  {links?.map((link, idx) => {
26
26
  const label = resolveNavLabel(
27
27
  translate, link.label, `modules.${link.alias ?? link.href ?? ''}`, link.alias ?? link.href
@@ -13,8 +13,8 @@ import type { NavLayoutProps } from './types.js'
13
13
  *
14
14
  * It lives in ONE constant because the three regions have to agree: a content area with its own
15
15
  * width sits visibly inset from a full-width header, which reads as a mistake rather than as a
16
- * design. Override it through `containerClassName` — never by giving the content a width of its
17
- * own.
16
+ * design. Adjust it through `containerClassName`, which is MERGED over this — never by giving
17
+ * the content a width of its own.
18
18
  */
19
19
  const CONTAINER = 'mx-auto w-full max-w-6xl px-4'
20
20
 
@@ -28,17 +28,40 @@ const CONTAINER = 'mx-auto w-full max-w-6xl px-4'
28
28
  */
29
29
  export const NavLayout: FC<NavLayoutProps> = ({
30
30
  nav, translate, title, home, actions, footer, children, className, style,
31
- contentClassName, containerClassName
31
+ headerClassName, contentClassName, containerClassName
32
32
  }) => {
33
33
  const navigator = useNavigate()
34
34
  const brandAlias = home ?? nav.sections.find(section => section.items.length > 0)?.items[0]?.alias
35
- const container = containerClassName ?? CONTAINER
35
+ // MERGED over the default, never substituted for it. `containerClassName` is how a design
36
+ // adjusts ONE aspect of the rhythm — almost always the width — and a caller passing
37
+ // `max-w-[1280px]` means "wider", not "no padding and no centring". Substituting dropped
38
+ // `px-4` and `mx-auto` along with the width it replaced, which is a page whose header,
39
+ // content and footer all run flush to the window edge. tailwind-merge keeps the override
40
+ // winning on the utility it names and leaves the rest of the rhythm standing, so a width-only
41
+ // value stays a width-only change; `px-8` still overrides the padding when that is the intent.
42
+ const container = cn(CONTAINER, containerClassName)
36
43
 
37
44
  return <div
38
45
  className={cn('flex min-h-screen flex-col bg-background text-foreground', className)}
39
46
  style={style}
40
47
  >
41
- <header className="sticky top-0 z-40 border-b bg-background">
48
+ {/*
49
+ * The header is a SURFACE, and it states both halves of one.
50
+ *
51
+ * It has to paint an opaque background — it is sticky, and content scrolls underneath —
52
+ * which makes it a different surface from the root behind it. A colour set on the root
53
+ * (`className="bg-secondary text-secondary-foreground"`, a dark application shell) then
54
+ * inherits INTO this bar while its own `bg-background` stays put, and every child that
55
+ * states no colour of its own — the brand, a ghost-variant action button — is painted in
56
+ * the foreground of a surface it is not on. That is light-on-light, it raises nothing at
57
+ * build or run time, and it is invisible only to whoever opens the page.
58
+ *
59
+ * `text-foreground` is what stops the inheritance at the boundary. It is not decoration
60
+ * and it is not redundant with the root: pairing has to be restated by every element that
61
+ * repaints its own background. `headerClassName` lands after it, so an app that wants a
62
+ * dark bar overrides BOTH halves through tailwind-merge.
63
+ */}
64
+ <header className={cn('sticky top-0 z-40 border-b bg-background text-foreground', headerClassName)}>
42
65
  <div className={cn('flex h-14 items-center gap-6', container)}>
43
66
  {title != null ? <a
44
67
  onClick={brandAlias != null ? navigator.press(brandAlias) : undefined}
@@ -30,12 +30,27 @@ export interface NavLayoutProps extends PropsWithChildren<StyledProps> {
30
30
  actions?: ReactNode
31
31
  /** Links array renders the standard footer; a node replaces it entirely. */
32
32
  footer?: PanelNavLink[] | ReactNode
33
+ /**
34
+ * Styles the HEADER — the sticky bar carrying the brand, the section menu and `actions`.
35
+ *
36
+ * The header is its own SURFACE: it paints an opaque background because content scrolls
37
+ * under it. Give it a background here and you must give it the paired foreground too
38
+ * (`bg-secondary text-secondary-foreground`), exactly as on any other surface — this is the
39
+ * supported way to give an application a dark top bar, and it is why colouring the root
40
+ * instead is not.
41
+ */
42
+ headerClassName?: string
33
43
  /** Styles the content area. NOT its width — see `containerClassName`. */
34
44
  contentClassName?: string
35
45
  /**
36
46
  * The page's horizontal rhythm — width and side padding — applied identically to the header
37
47
  * row, the content and the footer row. Give the content a width of its own and it sits inset
38
48
  * from a full-width header, which reads as a bug rather than as a layout.
49
+ *
50
+ * MERGED over the shell's default (`mx-auto w-full max-w-6xl px-4`), not substituted for it:
51
+ * pass `max-w-[1280px]` and only the width changes, while the centring and the side padding
52
+ * stay. Name the utility you actually want to move — `px-8` widens the gutters — because
53
+ * anything you do not name keeps its default.
39
54
  */
40
55
  containerClassName?: string
41
56
  }
@@ -53,6 +53,16 @@ const Layout: FC<PropsWithChildren> = ({ children }) => <NavLayout
53
53
  title="Harness"
54
54
  actions={<button id="action-slot">action</button>}
55
55
  footer={footerLinks}
56
+ // A DARK APPLICATION SHELL, which is what a themed app does to the root: a contrasting
57
+ // surface pair, both halves correct. The header paints its own opaque background, so it is
58
+ // a different surface, and everything in it must stay legible against `--background`
59
+ // rather than against this. The harness carries it permanently so every navigation test
60
+ // runs against the hostile case instead of a default-coloured page.
61
+ className="bg-primary text-primary-foreground"
62
+ // A WIDTH-ONLY rhythm override, which is what a design pass writes when it wants a wider
63
+ // page. It names the width and nothing else, so the centring and the side padding must
64
+ // survive it — substituting this for the default is a page running flush to the window edge.
65
+ containerClassName="max-w-[1280px]"
56
66
  >{children}</NavLayout>
57
67
 
58
68
  /** A grouping screen — it renders whichever child the router matched. */
package/tests/nav.spec.ts CHANGED
@@ -98,6 +98,88 @@ describe('@owlmeans/web-panel — two-layer navigation', () => {
98
98
  }, TIMEOUT)
99
99
  })
100
100
 
101
+ /**
102
+ * Perceptual lightness of a computed colour, 0 (black) to 1 (white).
103
+ *
104
+ * The theme is authored in `oklch(L C H)` and Chromium hands that back from
105
+ * `getComputedStyle` unchanged — it does not convert to `rgb()`, and neither does a canvas
106
+ * `fillStyle` round-trip — so L is read straight off. `rgb()` still turns up for the
107
+ * transparent case, and is approximated through its luminance.
108
+ *
109
+ * Comparing lightness rather than colour strings is the point: the failure being pinned is
110
+ * not a wrong colour, it is two colours too CLOSE to tell apart, which equality never sees.
111
+ */
112
+ const lightness = (value: string): number => {
113
+ const oklch = /^oklch\(\s*([\d.]+%?)/i.exec(value.trim())
114
+ if (oklch != null) {
115
+ const raw = oklch[1]
116
+
117
+ return raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw)
118
+ }
119
+
120
+ const [r, g, b] = (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
121
+ const channel = (c: number): number => {
122
+ const v = c / 255
123
+
124
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
125
+ }
126
+
127
+ // The cube root maps luminance onto roughly the same perceptual scale as OKLab's L.
128
+ return (0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)) ** (1 / 3)
129
+ }
130
+
131
+ /** The gap below which text stops being readable on its surface — as `ensureReadableTheme`. */
132
+ const READABLE = 0.35
133
+
134
+ describe('@owlmeans/web-panel — the header is its own surface', () => {
135
+ // The harness root carries `bg-primary text-primary-foreground`: a dark application shell,
136
+ // both halves of the pair correct. The header paints `bg-background` over it and is
137
+ // therefore a DIFFERENT surface — a near-white foreground inherited from the root lands on
138
+ // a near-white bar, and every child stating no colour of its own goes invisible. Nothing
139
+ // fails at build or at run time, so this is pinned from outside, on rendered pixels.
140
+ test('header content stays legible when the shell around it is dark', async () => {
141
+ const { page, close } = await open('/dash')
142
+ try {
143
+ await page.waitForSelector('#dash')
144
+ const style = async (selector: string, property: 'color' | 'backgroundColor') =>
145
+ page.locator(selector).first().evaluate(
146
+ (el, prop) => window.getComputedStyle(el)[prop as 'color'], property
147
+ )
148
+
149
+ const surface = lightness(await style('header', 'backgroundColor'))
150
+ const legible = async (selector: string) =>
151
+ Math.abs(lightness(await style(selector, 'color')) - surface) >= READABLE
152
+
153
+ // The brand and the action slot state no colour of their own, so they are exactly the
154
+ // elements that inherit across the boundary. The action slot is the reported case: a
155
+ // ghost-variant button rendered white on a white bar.
156
+ expect({
157
+ brand: await legible('a.brand'),
158
+ actions: await legible('#action-slot'),
159
+ section: await legible('[data-slot="navigation-menu-link"]'),
160
+ }).toEqual({ brand: true, actions: true, section: true })
161
+ } finally {
162
+ await close()
163
+ }
164
+ }, TIMEOUT)
165
+
166
+ test('the header keeps an opaque background of its own', async () => {
167
+ // This is what makes it a separate surface, and therefore what makes the pairing above
168
+ // mandatory rather than redundant: it is sticky, so content scrolls beneath it and a
169
+ // transparent bar would show that content through the menu.
170
+ const { page, close } = await open('/dash')
171
+ try {
172
+ await page.waitForSelector('#dash')
173
+ const background = await page.locator('header').first()
174
+ .evaluate(el => window.getComputedStyle(el).backgroundColor)
175
+
176
+ expect(background).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
177
+ } finally {
178
+ await close()
179
+ }
180
+ }, TIMEOUT)
181
+ })
182
+
101
183
  describe('@owlmeans/web-panel — layout rhythm and link styling', () => {
102
184
  test('header, content and footer share one horizontal rhythm', async () => {
103
185
  // A content area with a width of its own sits visibly inset from a full-width header, which
@@ -121,6 +203,34 @@ describe('@owlmeans/web-panel — layout rhythm and link styling', () => {
121
203
  }
122
204
  }, TIMEOUT)
123
205
 
206
+ test('a width-only rhythm override keeps the side padding', async () => {
207
+ // The harness passes `containerClassName="max-w-[1280px]"` — a width and nothing else.
208
+ // `containerClassName` is merged over the shell's default rhythm rather than substituted
209
+ // for it, so `px-4` and `mx-auto` survive an override that never mentioned them. Replacing
210
+ // instead produced a header, content and footer all flush to the window edge.
211
+ const { page, close } = await open('/prefs')
212
+ try {
213
+ await page.waitForSelector('#prefs')
214
+ const padding = async (selector: string) => page.locator(selector).first().evaluate(el => {
215
+ const style = window.getComputedStyle(el)
216
+
217
+ return [parseFloat(style.paddingLeft), parseFloat(style.paddingRight)]
218
+ })
219
+
220
+ const [header, content, footer] = await Promise.all([
221
+ padding('header > div'), padding('main > div'), padding('footer > div'),
222
+ ])
223
+
224
+ expect({
225
+ header: header.every(value => value > 0),
226
+ content: content.every(value => value > 0),
227
+ footer: footer.every(value => value > 0),
228
+ }).toEqual({ header: true, content: true, footer: true })
229
+ } finally {
230
+ await close()
231
+ }
232
+ }, TIMEOUT)
233
+
124
234
  test('section entries render as links, not buttons', async () => {
125
235
  const { page, close } = await open('/dash')
126
236
  try {