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

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 +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,CAiDxC,CAAA"}
@@ -22,11 +22,11 @@ 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
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)
29
+ 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
30
  ? _jsx(Footer, { links: footer, translate: translate, containerClassName: container })
31
31
  : footer != null ? _jsx(Footer, { containerClassName: container, children: footer }) : null] });
32
32
  };
@@ -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":";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,eAAe,EAAE,gBAAgB,EAAE,kBAAkB,EACtD,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,aAkBZ,kBAAQ,SAAS,EAAE,EAAE,CAAC,0DAA0D,EAAE,eAAe,CAAC,aAChG,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"}
@@ -27,6 +27,16 @@ 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
  /**
@@ -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;;;;OAIG;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.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -28,7 +28,7 @@ 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
@@ -38,7 +38,23 @@ export const NavLayout: FC<NavLayoutProps> = ({
38
38
  className={cn('flex min-h-screen flex-col bg-background text-foreground', className)}
39
39
  style={style}
40
40
  >
41
- <header className="sticky top-0 z-40 border-b bg-background">
41
+ {/*
42
+ * The header is a SURFACE, and it states both halves of one.
43
+ *
44
+ * It has to paint an opaque background — it is sticky, and content scrolls underneath —
45
+ * which makes it a different surface from the root behind it. A colour set on the root
46
+ * (`className="bg-secondary text-secondary-foreground"`, a dark application shell) then
47
+ * inherits INTO this bar while its own `bg-background` stays put, and every child that
48
+ * states no colour of its own — the brand, a ghost-variant action button — is painted in
49
+ * the foreground of a surface it is not on. That is light-on-light, it raises nothing at
50
+ * build or run time, and it is invisible only to whoever opens the page.
51
+ *
52
+ * `text-foreground` is what stops the inheritance at the boundary. It is not decoration
53
+ * and it is not redundant with the root: pairing has to be restated by every element that
54
+ * repaints its own background. `headerClassName` lands after it, so an app that wants a
55
+ * dark bar overrides BOTH halves through tailwind-merge.
56
+ */}
57
+ <header className={cn('sticky top-0 z-40 border-b bg-background text-foreground', headerClassName)}>
42
58
  <div className={cn('flex h-14 items-center gap-6', container)}>
43
59
  {title != null ? <a
44
60
  onClick={brandAlias != null ? navigator.press(brandAlias) : undefined}
@@ -30,6 +30,16 @@ 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
  /**
@@ -53,6 +53,12 @@ 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"
56
62
  >{children}</NavLayout>
57
63
 
58
64
  /** 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