@machfivetechchicago/machvive-neodigm55-ux 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -74,7 +74,7 @@ is the difference that makes a theme builder possible:
74
74
  ```js
75
75
  import { installTheme, themeCss } from '@machfivetechchicago/machvive-neodigm55-ux/conductor';
76
76
 
77
- installTheme(); // --n55-brand, --n55-brand-deep, --n55-brand-glyph, …
77
+ installTheme(); // --n55-brand, --n55-brand-deep, --n55-brand-ink, …
78
78
  themeCss(myPalette, '.promo'); // or emit a stylesheet for a section, or for a build
79
79
  ```
80
80
 
@@ -82,6 +82,28 @@ Override `--n55-brand` on a page and every component follows, with no rebuild an
82
82
  component aware it happened. `disabled` doubles as the opt-out: an element themed
83
83
  `disabled` is never choreographed.
84
84
 
85
+ Text on a theme colour uses `--n55-<name>-ink` (and `-deep-ink` on the deep shade):
86
+ near-black or white, whichever contrasts more, computed by `inkFor()` so a custom
87
+ palette stays readable. `contrast()` is exported for auditing a palette in CI.
88
+
89
+ ### Light and dark
90
+
91
+ Components follow the `color-scheme` they inherit, per instance, with no JavaScript.
92
+ Surfaces that are not a theme colour — Buffet's top row, an unthemed selection — come
93
+ from `--n55-surface`, `--n55-surface-ink`, `--n55-line`, `--n55-inverse` and
94
+ `--n55-inverse-ink`, each a `light-dark()` pair.
95
+
96
+ ```html
97
+ <html style="color-scheme: light dark"> <!-- follow the OS -->
98
+ <html data-n55-scheme="dark"> <!-- pin the page -->
99
+ <aside data-n55-scheme="dark"> <!-- or one section -->
100
+ <neodigm-buffet data-n55-scheme="light"> <!-- or one instance -->
101
+ ```
102
+
103
+ The palette never sets `color-scheme` on `:root` itself, because that also restyles
104
+ the page's scrollbars and form controls. Theme colours and their inks are the same in
105
+ both modes.
106
+
85
107
  ## Toast
86
108
 
87
109
  ```html
@@ -99,6 +121,35 @@ Queued, with the original's debounce — the same message twice in a row is the
99
121
  notification. `|` breaks the line. **Message text is never parsed as HTML**, unlike the
100
122
  original's `innerHTML`, because toast text is routinely built from user input.
101
123
 
124
+ ## Buffet
125
+
126
+ A full-width, two-level menu driven by metadata. The top row divides its
127
+ container's width evenly; the selection's children sit below at half the height.
128
+ Both levels keep a selection, and each item remembers its last child.
129
+
130
+ ```html
131
+ <neodigm-buffet data-n55-theme="brand" data-n55-buffet='[
132
+ {"token":"shows","caption":"Shows","selected":true,"secondary":[
133
+ {"token":"live","caption":"Live"},{"token":"vod","caption":"VOD"}]},
134
+ {"token":"people","caption":"People","count":3,"theme":"marcom"}
135
+ ]'></neodigm-buffet>
136
+ ```
137
+
138
+ ```js
139
+ const menu = document.querySelector('neodigm-buffet');
140
+ menu.addEventListener('neodigm-buffet-select', (e) => {
141
+ if (hasUnsavedWork()) e.preventDefault(); // cancelable, at either level
142
+ else route(e.detail.primary, e.detail.secondary);
143
+ });
144
+ menu.select('shows', 'vod'); // sync to a route, silently
145
+ menu.items = relabelled; // selection survives where tokens do
146
+ menu.updateCount('people', 4);
147
+ ```
148
+
149
+ The theme on the host colours the whole menu, so a sweep moves across it as one
150
+ instrument. An item's own `theme` is an accent that a sweep does not reach. Height
151
+ is `--n55-buffet-height` (default `64px`). Sticky positioning is left to the page.
152
+
102
153
  ## What changed in the port
103
154
 
104
155
  | | Original | Here |
package/index.d.ts CHANGED
@@ -4,4 +4,6 @@ export { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText } from './src/wc/ne
4
4
  export type { MarqueeCell } from './src/wc/neodigm-marquee/neodigm-marquee.js';
5
5
  export { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom } from './src/wc/neodigm-tulip/neodigm-tulip.js';
6
6
  export type { TulipConfig } from './src/wc/neodigm-tulip/neodigm-tulip.js';
7
+ export { NeodigmBuffet, readItems } from './src/wc/neodigm-buffet/neodigm-buffet.js';
8
+ export type { BuffetItem, BuffetSelection, BuffetSelectDetail } from './src/wc/neodigm-buffet/neodigm-buffet.js';
7
9
  export * from './src/conductor/index.js';
package/index.js CHANGED
@@ -10,12 +10,15 @@
10
10
  import { NeodigmToast, DEFAULT_DURATION } from './src/wc/neodigm-toast/neodigm-toast.js';
11
11
  import { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText } from './src/wc/neodigm-marquee/neodigm-marquee.js';
12
12
  import { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom } from './src/wc/neodigm-tulip/neodigm-tulip.js';
13
+ import { NeodigmBuffet, readItems } from './src/wc/neodigm-buffet/neodigm-buffet.js';
13
14
 
14
15
  export { NeodigmToast, DEFAULT_DURATION };
15
16
  export { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText };
16
17
  export { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom };
18
+ export { NeodigmBuffet, readItems };
17
19
  export {
18
20
  THEMES, THEME_NAMES, OPT_OUT, THEME_ATTR, PULSE_ATTR,
21
+ INK_DARK, INK_LIGHT, contrast, inkFor, NEUTRALS, SCHEME_ATTR,
19
22
  themeCss, installTheme, resolveTheme,
20
23
  motionAllowed, setMotionAllowed, pause, play, onMotionChange, resetMotion,
21
24
  robinTheme, pulseTheme, flashTitle, resetTitle
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@machfivetechchicago/machvive-neodigm55-ux",
3
- "version": "0.1.0",
4
- "description": "Neodigm 55 as dependency-free Web Components \u2014 a page-level choreographer that moves colour and attention across every themed element, plus the components it conducts.",
3
+ "version": "0.1.2",
4
+ "description": "Neodigm 55 as dependency-free Web Components — a page-level choreographer that moves colour and attention across every themed element, plus the components it conducts.",
5
5
  "keywords": [
6
6
  "web-components",
7
7
  "webcomponents",
@@ -12,6 +12,8 @@
12
12
  "theme",
13
13
  "toast",
14
14
  "snackbar",
15
+ "navigation",
16
+ "menu",
15
17
  "neodigm",
16
18
  "no-dependencies",
17
19
  "esm"
@@ -41,6 +43,10 @@
41
43
  "types": "./src/wc/neodigm-tulip/neodigm-tulip.d.ts",
42
44
  "default": "./src/wc/neodigm-tulip/neodigm-tulip.js"
43
45
  },
46
+ "./buffet": {
47
+ "types": "./src/wc/neodigm-buffet/neodigm-buffet.d.ts",
48
+ "default": "./src/wc/neodigm-buffet/neodigm-buffet.js"
49
+ },
44
50
  "./package.json": "./package.json"
45
51
  },
46
52
  "sideEffects": true,
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export {
11
11
  THEMES, THEME_NAMES, OPT_OUT, THEME_ATTR, PULSE_ATTR,
12
+ INK_DARK, INK_LIGHT, contrast, inkFor, NEUTRALS, SCHEME_ATTR,
12
13
  themeCss, installTheme, resolveTheme
13
14
  } from './theme.js';
14
15
 
@@ -1,12 +1,25 @@
1
1
  export interface NeodigmTheme {
2
2
  /** The theme's colour. */
3
3
  color: string;
4
- /** Its darker companion, for text on the colour or for hover. */
4
+ /** Its darker companion, for a second surface or for hover. */
5
5
  deep: string;
6
6
  /** The emoji the tab title flashes. */
7
7
  glyph: string;
8
+ /** Text on `color`. Computed with `inkFor` when omitted. */
9
+ ink?: string;
10
+ /** Text on `deep`. Computed with `inkFor` when omitted. */
11
+ deepInk?: string;
8
12
  }
9
13
 
14
+ /** The components' neutral dark ink. */
15
+ export const INK_DARK: '#1a1a1a';
16
+ /** White ink. */
17
+ export const INK_LIGHT: '#fff';
18
+ /** WCAG contrast ratio between two hex colours, 1–21; NaN if either is not hex. Pure. */
19
+ export function contrast(a: string, b: string): number;
20
+ /** INK_DARK or INK_LIGHT, whichever contrasts more with `surface`. Pure. */
21
+ export function inkFor(surface: string): '#1a1a1a' | '#fff';
22
+
10
23
  /** The thirteen Neodigm 55 themes, in round-robin order. */
11
24
  export const THEMES: Readonly<Record<string, NeodigmTheme>>;
12
25
  export const THEME_NAMES: readonly string[];
@@ -16,15 +29,39 @@ export const OPT_OUT: 'disabled';
16
29
  export const THEME_ATTR: 'data-n55-theme';
17
30
  export const PULSE_ATTR: 'data-n55-theme-pulse';
18
31
 
32
+ /** A `[light, dark]` pair, emitted as `light-dark(light, dark)`. */
33
+ export type SchemePair = readonly [light: string, dark: string];
34
+
35
+ /**
36
+ * The page surfaces components paint when not wearing a theme, as
37
+ * `--n55-surface`, `--n55-surface-ink`, `--n55-line`, `--n55-inverse`,
38
+ * `--n55-inverse-ink`. Each resolves against the using element's color-scheme.
39
+ */
40
+ export const NEUTRALS: Readonly<{
41
+ surface: SchemePair;
42
+ surfaceInk: SchemePair;
43
+ line: SchemePair;
44
+ inverse: SchemePair;
45
+ inverseInk: SchemePair;
46
+ }>;
47
+
48
+ /** `data-n55-scheme="light|dark"` pins a subtree, or one component, to a mode. */
49
+ export const SCHEME_ATTR: 'data-n55-scheme';
50
+
19
51
  /** Renders the palette as CSS custom properties, for a stylesheet or a builder. */
20
- export function themeCss(themes?: Record<string, NeodigmTheme>, selector?: string): string;
52
+ export function themeCss(
53
+ themes?: Record<string, NeodigmTheme>,
54
+ selector?: string,
55
+ neutrals?: Record<string, SchemePair>
56
+ ): string;
21
57
 
22
58
  /** Puts the palette on the document. Idempotent; a second call replaces the first. */
23
59
  export function installTheme(options?: {
24
60
  themes?: Record<string, NeodigmTheme>;
25
61
  selector?: string;
62
+ neutrals?: Record<string, SchemePair>;
26
63
  doc?: Document;
27
64
  }): HTMLStyleElement | null;
28
65
 
29
- /** Reads a theme's *resolved* values, honouring any page override. */
66
+ /** Reads a theme's *resolved* values, honouring any page override. Inks always present. */
30
67
  export function resolveTheme(name: string, options?: { doc?: Document }): NeodigmTheme | null;
@@ -11,13 +11,24 @@
11
11
  * follows, with no rebuild and no component aware it happened.
12
12
  *
13
13
  * Each theme carries three values, matching the original's `[light, dark, glyph]`
14
- * triple:
15
- * --n55-<name> the colour
16
- * --n55-<name>-deep its darker companion, for text on the colour or hover
17
- * --n55-<name>-glyph the emoji the tab title flashes (see flashTitle)
14
+ * triple, plus two inks derived from them:
15
+ * --n55-<name> the colour
16
+ * --n55-<name>-deep its darker companion, for a second surface or hover
17
+ * --n55-<name>-glyph the emoji the tab title flashes (see flashTitle)
18
+ * --n55-<name>-ink text on the colour
19
+ * --n55-<name>-deep-ink text on the deep companion
20
+ *
21
+ * The inks exist because `-deep` was used as text on the colour, and measured
22
+ * that falls below WCAG AA (4.5:1) on twelve of the thirteen themes — party
23
+ * manages 1.76. Each ink is whichever of two neutrals contrasts more with its
24
+ * surface, computed rather than hand-picked so a custom palette gets readable
25
+ * text without anyone measuring it.
18
26
  */
19
27
 
20
- /** @typedef {{ color: string, deep: string, glyph: string }} NeodigmTheme */
28
+ /**
29
+ * @typedef {{ color: string, deep: string, glyph: string,
30
+ * ink?: string, deepInk?: string }} NeodigmTheme
31
+ */
21
32
 
22
33
  /**
23
34
  * Ported verbatim from `N55_THEME_COLORS` so an existing page's colours do not
@@ -42,6 +53,83 @@ export const THEMES = Object.freeze({
42
53
  party: { color: '#FF6F61', deep: '#C93F60', glyph: '🟪' }
43
54
  });
44
55
 
56
+ /** The two inks a surface can take: the components' neutral dark, and white. */
57
+ export const INK_DARK = '#1a1a1a';
58
+ export const INK_LIGHT = '#fff';
59
+
60
+ /** WCAG relative luminance of `#rgb` or `#rrggbb`; null for anything else. */
61
+ function luminance(hex) {
62
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex).trim());
63
+ if (!m) return null;
64
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
65
+ const [r, g, b] = [0, 2, 4].map((i) => {
66
+ const v = parseInt(h.slice(i, i + 2), 16) / 255;
67
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
68
+ });
69
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
70
+ }
71
+
72
+ /**
73
+ * WCAG contrast ratio between two hex colours, 1 to 21. Pure, so a theme
74
+ * builder or a CI check can audit a palette with no DOM. NaN if either is not
75
+ * a hex colour.
76
+ */
77
+ export function contrast(a, b) {
78
+ const [la, lb] = [luminance(a), luminance(b)];
79
+ if (la === null || lb === null) return NaN;
80
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
81
+ }
82
+
83
+ /**
84
+ * The readable ink for text on `surface`: INK_DARK or INK_LIGHT, whichever
85
+ * contrasts more. A surface that is not a hex colour gets INK_DARK — there is
86
+ * nothing to measure, so the palette author should pass `ink` explicitly.
87
+ */
88
+ export function inkFor(surface) {
89
+ const dark = contrast(INK_DARK, surface);
90
+ if (Number.isNaN(dark)) return INK_DARK;
91
+ return contrast(INK_LIGHT, surface) > dark ? INK_LIGHT : INK_DARK;
92
+ }
93
+
94
+ /**
95
+ * The page's own surfaces, as opposed to a theme's: what a component paints
96
+ * when it is not wearing a theme. Each is a `[light, dark]` pair, emitted as
97
+ * `light-dark(light, dark)`.
98
+ *
99
+ * That function resolves on the element that *uses* the variable, against the
100
+ * `color-scheme` that element inherits — and `color-scheme` inherits through a
101
+ * shadow boundary just as custom properties do. So every instance works out
102
+ * its own mode where it stands, with no listener and no JavaScript: a dark
103
+ * sidebar in a light page needs nothing but `color-scheme: dark` on the
104
+ * sidebar. A browser without `light-dark()` drops the declaration and the
105
+ * component's own `var()` fallback — the light value — applies.
106
+ *
107
+ * --n55-surface a resting surface, such as Buffet's top row
108
+ * --n55-surface-ink text on it
109
+ * --n55-line hairlines and dividers on it
110
+ * --n55-inverse the emphasis surface when there is no theme
111
+ * --n55-inverse-ink text on that
112
+ */
113
+ export const NEUTRALS = Object.freeze({
114
+ surface: ['#fff', '#1a1a1a'],
115
+ surfaceInk: ['#1a1a1a', '#fff'],
116
+ line: ['rgb(0 0 0 / .12)', 'rgb(255 255 255 / .16)'],
117
+ inverse: ['#1a1a1a', '#e8e8e8'],
118
+ inverseInk: ['#fff', '#1a1a1a']
119
+ });
120
+
121
+ /**
122
+ * The attribute that pins a subtree to light or dark: `data-n55-scheme="dark"`
123
+ * on `<html>`, a section, or a single component. Without it, components follow
124
+ * whatever `color-scheme` the page already sets — `light dark` follows the OS.
125
+ * The palette deliberately does not set `color-scheme` on `:root` itself: that
126
+ * also restyles the page's own scrollbars and form controls, which is the
127
+ * page's decision.
128
+ */
129
+ export const SCHEME_ATTR = 'data-n55-scheme';
130
+
131
+ const kebab = (key) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
132
+
45
133
  /** Theme names in round-robin order. */
46
134
  export const THEME_NAMES = Object.freeze(Object.keys(THEMES));
47
135
 
@@ -68,15 +156,22 @@ export const PULSE_ATTR = 'data-n55-theme-pulse';
68
156
  * @param {Record<string, NeodigmTheme>} [themes]
69
157
  * @param {string} [selector] Where to hang the variables. A section-scoped
70
158
  * selector gives that subtree its own palette, since custom properties cascade.
159
+ * @param {Record<string, [string, string]>} [neutrals] `[light, dark]` pairs.
71
160
  * @returns {string}
72
161
  */
73
- export function themeCss(themes = THEMES, selector = ':root') {
162
+ export function themeCss(themes = THEMES, selector = ':root', neutrals = NEUTRALS) {
74
163
  const lines = Object.entries(themes).flatMap(([name, t]) => [
75
164
  ` --n55-${name}: ${t.color};`,
76
165
  ` --n55-${name}-deep: ${t.deep};`,
77
- ` --n55-${name}-glyph: "${t.glyph}";`
166
+ ` --n55-${name}-glyph: "${t.glyph}";`,
167
+ ` --n55-${name}-ink: ${t.ink ?? inkFor(t.color)};`,
168
+ ` --n55-${name}-deep-ink: ${t.deepInk ?? inkFor(t.deep)};`
78
169
  ]);
79
- return `${selector} {\n${lines.join('\n')}\n}\n`;
170
+ const surfaces = Object.entries(neutrals).map(([key, [light, dark]]) =>
171
+ ` --n55-${kebab(key)}: light-dark(${light}, ${dark});`);
172
+ return `${selector} {\n${[...lines, ...surfaces].join('\n')}\n}\n` +
173
+ `[${SCHEME_ATTR}="light"] { color-scheme: light; }\n` +
174
+ `[${SCHEME_ATTR}="dark"] { color-scheme: dark; }\n`;
80
175
  }
81
176
 
82
177
  /** The id of the stylesheet this installs, so a second call can replace it. */
@@ -88,14 +183,17 @@ const STYLE_ID = 'n55-theme-tokens';
88
183
  * Idempotent: calling it again replaces the stylesheet rather than stacking a
89
184
  * second one, which is what lets a theme builder preview a palette live.
90
185
  *
91
- * @param {{ themes?: Record<string, NeodigmTheme>, selector?: string, doc?: Document }} [options]
186
+ * @param {{ themes?: Record<string, NeodigmTheme>, selector?: string,
187
+ * neutrals?: Record<string, [string, string]>, doc?: Document }} [options]
92
188
  * @returns {HTMLStyleElement|null} null where there is no document.
93
189
  */
94
- export function installTheme({ themes = THEMES, selector = ':root', doc = globalThis.document } = {}) {
190
+ export function installTheme({
191
+ themes = THEMES, selector = ':root', neutrals = NEUTRALS, doc = globalThis.document
192
+ } = {}) {
95
193
  if (!doc?.head) return null;
96
194
  const style = doc.getElementById(STYLE_ID) ?? doc.createElement('style');
97
195
  style.id = STYLE_ID;
98
- style.textContent = themeCss(themes, selector);
196
+ style.textContent = themeCss(themes, selector, neutrals);
99
197
  if (!style.isConnected) doc.head.append(style);
100
198
  return style;
101
199
  }
@@ -115,13 +213,18 @@ export function installTheme({ themes = THEMES, selector = ':root', doc = global
115
213
  export function resolveTheme(name, { doc = globalThis.document } = {}) {
116
214
  if (!THEMES[name]) return null;
117
215
  const root = doc?.documentElement;
118
- if (!root || typeof globalThis.getComputedStyle !== 'function') return THEMES[name];
216
+ const built = THEMES[name];
217
+ if (!root || typeof globalThis.getComputedStyle !== 'function') {
218
+ return { ...built, ink: inkFor(built.color), deepInk: inkFor(built.deep) };
219
+ }
119
220
  const read = (suffix) =>
120
221
  globalThis.getComputedStyle(root).getPropertyValue(`--n55-${name}${suffix}`).trim();
121
222
  return {
122
223
  color: read('') || THEMES[name].color,
123
224
  deep: read('-deep') || THEMES[name].deep,
124
225
  // The glyph round-trips through CSS as a quoted string.
125
- glyph: (read('-glyph') || '').replace(/^["']|["']$/g, '') || THEMES[name].glyph
226
+ glyph: (read('-glyph') || '').replace(/^["']|["']$/g, '') || THEMES[name].glyph,
227
+ ink: read('-ink') || inkFor(THEMES[name].color),
228
+ deepInk: read('-deep-ink') || inkFor(THEMES[name].deep)
126
229
  };
127
230
  }
@@ -0,0 +1,59 @@
1
+ /** One menu entry. The schema `m5t-primary-nav` uses, so existing data drops in. */
2
+ export interface BuffetItem {
3
+ /** Unique identifier. Selection, events and `updateCount` are keyed by it. */
4
+ token: string;
5
+ /** Display text. Falls back to the token. Shown as text, never parsed as markup. */
6
+ caption?: string;
7
+ /** Only `false` disables; a missing value means enabled. */
8
+ enabled?: boolean;
9
+ /** Selects this item initially. */
10
+ selected?: boolean;
11
+ /** Accepted for compatibility and ignored. Icons are deferred; no icon fonts. */
12
+ icon?: string;
13
+ /** Shown as the native tooltip. */
14
+ tagline?: string;
15
+ /** A badge. */
16
+ count?: number;
17
+ /** A palette theme used as this item's accent. Not reached by sweeps. */
18
+ theme?: string;
19
+ /** Children, shown in the half-height row when this item is selected. */
20
+ secondary?: BuffetItem[];
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface BuffetSelection {
25
+ primary: string | null;
26
+ secondary: string | null;
27
+ }
28
+
29
+ export interface BuffetSelectDetail extends BuffetSelection {
30
+ level: 'primary' | 'secondary';
31
+ token: string;
32
+ item: BuffetItem;
33
+ }
34
+
35
+ /** Parses metadata: JSON or an array. Never throws; drops items without a token. */
36
+ export function readItems(raw: string | unknown[] | null): BuffetItem[];
37
+
38
+ export class NeodigmBuffet extends HTMLElement {
39
+ /** The metadata, as a copy. Assign to change it; selection survives where tokens do. */
40
+ items: BuffetItem[];
41
+ /** The current selection at both levels. */
42
+ readonly selection: BuffetSelection;
43
+ /**
44
+ * Selects without dispatching an event, for keeping in step with a route.
45
+ * Omit `secondary` for the child last chosen under `primary`.
46
+ * False if either token is unknown or disabled.
47
+ */
48
+ select(primary: string, secondary?: string | null): boolean;
49
+ /** Updates a badge in place; `null` removes it. False if no item has that token. */
50
+ updateCount(token: string, count: number | null): boolean;
51
+ }
52
+
53
+ declare global {
54
+ interface HTMLElementTagNameMap { 'neodigm-buffet': NeodigmBuffet }
55
+ interface HTMLElementEventMap {
56
+ /** Cancelable. Fires on every click, including the current selection. */
57
+ 'neodigm-buffet-select': CustomEvent<BuffetSelectDetail>;
58
+ }
59
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * `<neodigm-buffet>` — a full-width, two-level menu, driven by metadata.
3
+ *
4
+ * The top row splits the width of its container evenly between its buttons.
5
+ * Below it sits the selected button's children, at half the height and in a
6
+ * smaller type. Both levels keep a selection, and each top-level button
7
+ * remembers which child was last chosen under it.
8
+ *
9
+ * ```html
10
+ * <neodigm-buffet data-n55-theme="brand" data-n55-buffet='[
11
+ * {"token":"shows","caption":"Shows","selected":true,"secondary":[
12
+ * {"token":"live","caption":"Live"},{"token":"vod","caption":"VOD"}]},
13
+ * {"token":"people","caption":"People","count":3}
14
+ * ]'></neodigm-buffet>
15
+ * ```
16
+ *
17
+ * The metadata is the schema `m5t-primary-nav` already uses — `token`,
18
+ * `caption`, `enabled`, `selected`, `icon`, `tagline`, `count`, children under
19
+ * `secondary` — so existing navigation data drops straight in. `data-buttons`
20
+ * is read as an alias for the same reason. What did not come across, and why:
21
+ *
22
+ * - **The logo, PubSub, the i18n listener and `admin_role`** belong to the app.
23
+ * An app that re-resolves its labels sets `items` again; selection survives
24
+ * wherever the tokens do.
25
+ * - **Re-rendering on every click.** That one rebuilt its whole shadow root per
26
+ * selection, which dropped keyboard focus. Here the top row is built once per
27
+ * `items` and selection only moves attributes.
28
+ * - **`innerHTML` with captions and taglines**, which made every label an
29
+ * injection point. Labels are text nodes and the tagline is a `title`.
30
+ * - **A missing `enabled` meant disabled.** Only `enabled: false` disables.
31
+ * - **`icon` is not rendered.** It held a Font Awesome class there, which
32
+ * cannot style anything inside a shadow root, and icon fonts are not
33
+ * wanted here at all. Icons are deferred; until then the field is accepted
34
+ * and ignored, so existing data neither breaks nor prints class names.
35
+ *
36
+ * **Theming.** The whole menu is one instrument: `data-n55-theme` on the host
37
+ * colours the selection and the child row, so a `robinTheme()` sweep recolours
38
+ * the menu as a unit. An item may carry a `theme` of its own as an accent; that
39
+ * lives inside the shadow root, so a sweep passes it by — deliberately, it is
40
+ * the item's identity rather than page state.
41
+ */
42
+ import { THEMES, THEME_ATTR } from '../../conductor/theme.js';
43
+
44
+ /**
45
+ * The four colours the menu is painted from, for one theme: two surfaces and
46
+ * the palette's ink for each. Every theme sets all four, so an accent inside a
47
+ * menu of another theme never inherits the wrong ink.
48
+ */
49
+ const colours = (name) =>
50
+ `--_fill: var(--n55-${name}); --_ink: var(--n55-${name}-ink); ` +
51
+ `--_row: var(--n55-${name}-deep); --_row-ink: var(--n55-${name}-deep-ink);`;
52
+
53
+ const STYLES = `
54
+ :host {
55
+ /* Unthemed: the palette's inverse surface for the selection — dark on a
56
+ light page, light on a dark one — and a dark child row in both. The
57
+ literals are fallbacks for a page that never called installTheme(). */
58
+ --_fill: var(--n55-inverse, #1a1a1a);
59
+ --_ink: var(--n55-inverse-ink, #fff);
60
+ --_row: #2b2b2b;
61
+ --_row-ink: #fff;
62
+
63
+ display: block;
64
+ inline-size: 100%;
65
+ box-sizing: border-box;
66
+ font: 600 .875rem/1.2 system-ui, sans-serif;
67
+ user-select: none;
68
+ -webkit-user-select: none;
69
+ }
70
+ :host([hidden]) { display: none; }
71
+
72
+ /* Theme comes from the host attribute, which is what a sweep sets. Generated
73
+ from the palette so a new theme cannot be missed here. */
74
+ ${Object.keys(THEMES).map((name) => `
75
+ :host([${THEME_ATTR}="${name}"]) { ${colours(name)} }`).join('')}
76
+
77
+ /* Per-item accents. Not data-n55-theme: that attribute is the page's
78
+ contract, and putting it on a shadow-internal node implies a sweep can
79
+ reach it, which it cannot. */
80
+ ${Object.keys(THEMES).map((name) => `
81
+ [data-accent="${name}"] { ${colours(name)} }`).join('')}
82
+
83
+ .primary, .secondary {
84
+ display: grid;
85
+ grid-auto-flow: column;
86
+ /* minmax(0, …) rather than 1fr, so a long caption truncates instead of
87
+ widening its column: the width is the container's divided by the count. */
88
+ grid-auto-columns: minmax(0, 1fr);
89
+ }
90
+ .primary {
91
+ block-size: var(--n55-buffet-height, 64px);
92
+ /* The page's surface, so the top row follows light and dark. */
93
+ background: var(--n55-surface, #fff);
94
+ border-block-end: 1px solid var(--n55-line, rgb(0 0 0 / .12));
95
+ }
96
+ .secondary {
97
+ block-size: calc(var(--n55-buffet-height, 64px) / 2);
98
+ background: var(--_row);
99
+ color: var(--_row-ink);
100
+ font-size: .8em;
101
+ font-weight: 500;
102
+ }
103
+
104
+ .item {
105
+ position: relative;
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ gap: .4em;
110
+ min-inline-size: 0;
111
+ margin: 0;
112
+ padding: 0 .75em;
113
+ border: 0;
114
+ border-inline-start: 1px solid var(--n55-line, rgb(0 0 0 / .12));
115
+ background: transparent;
116
+ color: inherit;
117
+ font: inherit;
118
+ cursor: pointer;
119
+ transition: background-color .2s ease, color .2s ease;
120
+ }
121
+ .item:first-child { border-inline-start: 0; }
122
+ .primary .item { color: var(--n55-surface-ink, #1a1a1a); }
123
+ .secondary .item { border-inline-start-color: rgb(255 255 255 / .14); }
124
+
125
+ .primary .item:hover:not(:disabled):not([aria-current]) {
126
+ background: color-mix(in srgb, var(--_fill) 14%, transparent);
127
+ }
128
+ .secondary .item:hover:not(:disabled):not([aria-current]) {
129
+ background: color-mix(in srgb, var(--_row-ink) 12%, transparent);
130
+ }
131
+
132
+ /* Selected, at either level: the theme's colour with its own ink, plus a bar
133
+ along the bottom so selection never rests on colour alone — white on a
134
+ white row would otherwise be invisible. */
135
+ .item[aria-current] {
136
+ background: var(--_fill);
137
+ color: var(--_ink);
138
+ box-shadow: inset 0 -3px 0 var(--_ink);
139
+ }
140
+ .secondary .item[aria-current] { box-shadow: inset 0 -2px 0 var(--_ink); }
141
+
142
+ .item:focus-visible { outline: 2px solid currentColor; outline-offset: -5px; }
143
+ .item:disabled { opacity: .45; cursor: not-allowed; }
144
+
145
+ .caption { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
146
+ .count {
147
+ position: absolute;
148
+ inset-block-start: 6px;
149
+ inset-inline-end: 8px;
150
+ min-inline-size: 1.5em;
151
+ padding: .15em .45em;
152
+ border-radius: 1em;
153
+ box-sizing: border-box;
154
+ background: var(--_row);
155
+ color: var(--_row-ink);
156
+ font-size: .75em;
157
+ line-height: 1.2;
158
+ }
159
+ .secondary .count { inset-block-start: 2px; }
160
+
161
+ @media (prefers-reduced-motion: reduce) {
162
+ .item { transition: none; }
163
+ }
164
+ `;
165
+
166
+ /**
167
+ * Reads menu metadata: a JSON array, or an array already parsed.
168
+ *
169
+ * Never throws. A menu that renders empty is easier to diagnose than one that
170
+ * takes the page down, and anything without a string `token` is dropped,
171
+ * because selection, events and `updateCount` are all keyed by it.
172
+ *
173
+ * @param {string|unknown[]|null} raw
174
+ * @returns {object[]}
175
+ */
176
+ export function readItems(raw) {
177
+ let list = raw;
178
+ if (typeof raw === 'string') {
179
+ try { list = JSON.parse(raw); } catch { return []; }
180
+ }
181
+ if (!Array.isArray(list)) return [];
182
+ const clean = (item) => item && typeof item === 'object' && typeof item.token === 'string';
183
+ return list.filter(clean).map((item) => ({
184
+ ...item,
185
+ secondary: Array.isArray(item.secondary)
186
+ ? item.secondary.filter(clean).map((child) => ({ ...child }))
187
+ : []
188
+ }));
189
+ }
190
+
191
+ const usable = (item) => item && item.enabled !== false;
192
+ const copy = (item) => (item ? JSON.parse(JSON.stringify(item)) : null);
193
+
194
+ export class NeodigmBuffet extends HTMLElement {
195
+ #items = [];
196
+ #primary = null;
197
+ #secondary = null;
198
+ /** primary token → the child last selected under it */
199
+ #memory = new Map();
200
+
201
+ static get observedAttributes() { return ['data-n55-buffet', 'data-buttons']; }
202
+
203
+ constructor() {
204
+ super();
205
+ this.attachShadow({ mode: 'open' });
206
+ this.shadowRoot.addEventListener('click', this.#onClick);
207
+ }
208
+
209
+ connectedCallback() {
210
+ if (!this.shadowRoot.firstChild) {
211
+ this.shadowRoot.innerHTML =
212
+ `<style>${STYLES}</style>` +
213
+ `<div class="primary" part="primary"></div>` +
214
+ `<div class="secondary" part="secondary"></div>`;
215
+ }
216
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'navigation');
217
+ if (!this.#items.length) this.#setItems(readItems(this.#source()));
218
+ else this.#render();
219
+ }
220
+
221
+ attributeChangedCallback(name, before, after) {
222
+ if (before === after) return;
223
+ this.#setItems(readItems(this.#source()));
224
+ }
225
+
226
+ /** The menu's metadata. A copy: change it by assigning, not by mutating. */
227
+ get items() { return copy(this.#items); }
228
+ set items(value) { this.#setItems(readItems(value)); }
229
+
230
+ /** The current selection at both levels. */
231
+ get selection() { return { primary: this.#primary, secondary: this.#secondary }; }
232
+
233
+ /**
234
+ * Selects by token without announcing it, for keeping the menu in step with
235
+ * a route. Clicks announce; this does not, or syncing would loop.
236
+ *
237
+ * @param {string} primary
238
+ * @param {string|null} [secondary] Omitted: the child last chosen under it.
239
+ * @returns {boolean} false if either token is unknown or disabled.
240
+ */
241
+ select(primary, secondary) {
242
+ const item = this.#find(primary);
243
+ if (!usable(item)) return false;
244
+ let child = secondary === undefined ? this.#remembered(item) : secondary;
245
+ if (child !== null && !usable(item.secondary.find((c) => c.token === child))) return false;
246
+ this.#apply(primary, child);
247
+ return true;
248
+ }
249
+
250
+ /**
251
+ * Updates a badge in place. `null` removes it.
252
+ * @returns {boolean} false if no item has that token.
253
+ */
254
+ updateCount(token, count) {
255
+ for (const item of this.#items) {
256
+ const hit = item.token === token ? item : item.secondary.find((c) => c.token === token);
257
+ if (!hit) continue;
258
+ if (typeof count === 'number') hit.count = count; else delete hit.count;
259
+ const level = hit === item ? 'primary' : 'secondary';
260
+ const button = [...this.shadowRoot.querySelectorAll(`.${level} .item`)]
261
+ .find((b) => b.dataset.token === token);
262
+ if (button) this.#fill(button, hit);
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ #source() {
269
+ return this.getAttribute('data-n55-buffet') ?? this.getAttribute('data-buttons');
270
+ }
271
+
272
+ #find(token) { return this.#items.find((item) => item.token === token) ?? null; }
273
+
274
+ #remembered(item) {
275
+ const token = this.#memory.get(item.token);
276
+ return usable(item.secondary.find((c) => c.token === token)) ? token : null;
277
+ }
278
+
279
+ /**
280
+ * Takes new metadata, keeping the selection wherever its tokens survive —
281
+ * which is what lets an app swap in relabelled items without the menu
282
+ * jumping.
283
+ */
284
+ #setItems(items) {
285
+ this.#items = items;
286
+ for (const item of items) {
287
+ const flagged = item.secondary.find((c) => c.selected && usable(c));
288
+ if (flagged) this.#memory.set(item.token, flagged.token);
289
+ }
290
+ const kept = usable(this.#find(this.#primary)) ? this.#primary : null;
291
+ const primary = kept
292
+ ?? items.find((i) => i.selected && usable(i))?.token
293
+ ?? items.find(usable)?.token
294
+ ?? null;
295
+ const item = this.#find(primary);
296
+ const child = item?.secondary.find((c) => c.token === this.#secondary && usable(c))
297
+ ? this.#secondary
298
+ : item ? this.#remembered(item) : null;
299
+ this.#primary = primary;
300
+ this.#secondary = child;
301
+ this.#render();
302
+ }
303
+
304
+ #render() {
305
+ const row = this.shadowRoot.querySelector('.primary');
306
+ if (!row) return; // not connected yet; connectedCallback renders
307
+ row.replaceChildren(...this.#items.map((item) => this.#button(item, 'primary')));
308
+ this.#renderChildren();
309
+ this.#paint();
310
+ }
311
+
312
+ #renderChildren() {
313
+ const row = this.shadowRoot.querySelector('.secondary');
314
+ if (!row) return;
315
+ const item = this.#find(this.#primary);
316
+ row.replaceChildren(...(item?.secondary ?? []).map((c) => this.#button(c, 'secondary')));
317
+ // The child row belongs to its parent, so it takes the parent's accent.
318
+ this.#accent(row, item?.theme);
319
+ }
320
+
321
+ #apply(primary, secondary) {
322
+ const moved = primary !== this.#primary;
323
+ this.#primary = primary;
324
+ this.#secondary = secondary;
325
+ if (secondary !== null) this.#memory.set(primary, secondary);
326
+ if (moved) this.#renderChildren();
327
+ this.#paint();
328
+ }
329
+
330
+ /** Moves the selection markers. Nothing is rebuilt, so focus stays put. */
331
+ #paint() {
332
+ for (const [level, token] of [['primary', this.#primary], ['secondary', this.#secondary]]) {
333
+ for (const button of this.shadowRoot.querySelectorAll(`.${level} .item`)) {
334
+ if (button.dataset.token === token) button.setAttribute('aria-current', 'true');
335
+ else button.removeAttribute('aria-current');
336
+ }
337
+ }
338
+ }
339
+
340
+ #button(item, level) {
341
+ const button = this.ownerDocument.createElement('button');
342
+ button.type = 'button';
343
+ button.className = 'item';
344
+ button.setAttribute('part', `item ${level}-item`);
345
+ button.dataset.token = item.token;
346
+ button.dataset.level = level;
347
+ button.disabled = item.enabled === false;
348
+ if (typeof item.tagline === 'string' && item.tagline) button.title = item.tagline;
349
+ this.#accent(button, item.theme);
350
+ this.#fill(button, item);
351
+ return button;
352
+ }
353
+
354
+ #accent(node, theme) {
355
+ if (theme && Object.hasOwn(THEMES, theme)) node.dataset.accent = theme;
356
+ else delete node.dataset.accent;
357
+ }
358
+
359
+ /** Writes a button's contents as text nodes, never markup. */
360
+ #fill(button, item) {
361
+ const doc = this.ownerDocument;
362
+ const span = (cls, text) => {
363
+ const s = doc.createElement('span');
364
+ s.className = cls;
365
+ s.textContent = text;
366
+ return s;
367
+ };
368
+ const parts = [span('caption', String(item.caption ?? item.token))];
369
+ if (typeof item.count === 'number') parts.push(span('count', String(item.count)));
370
+ button.replaceChildren(...parts);
371
+ }
372
+
373
+ #onClick = (ev) => {
374
+ const button = ev.target.closest?.('.item');
375
+ if (!button || button.disabled) return;
376
+ const { token, level } = button.dataset;
377
+
378
+ let primary = this.#primary;
379
+ let secondary = token;
380
+ let item;
381
+ if (level === 'primary') {
382
+ item = this.#find(token);
383
+ primary = token;
384
+ secondary = item ? this.#remembered(item) : null;
385
+ } else {
386
+ item = this.#find(this.#primary)?.secondary.find((c) => c.token === token);
387
+ }
388
+ if (!item) return;
389
+
390
+ // Cancelable, as Tulip's open is: a page with unsaved work can veto the
391
+ // move. It fires on every click, including the current selection, because
392
+ // re-choosing where you are is how a user asks to go back to its start.
393
+ const proceed = this.dispatchEvent(new CustomEvent('neodigm-buffet-select', {
394
+ bubbles: true, composed: true, cancelable: true,
395
+ detail: { level, token, item: copy(item), primary, secondary }
396
+ }));
397
+ if (proceed) this.#apply(primary, secondary);
398
+ };
399
+ }
400
+
401
+ if (!customElements.get('neodigm-buffet')) {
402
+ customElements.define('neodigm-buffet', NeodigmBuffet);
403
+ }
@@ -50,10 +50,17 @@ const STYLES = `
50
50
 
51
51
  max-width: min(92vw, 560px);
52
52
  box-sizing: border-box;
53
- padding: 14px 20px;
53
+ /* Clips the progress bar to the rounded corners. */
54
+ overflow: hidden;
55
+ padding: 18px;
54
56
  border-radius: 8px;
55
- font: 15px/1.45 system-ui, sans-serif;
57
+ /* The original's type, white on every theme but the two light ones below.
58
+ A faint shadow keeps it legible at the light end of the gradient. */
59
+ font: 18px/1.4 'Helvetica', 'Helvetica Neue', sans-serif;
60
+ text-shadow: 0 1px 2px rgb(0 0 0 / .35);
56
61
  text-align: center;
62
+ user-select: none;
63
+ -webkit-user-select: none;
57
64
  box-shadow: 0 6px 24px rgb(0 0 0 / .28);
58
65
 
59
66
  /* The unthemed default, for a page that imported the toast but never called
@@ -61,21 +68,46 @@ const STYLES = `
61
68
  night-deep here would mean the two drift apart the first time anyone edits
62
69
  the palette, and nothing would fail. Both sides are declared so the toast
63
70
  never inherits a page colour it cannot guarantee contrast against. */
64
- background: var(--n55-night-deep, #2b2b2b);
65
- color: var(--n55-white, #fff);
71
+ background: linear-gradient(45deg, var(--n55-night, #2b2b2b), var(--n55-night-deep, #2b2b2b));
72
+ color: #fff;
66
73
  }
67
74
 
68
75
  :host([open]) {
69
76
  opacity: 1;
70
77
  visibility: visible;
71
78
  transform: translateX(-50%) translateY(0);
79
+ animation: n55-toast-in .5s ease-in-out;
72
80
  }
73
81
 
82
+ /* From the original's toast_bottom: rise from below the fold, overshoot,
83
+ settle back. Entry only — leaving is the plain transition above. */
84
+ @keyframes n55-toast-in {
85
+ 0% { transform: translateX(-50%) translateY(136px); }
86
+ 80% { transform: translateX(-50%) translateY(-6px); }
87
+ 100% { transform: translateX(-50%) translateY(0); }
88
+ }
89
+
90
+ /* Time remaining, filling left to right across the top over the toast's own
91
+ duration, which #next() writes onto it. The original fixed this at 5.2s
92
+ whatever the toast's duration. Like the original it runs dark to light,
93
+ the reverse of the background; each theme's colours are set below. */
94
+ .bar {
95
+ position: absolute;
96
+ inset-block-start: 0;
97
+ inset-inline-start: 0;
98
+ block-size: 3px;
99
+ inline-size: 0;
100
+ background: linear-gradient(90deg, var(--n55-night-deep, #2b2b2b), var(--n55-night, #2b2b2b));
101
+ animation: n55-toast-bar linear forwards;
102
+ }
103
+ @keyframes n55-toast-bar { to { inline-size: 100%; } }
104
+
74
105
  /* A visitor who asked for less motion still gets the toast — it carries
75
106
  information — but it appears rather than slides. */
76
107
  @media (prefers-reduced-motion: reduce) {
77
108
  :host { transition: opacity .01s linear; transform: translateX(-50%); }
78
- :host([open]) { transform: translateX(-50%); }
109
+ :host([open]) { transform: translateX(-50%); animation: none; }
110
+ .bar { display: none; }
79
111
  }
80
112
 
81
113
  :host([hidden]) { display: none; }
@@ -85,13 +117,18 @@ const STYLES = `
85
117
  and silently missed here. */
86
118
  ${Object.keys(THEMES).map((name) => `
87
119
  :host([${THEME_ATTR}="${name}"]) {
88
- background: var(--n55-${name});
89
- color: var(--n55-${name}-deep);
120
+ background: linear-gradient(45deg, var(--n55-${name}), var(--n55-${name}-deep));
121
+ }
122
+ :host([${THEME_ATTR}="${name}"]) .bar {
123
+ background: linear-gradient(90deg, var(--n55-${name}-deep), var(--n55-${name}));
90
124
  }`).join('')}
91
125
 
92
- /* ghost and white are light backgrounds; their -deep value is the readable ink. */
126
+ /* white and ghost start from pure white, where white text would vanish. The
127
+ original never styled a toast for either; dark ink is the readable choice. */
93
128
  :host([${THEME_ATTR}="white"]),
94
- :host([${THEME_ATTR}="ghost"]) { color: #1a1a1a; }
129
+ :host([${THEME_ATTR}="ghost"]) { color: #1a1a1a; text-shadow: none; }
130
+ /* white's two colours are both white, so its bar would vanish too. */
131
+ :host([${THEME_ATTR}="white"]) .bar { background: currentColor; }
95
132
 
96
133
  .msg { margin: 0; }
97
134
  .msg b { font-weight: 600; }
@@ -197,6 +234,7 @@ export class NeodigmToast extends HTMLElement {
197
234
  this.#showing = true;
198
235
 
199
236
  this.#render(entry.message);
237
+ this.#progress(entry.duration);
200
238
  if (entry.theme) this.setAttribute(THEME_ATTR, entry.theme);
201
239
  this.setAttribute('open', '');
202
240
  this.dispatchEvent(new CustomEvent('neodigm-toast-show', {
@@ -218,6 +256,21 @@ export class NeodigmToast extends HTMLElement {
218
256
  this.#timer = setTimeout(() => this.#next(), gap);
219
257
  }
220
258
 
259
+ /**
260
+ * Replaces the progress bar with a fresh one, which is what restarts its CSS
261
+ * animation for each queued message. Left out entirely when motion is off: a
262
+ * moving bar is motion, and the toast still closes on time without it.
263
+ */
264
+ #progress(duration) {
265
+ this.shadowRoot.querySelector('.bar')?.remove();
266
+ if (!motionAllowed()) return;
267
+ const bar = this.ownerDocument.createElement('span');
268
+ bar.className = 'bar';
269
+ bar.setAttribute('part', 'progress');
270
+ bar.style.animationDuration = `${duration}ms`;
271
+ this.shadowRoot.append(bar);
272
+ }
273
+
221
274
  /**
222
275
  * Writes the message.
223
276
  *
@@ -109,7 +109,7 @@ const STYLES = `
109
109
  text-align: center;
110
110
  box-shadow: 0 4px 16px rgb(0 0 0 / .24);
111
111
  background: var(--n55-brand, #2b2b2b);
112
- color: var(--n55-brand-deep, #fff);
112
+ color: var(--n55-brand-ink, #fff);
113
113
  }
114
114
  :host([open]) { opacity: 1; visibility: visible; }
115
115
  :host([hidden]) { display: none; }
@@ -122,10 +122,8 @@ const STYLES = `
122
122
  ${Object.keys(THEMES).map((name) => `
123
123
  :host([${THEME_ATTR}="${name}"]) {
124
124
  background: var(--n55-${name});
125
- color: var(--n55-${name}-deep);
125
+ color: var(--n55-${name}-ink);
126
126
  }`).join('')}
127
- :host([${THEME_ATTR}="white"]),
128
- :host([${THEME_ATTR}="ghost"]) { color: #1a1a1a; }
129
127
 
130
128
  .msg { margin: 0; }
131
129
  .icon { margin-inline-end: 6px; font-style: normal; }