@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.
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-panel/SKILL.md +39 -2
- package/build/components/footer/component.js +1 -1
- package/build/components/footer/component.js.map +1 -1
- package/build/components/nav/layout.d.ts.map +1 -1
- package/build/components/nav/layout.js +12 -5
- package/build/components/nav/layout.js.map +1 -1
- package/build/components/nav/types.d.ts +15 -0
- package/build/components/nav/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/footer/component.tsx +1 -1
- package/src/components/nav/layout.tsx +28 -5
- package/src/components/nav/types.ts +15 -0
- package/tests/harness/mount.tsx +10 -0
- package/tests/nav.spec.ts +110 -0
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/web-panel",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-08-
|
|
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.
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/tests/harness/mount.tsx
CHANGED
|
@@ -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 {
|