@gemboss/website-ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/website.mjs CHANGED
@@ -6,10 +6,10 @@
6
6
  // the export line and the // comments, plus auto-init): edit this file, then run the build. Keep comments on lines of
7
7
  // their own, so the build can leave them out.
8
8
 
9
- var version = '0.9.0';
9
+ var version = '0.11.0';
10
10
  var api = { version: version, init: init, destroy: destroy };
11
11
  // Page state. Set up by the first init(), never at import: a server can import this file.
12
- var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
12
+ var doc, root, mine, fine, sys, wide, desk, mid, live = [], queued = false;
13
13
  var still, lights;
14
14
  var READY = 'data-gw-ready';
15
15
 
@@ -56,48 +56,315 @@ function drop(r) {
56
56
  }
57
57
  function records(kind, fn) { live.slice().forEach(function (r) { if (r.kind === kind) fn(r); }); }
58
58
 
59
- // Mega menu. Opened by hover then clicked, a panel stays open: the click meant "open". One open on the page, across
60
- // every header init() wired. Each change fires gw:mega on the trigger.
61
- function setMega(m, show, mode) {
62
- m.p.hidden = !show;
63
- m.t.setAttribute('aria-expanded', String(show));
64
- m.item.classList.toggle('is-open', show);
65
- m.mode = mode;
66
- megaOpen = show ? m : megaOpen === m ? null : megaOpen;
67
- emit(m.t, 'gw:mega', { id: m.p.id, open: show });
68
- }
69
- function showMega(m, mode) {
70
- clearTimeout(megaTimer);
71
- if (megaOpen === m) { if (mode === 'click') m.mode = mode; return; }
72
- if (megaOpen) setMega(megaOpen, false);
73
- setMega(m, true, mode);
74
- }
75
- function hideMega(focus) {
76
- var m = megaOpen;
77
- clearTimeout(megaTimer);
78
- if (!m) return;
79
- setMega(m, false);
80
- if (focus === true) m.t.focus();
59
+ // Mega menu (0.11.0): gemboss.ai's own, 1:1 (Chris 26/10/2026). One record per header with panels: each
60
+ // .gw-nav__item whose name (.gw-nav__trigger, or a [data-gw-mega] of a 0.10.0 header) names its panel by
61
+ // data-gw-mega or aria-controls. Timing: open after the pointer rests 200ms, switch after 90ms once one is open,
62
+ // close 350ms after it leaves; keyboard focus opens at once, ArrowDown on a name opens it and steps into its first
63
+ // row, Escape closes and gives the focus back to the name. A <button> name also opens and closes with a click; a
64
+ // link name is its page's link. From 1081px only: the phone menu has its own markup. The script marks the header
65
+ // .has-mega (.is-mega-open while one is open), draws the surface, the caret and the pill in it and the scrim in the
66
+ // body (all aria-hidden), takes `hidden` off the panels and keeps a closed one inert. A row's preview is the
67
+ // <template> its data-gw-preview names (the value "feature": the panel's own card); a card's pictures load the first
68
+ // time it shows (data-gw-bg, template[data-gw-hydrate]). Each open and close fires gw:mega on the name ({ id, open }).
69
+ // destroy gives everything back.
70
+ var hydrated = new WeakSet();
71
+ function hydrate(card) {
72
+ if (!card || hydrated.has(card)) return;
73
+ hydrated.add(card);
74
+ within('[data-gw-bg]', card).forEach(function (el) {
75
+ el.style.backgroundImage = 'url("' + attr(el, 'data-gw-bg', '') + '")';
76
+ el.classList.add('is-hydrated');
77
+ });
78
+ within('template[data-gw-hydrate]', card).forEach(function (t) {
79
+ t.parentNode.appendChild(t.content.cloneNode(true));
80
+ t.parentNode.classList.add('is-hydrated');
81
+ });
81
82
  }
82
- function inside(m, el) { return !!el && (m.item.contains(el) || m.p.contains(el)); }
83
83
  function mega(ctx) {
84
- each(ctx, '[data-gw-mega]', 'mega', function (t, on) {
85
- var p = doc.getElementById(attr(t, 'data-gw-mega', attr(t, 'aria-controls', '')));
86
- if (!p) return;
87
- var m = { t: t, p: p, item: t.closest('.gw-nav__item') || t.parentNode };
88
- on(t, 'click', function () { if (megaOpen === m && m.mode !== 'hover') hideMega(); else showMega(m, 'click'); });
89
- (m.item.contains(p) ? [m.item] : [m.item, p]).forEach(function (el) {
90
- on(el, 'mouseenter', function () {
91
- if (fine.matches) { clearTimeout(megaTimer); if (megaOpen !== m) megaTimer = setTimeout(function () { showMega(m, 'hover'); }, 80); }
84
+ each(ctx, '.gw-header', 'mega', function (header, on) {
85
+ var items = [];
86
+ all('.gw-nav__item', header).forEach(function (item) {
87
+ var t = item.querySelector('[data-gw-mega], .gw-nav__trigger');
88
+ var p = t && doc.getElementById(attr(t, 'data-gw-mega', attr(t, 'aria-controls', '')));
89
+ if (p) items.push({ item: item, t: t, p: p, body: p.querySelector('.gw-mega__body'), foot: p.querySelector('.gw-mega__foot'),
90
+ grid: p.querySelector('.gw-mega__grid'), rows: all('.gw-mega__link', p), pane: p.querySelector('.gw-mega__aside') });
91
+ });
92
+ if (!items.length) return;
93
+ var nav = header.querySelector('.gw-nav') || header;
94
+ var bar = header.querySelector('.gw-header__bar') || header;
95
+ var mk = function (tag, cls) { var n = doc.createElement(tag); n.className = cls; n.setAttribute('aria-hidden', 'true'); return n; };
96
+ var scrim = mk('div', 'gw-scrim'), bg = mk('div', 'gw-mega-surface'), caret = mk('div', 'gw-mega-caret'), pill = mk('span', 'gw-nav__pill');
97
+ var made = [scrim, bg, caret, pill], cards = new Map(), cur = null, openT = 0, closeT = 0, pvT = 0, pvTok = 0, quiet = false, rec;
98
+ doc.body.appendChild(scrim);
99
+ header.appendChild(pill);
100
+ header.appendChild(bg);
101
+ header.appendChild(caret);
102
+ header.classList.add('has-mega');
103
+ items.forEach(function (m) {
104
+ m.hid = m.p.hidden;
105
+ m.p.hidden = false;
106
+ m.p.inert = true;
107
+ m.feat = m.pane && m.pane.querySelector('.gw-mega__card');
108
+ });
109
+ var still2 = function () { return still.matches; };
110
+ // a change with transitions off, then on again (a reflow in between commits the jump)
111
+ function snap(els, fn) {
112
+ els.forEach(function (n) { if (n) n.style.transition = 'none'; });
113
+ fn();
114
+ void header.offsetWidth;
115
+ els.forEach(function (n) { if (n) n.style.transition = ''; });
116
+ }
117
+ function cardFor(m, row) {
118
+ var k = attr(row, 'data-gw-preview', '');
119
+ if (k === 'feature') return m.feat || null;
120
+ if (cards.has(row)) return cards.get(row);
121
+ var tpl = k && doc.getElementById(k);
122
+ if (!tpl || !tpl.content || !m.pane) return null;
123
+ var a = doc.createElement('a'), art = doc.createElement('span'), txt = doc.createElement('span'), go = doc.createElement('span');
124
+ var b = row.querySelector('b'), t = b ? b.cloneNode(true) : doc.createElement('b'), line = row.querySelector('i');
125
+ a.className = 'gw-mega__card';
126
+ a.href = attr(row, 'href', '#');
127
+ if (row.rel) a.rel = row.rel;
128
+ // the row is the link; this is its picture
129
+ a.tabIndex = -1;
130
+ a.setAttribute('aria-hidden', 'true');
131
+ within('[aria-hidden="true"]', t).forEach(function (n) { if (n !== t) n.remove(); });
132
+ go.className = 'gw-mega__go';
133
+ go.textContent = /^https?:/.test(a.getAttribute('href')) ? '\u2197' : '\u2192';
134
+ t.appendChild(go);
135
+ art.className = 'gw-mega__art';
136
+ art.appendChild(tpl.content.cloneNode(true));
137
+ txt.className = 'gw-mega__card-text';
138
+ txt.appendChild(t);
139
+ if (line) txt.appendChild(line.cloneNode(true));
140
+ a.appendChild(art);
141
+ a.appendChild(txt);
142
+ m.pane.appendChild(a);
143
+ cards.set(row, a);
144
+ return a;
145
+ }
146
+ function build(m) {
147
+ if (m.built) return;
148
+ m.built = true;
149
+ if (m.grid) { m.hl = mk('span', 'gw-mega__hl'); m.grid.insertBefore(m.hl, m.grid.firstChild); made.push(m.hl); }
150
+ hydrate(m.feat);
151
+ // a panel without its own card shows its first row's preview (gemboss.ai's Solutions)
152
+ if (m.pane && !m.feat && m.rows[0]) m.feat = cardFor(m, m.rows[0]);
153
+ // the other previews are built while the browser is idle, so their pictures are in hand before a row is reached
154
+ var idle = window.requestIdleCallback || function (f) { return setTimeout(f, 200); };
155
+ if (m.pane) idle(function () { if (header.classList.contains('has-mega')) m.rows.forEach(function (row) { cardFor(m, row); }); });
156
+ }
157
+ // a preview fades in once its pictures are decoded (240ms at most); a newer hover cancels one still waiting
158
+ function preview(m, row, now) {
159
+ clearTimeout(pvT);
160
+ var tok = ++pvTok;
161
+ var go = function () {
162
+ var c = row ? cardFor(m, row) : m.feat;
163
+ if (!c || c === m.on) return;
164
+ var swap = function () {
165
+ if (tok !== pvTok || m.on === c) return;
166
+ if (m.on) m.on.classList.remove('is-on');
167
+ c.classList.add('is-on');
168
+ m.on = c;
169
+ };
170
+ var wait = [].filter.call(c.querySelectorAll('img'), function (i) { return !i.complete && i.decode; });
171
+ if (!wait.length || !window.Promise) { swap(); return; }
172
+ var t = setTimeout(swap, 240);
173
+ Promise.all(wait.map(function (i) { return i.decode().catch(function () {}); })).then(function () { clearTimeout(t); swap(); });
174
+ };
175
+ // a sweep across the rows does not flicker the pane
176
+ if (now) go(); else pvT = setTimeout(go, 80);
177
+ }
178
+ function light(m, row) {
179
+ if (!m.hl) return;
180
+ if (!row) { m.hl.classList.remove('is-on'); return; }
181
+ var place = function () {
182
+ m.hl.style.translate = row.offsetLeft + 'px ' + row.offsetTop + 'px';
183
+ m.hl.style.width = row.offsetWidth + 'px';
184
+ m.hl.style.height = row.offsetHeight + 'px';
185
+ };
186
+ if (m.hl.classList.contains('is-on') && !still2()) place(); else snap([m.hl], place);
187
+ m.hl.classList.add('is-on');
188
+ }
189
+ function movePill(t) {
190
+ if (!t) { pill.classList.remove('is-on'); return; }
191
+ var hr = header.getBoundingClientRect(), r = t.getBoundingClientRect();
192
+ var place = function () {
193
+ pill.style.translate = Math.round(r.left - hr.left) + 'px ' + Math.round(r.top - hr.top) + 'px';
194
+ pill.style.width = Math.round(r.width) + 'px';
195
+ pill.style.height = Math.round(r.height) + 'px';
196
+ };
197
+ if (pill.classList.contains('is-on') && !still2()) place(); else snap([pill], place);
198
+ pill.classList.add('is-on');
199
+ }
200
+ // where each panel sits: centred under its name, kept 8px inside the bar's edges, measured as it opens
201
+ function measure() {
202
+ var hr = header.getBoundingClientRect(), inner = bar.getBoundingClientRect();
203
+ var lo = inner.left - hr.left + 8, hi = inner.right - hr.left - 8;
204
+ header.style.setProperty('--gw-mega-top', (header.clientHeight + 8) + 'px');
205
+ items.forEach(function (m) {
206
+ var t = m.t.getBoundingClientRect(), w = m.p.offsetWidth, h = m.p.offsetHeight;
207
+ var cx = t.left + t.width / 2 - hr.left;
208
+ var x = Math.round(Math.max(lo, Math.min(cx - w / 2, hi - w)));
209
+ m.x = x; m.w = w; m.h = h;
210
+ m.cx = Math.round(Math.max(x + 24, Math.min(cx, x + w - 24)));
211
+ m.p.style.left = x + 'px';
212
+ m.p.style.transformOrigin = (m.cx - x) + 'px 0';
213
+ });
214
+ }
215
+ // the part of panel m the surface covers when the surface is at rect r (panel coordinates)
216
+ function clipTo(m, r) {
217
+ if (!r) return 'inset(0px 0px 0px 0px round 20px)';
218
+ var l = Math.max(0, r.x - m.x), rt = Math.max(0, m.x + m.w - (r.x + r.w)), b = Math.max(0, m.h - r.h);
219
+ return 'inset(0px ' + Math.round(rt) + 'px ' + Math.round(b) + 'px ' + Math.round(l) + 'px round 20px)';
220
+ }
221
+ // x, width and height move on the main thread (left, width, height), like the panels' clip-path, so the surface
222
+ // and what it clips never drift apart; only the lift in and out (translate y) runs on the compositor
223
+ function surface(m, y) {
224
+ bg.style.left = m.x + 'px';
225
+ bg.style.translate = '0px ' + y + 'px';
226
+ bg.style.width = m.w + 'px';
227
+ bg.style.height = m.h + 'px';
228
+ bg.style.transformOrigin = (m.cx - m.x) + 'px 0';
229
+ caret.style.translate = (m.cx - 6) + 'px ' + y + 'px';
230
+ }
231
+ function show(m) {
232
+ clearTimeout(closeT);
233
+ if (cur === m) return;
234
+ var prev = cur;
235
+ cur = m;
236
+ // a panel on its way out leaves the Tab order at once (inert), not when its fade ends
237
+ if (prev) {
238
+ prev.pinned = false;
239
+ prev.item.classList.remove('is-open');
240
+ prev.t.setAttribute('aria-expanded', 'false');
241
+ prev.p.inert = true;
242
+ emit(prev.t, 'gw:mega', { id: prev.p.id, open: false });
243
+ }
244
+ if (!m) {
245
+ header.classList.remove('is-mega-open');
246
+ scrim.classList.remove('is-on');
247
+ if (prev) { bg.style.translate = '0px -6px'; caret.style.translate = (prev.cx - 6) + 'px -6px'; }
248
+ movePill(nav.querySelector('.gw-nav__trigger:hover, .gw-nav__link:hover'));
249
+ return;
250
+ }
251
+ build(m);
252
+ // one panel open on the page, whichever header holds it
253
+ records('mega', function (o) { if (o !== rec && o.open()) o.close(); });
254
+ // every panel arrives showing its own card, with no row lit
255
+ preview(m, null, true);
256
+ light(m, null);
257
+ if (!prev) {
258
+ measure();
259
+ snap([bg, caret, m.p, m.body, m.foot], function () {
260
+ surface(m, -6);
261
+ m.p.style.clipPath = clipTo(m, null);
262
+ m.p.style.setProperty('--gw-mega-dx', '0px');
263
+ });
264
+ header.classList.add('is-mega-open');
265
+ surface(m, 0);
266
+ } else {
267
+ var hr = header.getBoundingClientRect(), br = bg.getBoundingClientRect();
268
+ var from = { x: br.left - hr.left, w: br.width, h: br.height };
269
+ var dir = items.indexOf(m) > items.indexOf(prev) ? 1 : -1;
270
+ if (!still2()) {
271
+ snap([m.p, m.body, m.foot], function () {
272
+ m.p.style.clipPath = clipTo(m, from);
273
+ m.p.style.setProperty('--gw-mega-dx', (dir * 8) + 'px');
274
+ });
275
+ prev.p.style.clipPath = clipTo(prev, m);
276
+ prev.p.style.setProperty('--gw-mega-dx', (-dir * 8) + 'px');
277
+ }
278
+ m.p.style.clipPath = clipTo(m, null);
279
+ m.p.style.setProperty('--gw-mega-dx', '0px');
280
+ surface(m, 0);
281
+ }
282
+ m.p.inert = false;
283
+ m.item.classList.add('is-open');
284
+ m.t.setAttribute('aria-expanded', 'true');
285
+ scrim.classList.add('is-on');
286
+ movePill(m.t);
287
+ emit(m.t, 'gw:mega', { id: m.p.id, open: true });
288
+ }
289
+ function hideSoon() { clearTimeout(openT); clearTimeout(closeT); closeT = setTimeout(function () { show(null); }, 350); }
290
+ items.forEach(function (m) {
291
+ on(m.p, 'transitionend', function (e) {
292
+ // a panel that has faded out drops its clip, so the next open starts whole
293
+ if (e.target === m.p && e.propertyName === 'opacity' && !m.item.classList.contains('is-open')) m.p.style.clipPath = '';
294
+ });
295
+ on(m.item, 'pointerenter', function (e) {
296
+ if (!desk.matches || e.pointerType === 'touch') return;
297
+ clearTimeout(closeT);
298
+ clearTimeout(openT);
299
+ // once a panel is open, a pointer crossing a neighbour on its way down does not swap it out
300
+ openT = setTimeout(function () { show(m); }, cur ? 90 : 200);
301
+ });
302
+ on(m.item, 'pointerleave', function () { if (desk.matches && !m.pinned) hideSoon(); });
303
+ on(m.item, 'focusin', function () { if (desk.matches && !quiet) show(m); });
304
+ on(m.item, 'focusout', function (e) { if (desk.matches && !m.item.contains(e.relatedTarget)) hideSoon(); });
305
+ on(m.t, 'keydown', function (e) {
306
+ if (!desk.matches || e.key !== 'ArrowDown') return;
307
+ e.preventDefault();
308
+ show(m);
309
+ if (m.rows[0]) m.rows[0].focus();
92
310
  });
93
- on(el, 'mouseleave', function () {
94
- if (fine.matches) { clearTimeout(megaTimer); if (megaOpen === m && m.mode === 'hover') megaTimer = setTimeout(hideMega, 160); }
311
+ // a 0.10.0 header's <button> name: a click opens its panel and keeps it open when the pointer leaves (pinned),
312
+ // a click on it again closes it; a panel the pointer opened stays open at the click
313
+ if (m.t.tagName === 'BUTTON') on(m.t, 'click', function () {
314
+ if (!desk.matches) return;
315
+ clearTimeout(openT);
316
+ if (cur === m && m.pinned) { show(null); return; }
317
+ show(m);
318
+ m.pinned = true;
95
319
  });
320
+ on(m.p, 'click', function (e) { if (link(e)) show(null); });
321
+ m.rows.forEach(function (row) {
322
+ on(row, 'pointerenter', function () { if (!desk.matches || cur !== m) return; light(m, row); preview(m, row, false); });
323
+ on(row, 'focus', function () { if (!desk.matches) return; if (cur !== m) show(m); light(m, row); preview(m, row, true); });
324
+ });
325
+ // the Tab onto the panel's own card shows that card, whatever row was previewed last
326
+ if (m.feat && m.feat.tagName === 'A') on(m.feat, 'focus', function () { if (!desk.matches) return; light(m, null); preview(m, null, true); });
327
+ });
328
+ all('.gw-nav__trigger, .gw-nav__link', nav).forEach(function (t) {
329
+ on(t, 'pointerenter', function (e) { if (desk.matches && e.pointerType !== 'touch') movePill(t); });
330
+ on(t, 'focus', function () { if (desk.matches) movePill(t); });
96
331
  });
97
- on(m.item, 'focusout', function (e) { if (megaOpen === m && e.relatedTarget && !inside(m, e.relatedTarget)) hideMega(); });
98
- on(p, 'click', function (e) { if (link(e)) hideMega(); });
99
- m.stop = function () { if (megaOpen === m) hideMega(); };
100
- return m;
332
+ on(nav, 'pointerleave', function () { if (desk.matches && !cur) movePill(null); });
333
+ on(nav, 'focusout', function (e) { if (desk.matches && !cur && !nav.contains(e.relatedTarget)) movePill(null); });
334
+ on(window, 'resize', function () {
335
+ if (!desk.matches) { show(null); movePill(null); return; }
336
+ if (cur) { measure(); snap([bg, caret], function () { surface(cur, 0); }); movePill(cur.t); }
337
+ });
338
+ rec = {
339
+ open: function () { return !!cur; },
340
+ has: function (el) { return !!cur && !!el && (cur.item.contains(el) || cur.p.contains(el)); },
341
+ close: function (focus) {
342
+ if (!cur) return;
343
+ var t = cur.t;
344
+ quiet = true;
345
+ show(null);
346
+ if (focus) t.focus();
347
+ quiet = false;
348
+ if (focus) movePill(t);
349
+ },
350
+ stop: function () {
351
+ clearTimeout(openT); clearTimeout(closeT); clearTimeout(pvT);
352
+ if (cur) show(null);
353
+ made.forEach(function (n) { n.remove(); });
354
+ cards.forEach(function (c) { c.remove(); });
355
+ items.forEach(function (m) {
356
+ m.p.hidden = m.hid;
357
+ m.p.inert = false;
358
+ ['left', 'transformOrigin', 'clipPath'].forEach(function (k) { m.p.style[k] = ''; });
359
+ m.p.style.removeProperty('--gw-mega-dx');
360
+ if (m.on) m.on.classList.remove('is-on');
361
+ m.built = false;
362
+ });
363
+ header.style.removeProperty('--gw-mega-top');
364
+ header.classList.remove('has-mega', 'is-mega-open');
365
+ }
366
+ };
367
+ return rec;
101
368
  });
102
369
  }
103
370
 
@@ -143,6 +410,8 @@ function mobile(ctx) {
143
410
  if (g === cur) return;
144
411
  cur = g;
145
412
  groups.forEach(function (x) { x.open = x === g; });
413
+ // a level's card gets its pictures the first time the level shows (0.11.0)
414
+ if (g) hydrate(g.querySelector('.gw-mega__card'));
146
415
  menu.classList.toggle('is-drilled', !!g);
147
416
  menu.classList.toggle('is-back', !g && r.open());
148
417
  menu.scrollTop = 0;
@@ -425,11 +694,11 @@ function tables(ctx) {
425
694
  });
426
695
  }
427
696
 
428
- // Footer folds (0.7.0, gap #40): each <details class="gw-footer__fold"> (a column, a family row). Below 768px it keeps
429
- // the markup's own state (folded unless the markup says open) and the browser opens and closes it. From 768px it is a
697
+ // Footer folds (0.7.0, gap #40): each <details class="gw-footer__fold"> (a column, a family row). Up to 620px it keeps
698
+ // the markup's own state (folded unless the markup says open) and the browser opens and closes it. From 621px it is a
430
699
  // plain column: the script sets `open`, takes the <summary> out of the Tab order (tabindex="-1") and ignores a click on
431
700
  // it, so the heading cannot fold the list away. Again on every change of width; destroy gives back the markup's state.
432
- // The CSS shows the list from 768px without the script too (::details-content), so this is for state and for browsers
701
+ // The CSS shows the list from 621px without the script too (::details-content), so this is for state and for browsers
433
702
  // without that.
434
703
  function folds(ctx) {
435
704
  each(ctx, '.gw-footer__fold', 'fold', function (d, on) {
@@ -510,15 +779,18 @@ function install() {
510
779
  var stored = null;
511
780
  // 1080px, the kit's desktop width: the header shows its nav, the contents sit beside the article
512
781
  wide = mq('(min-width: 1080px)');
513
- // 768px: the footer's columns stand open from here, and fold below it
514
- mid = mq('(min-width: 768px)');
515
- on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
516
- on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
782
+ // 1081px: the header's desktop, as gemboss.ai (its phone menu runs to 1080px)
783
+ desk = mq('(min-width: 1081px)');
784
+ // 621px: the footer's columns stand open from here, and fold up to 620px, as on gemboss.ai (1:1, 26/10/2026)
785
+ mid = mq('(min-width: 621px)');
786
+ on(doc, 'click', function (e) { records('mega', function (r) { if (r.open() && !r.has(e.target)) r.close(); }); });
787
+ on(doc, 'keydown', function (e) { if (esc(e)) records('mega', function (r) { if (r.open()) r.close(true); }); });
517
788
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
518
789
  // a drill-down menu that is open keeps the focus (its trap); a click outside a language list closes it
519
790
  on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
520
791
  on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
521
- watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
792
+ watch(wide, function () { records('toc', function (r) { r.fit(); }); });
793
+ watch(desk, function () { if (desk.matches) closeMenus(); });
522
794
  watch(mid, function () { records('fold', function (r) { r.fit(); }); });
523
795
  // the spotlight: a fine pointer, motion welcome, and a browser that can draw the light (@property, color-mix)
524
796
  still = mq('(prefers-reduced-motion: reduce)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The GemBoss website kit: tokens, header, footer, sections and the rules (AGENTS.md) that GemCommerce public websites share, plus a CLI that checks a site, reports kit gaps and upgrades.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/system.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "surface": "#ffffff",
9
9
  "ink": "#1b1c1e",
10
10
  "ink-2": "#3b3d42",
11
- "muted": "#686b71",
11
+ "muted": "#6a6d73",
12
12
  "line": "#e5e7ea",
13
13
  "line-strong": "#d5d8dd",
14
14
  "line-control": "#85888e",
@@ -29,7 +29,8 @@
29
29
  "night": "#0c0e14",
30
30
  "night-2": "#141722",
31
31
  "on-night": "#e3e4e8",
32
- "on-night-muted": "#9aa3ff"
32
+ "on-night-muted": "#9aa3ff",
33
+ "scrim": "rgba(12, 14, 18, 0.12)"
33
34
  },
34
35
  "dark": {
35
36
  "bg": "#0d0d12",
@@ -41,7 +42,7 @@
41
42
  "line": "#2a2b33",
42
43
  "line-strong": "#383944",
43
44
  "line-control": "#6a6d78",
44
- "action": "#f2f3f5",
45
+ "action": "#e3e4e8",
45
46
  "action-hover": "#ffffff",
46
47
  "on-action": "#0d0d12",
47
48
  "accent": "#7c84ff",
@@ -58,7 +59,8 @@
58
59
  "night": "#0a0b10",
59
60
  "night-2": "#12141c",
60
61
  "on-night": "#e3e4e8",
61
- "on-night-muted": "#9aa3ff"
62
+ "on-night-muted": "#9aa3ff",
63
+ "scrim": "rgba(0, 0, 0, 0.35)"
62
64
  },
63
65
  "wash": {
64
66
  "$comment": "Secondary hues. ONE job: the hero wash and illustrations. Never chrome, text, borders or buttons.",
@@ -67,16 +69,27 @@
67
69
  "sky": "#43b0ff",
68
70
  "mint": "#1fcf9f"
69
71
  },
72
+ "stage": {
73
+ "$comment": "The Stage (0.11.0): the deep iris ground a mega menu's pictures sit on, as gemboss.ai draws it, the same in light and dark. glow and glow-2 light it from two corners (with their strength), top and bottom are its gradient, dot its faint grid, ink its words. A brand sets its own stage here.",
74
+ "glow": "rgba(139, 147, 255, 0.5)",
75
+ "glow-2": "rgba(86, 97, 245, 0.34)",
76
+ "top": "#1d2058",
77
+ "bottom": "#0d0f2e",
78
+ "dot": "rgba(255, 255, 255, 0.1)",
79
+ "ink": "#ffffff"
80
+ },
70
81
  "roles": {
71
82
  "action": "The thing you click: ink, or the brand's accent when the site chose the accent action profile (profiles.action).",
72
83
  "accent": "Links, focus rings, active state, a chart line, the 8px label square; the primary button only through the accent action profile. Never body text, never words in a heading, never a section band.",
73
84
  "success/warning/danger": "State only. Their -soft tints are grounds: a tip or a warning callout, a success or an error badge, a field marked invalid. danger (0.6.0, gap #28) is the dashboard's --gemboss-danger family: the words of a field's error and the fill of a destructive button (on-danger on it).",
74
85
  "line-control": "The border of a form control (a field, a checkbox, a radio), 0.6.0: it must reach 3:1 on bg, bg-soft and surface, light and dark, where line and line-strong are hairlines that do not. Like the dashboard's --gemboss-field-line.",
75
- "wash": "The hero wash and illustrations only."
86
+ "wash": "The hero wash and illustrations only.",
87
+ "scrim": "The veil over the page while a mega menu is open (0.11.0): a dim and a 4px blur, as gemboss.ai.",
88
+ "stage": "The ground of a mega menu's pictures and nothing else (0.11.0)."
76
89
  }
77
90
  },
78
91
  "radius": {
79
- "scale": [0, 4, 6, 8, 12, 16, 20, 24, 999],
92
+ "scale": [0, 2, 4, 6, 8, 12, 16, 20, 24, 999],
80
93
  "chip": 999,
81
94
  "$comment": "Chips stay pills in every profile. The radii of actions, cards, panels and popovers come from the site's radius profile (profiles.radius): components read --gw-radius-action|card|panel|popover, never a number."
82
95
  },
@@ -84,19 +97,21 @@
84
97
  "rest": "0 1px 2px rgba(31,30,29,.04), 0 2px 6px rgba(31,30,29,.06)",
85
98
  "sm": "0 1px 2px rgba(31,30,29,.04)",
86
99
  "raised": "0 4px 16px rgba(31,30,29,.10)",
100
+ "dark": { "rest": "0 1px 0 rgba(0,0,0,.3)", "sm": "0 1px 0 rgba(0,0,0,.25)", "raised": "0 4px 16px rgba(0,0,0,.5)" },
101
+ "lift": "0 16px 32px -12px rgba(4,5,26,.72), 0 4px 8px -2px rgba(4,5,26,.36)",
87
102
  "$comment": "The whole budget for chrome. A card sits; it does not hover. An illustration may light itself."
88
103
  },
89
104
  "type": {
90
105
  "family": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif",
91
106
  "mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
92
- "weights": [400, 500, 600, 650, 700],
107
+ "weights": [400, 500, 600, 650, 700, 800],
93
108
  "scale": [11, 12, 13, 14, 16, 18, 20, 22, 24, 30],
94
109
  "display_above": 30,
95
110
  "display": {
96
111
  "h1": "clamp(40px, 4.6vw, 68px)",
97
112
  "h2": "clamp(32px, 3.4vw, 52px)",
98
113
  "wordmark": "clamp(40px, 9vw, 128px)",
99
- "$comment": "wordmark (0.7.0): the footer's last line on gemboss.ai, the brand's name, faint, at the end of the page. Decoration only, never a heading.",
114
+ "$comment": "wordmark (0.7.0): the footer's last line on gemboss.ai, the brand's name, faint, at the end of the page. Decoration only, never a heading. Weight 800 (0.10.0, as gemboss.ai draws it): the one use of 800 in the kit.",
100
115
  "weight": 600,
101
116
  "tracking": "-0.03em"
102
117
  },
@@ -2,8 +2,8 @@
2
2
  Footer: one per site, the same on every page, built from this template (AGENTS.md: brands fill its rows, they never
3
3
  invent a footer part). It is the footer of gemboss.ai (0.7.0). In order:
4
4
  1. the brand block: logo, one sentence; OPTIONAL the Ask AI row; OPTIONAL the newsletter form (0.6.0)
5
- 2. three to six link columns, each a <details class="gw-footer__fold">: folded rows on phones (below 768px), open
6
- columns from 768px. Headings one or two words in sentence case, four to seven links each.
5
+ 2. three to six link columns, each a <details class="gw-footer__fold">: folded rows on phones (up to 620px), open
6
+ columns from 621px. Headings one or two words in sentence case, four to seven links each.
7
7
  3. OPTIONAL product-family rows: a label and two to eight products, name and one line each; repeat the <nav> for a
8
8
  second family
9
9
  4. the bottom row: copyright, legal links, OPTIONAL theme toggle, social links. Nothing else.
@@ -11,14 +11,15 @@
11
11
  Replace every [bracketed] placeholder and delete the optional parts you do not use. website.js wires the folds and
12
12
  the toggle; without it the footer still works (the browser folds the <details> by itself).
13
13
 
14
- Links that leave the site carry <span class="gw-footer__ext" aria-hidden="true"></span> (a drawn ↗). A link with
14
+ Links that leave the site carry <span class="gw-footer__ext" aria-hidden="true">↗</span> (the glyph, set muted, as
15
+ gemboss.ai writes it; an empty span draws it instead). A link with
15
16
  target="_blank" also carries <span class="gw-visually-hidden"> (opens in a new tab)</span>; a link without it does not.
16
17
  -->
17
18
  <footer class="gw-footer">
18
19
  <div class="gw-wrap">
19
20
  <div class="gw-footer__grid">
20
21
  <div class="gw-footer__brand">
21
- <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="26" height="26"><span class="gw-brand__name">[Brand]</span></a>
22
+ <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="34" height="34"><span class="gw-brand__name">[Brand]</span></a>
22
23
  <p>[One sentence on what the brand does for the reader.]</p>
23
24
  <!-- OPTIONAL Ask AI: each link opens an assistant with the same neutral question already typed, URL-encoded after
24
25
  ?q= (spaces as %20). Ask what the brand is and what it does for the reader, never a leading question. Words,
@@ -89,7 +90,7 @@
89
90
  <li><a href="[/blog]">[Blog]</a></li>
90
91
  <li><a href="[/docs]">[Docs]</a></li>
91
92
  <li><a href="[/page]">[Page title]</a></li>
92
- <li><a href="[https://another site of the brand]" rel="noopener">[Changelog]<span class="gw-footer__ext" aria-hidden="true"></span></a></li>
93
+ <li><a href="[https://another site of the brand]" rel="noopener">[Changelog] <span class="gw-footer__ext" aria-hidden="true">↗</span></a></li>
93
94
  </ul>
94
95
  </details>
95
96
  </nav>
@@ -114,8 +115,8 @@
114
115
  <ul>
115
116
  <li><a href="[/product]"><b>[Product]</b> <i>[One line on what it is]</i></a></li>
116
117
  <li><a href="[/product]"><b>[Product]</b> <i>[One line on what it is]</i></a></li>
117
- <li><a href="[https://another site of the brand]" rel="noopener"><b>[Product]<span class="gw-footer__ext" aria-hidden="true"></span></b> <i>[One line on what it is]</i></a></li>
118
- <li><a href="[https://a sibling brand's site]" target="_blank" rel="noopener"><b>[Product]<span class="gw-footer__ext" aria-hidden="true"></span></b> <i>[One line on what it is]</i><span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
118
+ <li><a href="[https://another site of the brand]" rel="noopener"><b>[Product] <span class="gw-footer__ext" aria-hidden="true">↗</span></b> <i>[One line on what it is]</i></a></li>
119
+ <li><a href="[https://a sibling brand's site]" target="_blank" rel="noopener"><b>[Product] <span class="gw-footer__ext" aria-hidden="true">↗</span></b> <i>[One line on what it is]</i><span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
119
120
  </ul>
120
121
  </details>
121
122
  </nav>
@@ -126,7 +127,7 @@
126
127
  <li><a href="[/terms]">Terms</a></li>
127
128
  </ul>
128
129
  <!-- OPTIONAL theme toggle: the header's toggle, here instead of (or as well as) in the header -->
129
- <button class="gw-theme-toggle gw-footer__toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor"/></svg></button>
130
+ <button class="gw-theme-toggle gw-footer__toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme"><svg class="gw-theme-toggle__moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M21 12.8A8 8 0 1111.2 3a6 6 0 009.8 9.8z"/></svg><svg class="gw-theme-toggle__sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/></svg></button>
130
131
  <ul aria-label="Social">
131
132
  <li><a href="[https://social profile]" rel="noopener">[Network]</a></li>
132
133
  <li><a href="[https://social profile]" rel="noopener">[Network]</a></li>
@@ -134,7 +135,8 @@
134
135
  </div>
135
136
  <!-- OPTIONAL wordmark: decoration (aria-hidden="true", the name is already in the brand block). The mark is an
136
137
  inline SVG drawn in currentColor, so it takes the faint colour; an <img> cannot, so leave the mark out and keep
137
- the name. Not shown on phones. -->
138
+ the name. Not shown up to 620px. The mark is 7vw wide; give the <svg> width and height attributes for its
139
+ shape (gemboss.ai's gem: width="86" height="100"). -->
138
140
  <div class="gw-footer__wordmark" aria-hidden="true">
139
141
  <svg viewBox="[0 0 width height]">[the brand's mark, every fill and stroke "currentColor"]</svg>
140
142
  <span>[Brand]</span>