@gemboss/website-ui 0.8.0 → 0.10.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.js CHANGED
@@ -1,11 +1,12 @@
1
- /*! @gemboss/website-ui 0.8.0 website.js | GENERATED by scripts/build.mjs from js/website.mjs (the source, with its comments): edit that file | spec: docs/markup-contract.md | load with defer */
1
+ /*! @gemboss/website-ui 0.10.0 website.js | GENERATED by scripts/build.mjs from js/website.mjs (the source, with its comments): edit that file | spec: docs/markup-contract.md | load with defer */
2
2
  (function () {
3
3
  'use strict';
4
4
  if (window.gwWebsite) return;
5
5
 
6
- var version = '0.8.0';
6
+ var version = '0.10.0';
7
7
  var api = { version: version, init: init, destroy: destroy };
8
8
  var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
9
+ var still, lights;
9
10
  var READY = 'data-gw-ready';
10
11
 
11
12
  function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
@@ -377,12 +378,57 @@ function folds(ctx) {
377
378
  });
378
379
  }
379
380
 
381
+ var TRAIL = 0.18, SETTLE = 0.3, HEADING = /^H[1-6]$/;
382
+ function spot(ctx) {
383
+ if (lights) each(ctx, '[data-gw-spot]', 'spot', function (el) {
384
+ var sel = attr(el, 'data-gw-spot', ''), t = el;
385
+ if (sel) { try { t = el.querySelector(sel); } catch (e) { t = null; } }
386
+ if (!t || HEADING.test(t.tagName) || HEADING.test(el.tagName)) return;
387
+ var had = t.classList.contains('gw-spot'), x = 0, y = 0, px = 0, py = 0, raf = 0, heard = false;
388
+ function put(nx, ny) {
389
+ x = nx; y = ny;
390
+ t.style.setProperty('--gw-spot-x', x.toFixed(1) + 'px');
391
+ t.style.setProperty('--gw-spot-y', y.toFixed(1) + 'px');
392
+ }
393
+ function note(e) { if (e.pointerType === 'touch') return false; px = e.clientX; py = e.clientY; return true; }
394
+ function aim() { var b = t.getBoundingClientRect(); return [px - b.left - (t.clientLeft || 0), py - b.top - (t.clientTop || 0)]; }
395
+ function step() {
396
+ var a = aim(), nx = x + (a[0] - x) * TRAIL, ny = y + (a[1] - y) * TRAIL, done = Math.abs(a[0] - nx) < SETTLE && Math.abs(a[1] - ny) < SETTLE;
397
+ if (done) put(a[0], a[1]); else put(nx, ny);
398
+ raf = done ? 0 : requestAnimationFrame(step);
399
+ }
400
+ function halt() { cancelAnimationFrame(raf); raf = 0; }
401
+ function enter(e) { if (note(e)) { halt(); put.apply(null, aim()); } }
402
+ function move(e) { if (note(e) && !raf) raf = requestAnimationFrame(step); }
403
+ function listen(want) {
404
+ if (want === heard) return;
405
+ heard = want;
406
+ [['pointerenter', enter], ['pointermove', move]].forEach(function (l) {
407
+ if (want) el.addEventListener(l[0], l[1]); else el.removeEventListener(l[0], l[1]);
408
+ });
409
+ if (!want) halt();
410
+ }
411
+ var r = {
412
+ fit: function () { listen(fine.matches && !still.matches); },
413
+ stop: function () {
414
+ listen(false);
415
+ if (!had) t.classList.remove('gw-spot');
416
+ t.style.removeProperty('--gw-spot-x');
417
+ t.style.removeProperty('--gw-spot-y');
418
+ }
419
+ };
420
+ t.classList.add('gw-spot');
421
+ r.fit();
422
+ return r;
423
+ });
424
+ }
425
+
380
426
  function install() {
381
427
  doc = document; root = doc.documentElement; mine = new WeakSet();
382
428
  fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
383
429
  var stored = null;
384
430
  wide = mq('(min-width: 1080px)');
385
- mid = mq('(min-width: 768px)');
431
+ mid = mq('(min-width: 621px)');
386
432
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
387
433
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
388
434
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
@@ -390,6 +436,10 @@ function install() {
390
436
  on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
391
437
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
392
438
  watch(mid, function () { records('fold', function (r) { r.fit(); }); });
439
+ still = mq('(prefers-reduced-motion: reduce)');
440
+ var css = window.CSS;
441
+ lights = !!(css && css.registerProperty && css.supports && css.supports('color', 'color-mix(in srgb, red 50%, blue)'));
442
+ [fine, still].forEach(function (m) { watch(m, function () { records('spot', function (r) { r.fit(); }); }); });
393
443
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
394
444
  try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
395
445
  if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
@@ -414,7 +464,7 @@ function init(ctx) {
414
464
  window.gwWebsite = api;
415
465
  if (!doc) run([install]);
416
466
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
417
- run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
467
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds, spot], ctx && ctx.querySelectorAll ? ctx : doc);
418
468
  }
419
469
 
420
470
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
package/js/website.mjs CHANGED
@@ -6,10 +6,11 @@
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.8.0';
9
+ var version = '0.10.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
12
  var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
13
+ var still, lights;
13
14
  var READY = 'data-gw-ready';
14
15
 
15
16
  function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
@@ -424,11 +425,11 @@ function tables(ctx) {
424
425
  });
425
426
  }
426
427
 
427
- // Footer folds (0.7.0, gap #40): each <details class="gw-footer__fold"> (a column, a family row). Below 768px it keeps
428
- // the markup's own state (folded unless the markup says open) and the browser opens and closes it. From 768px it is a
428
+ // Footer folds (0.7.0, gap #40): each <details class="gw-footer__fold"> (a column, a family row). Up to 620px it keeps
429
+ // the markup's own state (folded unless the markup says open) and the browser opens and closes it. From 621px it is a
429
430
  // plain column: the script sets `open`, takes the <summary> out of the Tab order (tabindex="-1") and ignores a click on
430
431
  // it, so the heading cannot fold the list away. Again on every change of width; destroy gives back the markup's state.
431
- // The CSS shows the list from 768px without the script too (::details-content), so this is for state and for browsers
432
+ // The CSS shows the list from 621px without the script too (::details-content), so this is for state and for browsers
432
433
  // without that.
433
434
  function folds(ctx) {
434
435
  each(ctx, '.gw-footer__fold', 'fold', function (d, on) {
@@ -445,6 +446,63 @@ function folds(ctx) {
445
446
  });
446
447
  }
447
448
 
449
+ // Spotlight (0.9.0, issue #74): data-gw-spot on an <a href>, a <button>, or a card that holds exactly one of them. A
450
+ // light in the accent follows the pointer over it (css/effects.css draws it). The element that carries the light, the
451
+ // host or the first one its data-gw-spot="<selector>" names inside it (never a heading), gets .gw-spot and the
452
+ // pointer's place on it, in px from its padding box, as --gw-spot-x and --gw-spot-y. The light trails the pointer: on
453
+ // each frame it goes TRAIL of the way that is left (one requestAnimationFrame loop per element, stopped once it is
454
+ // within SETTLE px), and it starts where the pointer comes in, never sliding in from the middle. A move only notes
455
+ // where the pointer is; the element is measured once a frame, in the loop. The two listeners are there only on a fine
456
+ // pointer and without reduced motion (where the CSS keeps the light still, in the middle), again on every change of
457
+ // either. Nothing is wired without @property and color-mix() (lights), so nothing is drawn. TRAIL and SETTLE are
458
+ // system.json's motion.spot.trail and .settle (the tests hold them equal). No event.
459
+ var TRAIL = 0.18, SETTLE = 0.3, HEADING = /^H[1-6]$/;
460
+ function spot(ctx) {
461
+ if (lights) each(ctx, '[data-gw-spot]', 'spot', function (el) {
462
+ var sel = attr(el, 'data-gw-spot', ''), t = el;
463
+ if (sel) { try { t = el.querySelector(sel); } catch (e) { t = null; } }
464
+ if (!t || HEADING.test(t.tagName) || HEADING.test(el.tagName)) return;
465
+ var had = t.classList.contains('gw-spot'), x = 0, y = 0, px = 0, py = 0, raf = 0, heard = false;
466
+ function put(nx, ny) {
467
+ x = nx; y = ny;
468
+ t.style.setProperty('--gw-spot-x', x.toFixed(1) + 'px');
469
+ t.style.setProperty('--gw-spot-y', y.toFixed(1) + 'px');
470
+ }
471
+ // where the pointer is (a mouse or a pen; never a finger)
472
+ function note(e) { if (e.pointerType === 'touch') return false; px = e.clientX; py = e.clientY; return true; }
473
+ // the pointer's place on the element that carries the light, measured now
474
+ function aim() { var b = t.getBoundingClientRect(); return [px - b.left - (t.clientLeft || 0), py - b.top - (t.clientTop || 0)]; }
475
+ function step() {
476
+ var a = aim(), nx = x + (a[0] - x) * TRAIL, ny = y + (a[1] - y) * TRAIL, done = Math.abs(a[0] - nx) < SETTLE && Math.abs(a[1] - ny) < SETTLE;
477
+ if (done) put(a[0], a[1]); else put(nx, ny);
478
+ raf = done ? 0 : requestAnimationFrame(step);
479
+ }
480
+ function halt() { cancelAnimationFrame(raf); raf = 0; }
481
+ function enter(e) { if (note(e)) { halt(); put.apply(null, aim()); } }
482
+ function move(e) { if (note(e) && !raf) raf = requestAnimationFrame(step); }
483
+ function listen(want) {
484
+ if (want === heard) return;
485
+ heard = want;
486
+ [['pointerenter', enter], ['pointermove', move]].forEach(function (l) {
487
+ if (want) el.addEventListener(l[0], l[1]); else el.removeEventListener(l[0], l[1]);
488
+ });
489
+ if (!want) halt();
490
+ }
491
+ var r = {
492
+ fit: function () { listen(fine.matches && !still.matches); },
493
+ stop: function () {
494
+ listen(false);
495
+ if (!had) t.classList.remove('gw-spot');
496
+ t.style.removeProperty('--gw-spot-x');
497
+ t.style.removeProperty('--gw-spot-y');
498
+ }
499
+ };
500
+ t.classList.add('gw-spot');
501
+ r.fit();
502
+ return r;
503
+ });
504
+ }
505
+
448
506
  // Once per page: what belongs to the document, the window or a media query rather than to one element.
449
507
  function install() {
450
508
  doc = document; root = doc.documentElement; mine = new WeakSet();
@@ -452,8 +510,8 @@ function install() {
452
510
  var stored = null;
453
511
  // 1080px, the kit's desktop width: the header shows its nav, the contents sit beside the article
454
512
  wide = mq('(min-width: 1080px)');
455
- // 768px: the footer's columns stand open from here, and fold below it
456
- mid = mq('(min-width: 768px)');
513
+ // 621px: the footer's columns stand open from here, and fold up to 620px, as on gemboss.ai (1:1, 26/10/2026)
514
+ mid = mq('(min-width: 621px)');
457
515
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
458
516
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
459
517
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
@@ -462,6 +520,11 @@ function install() {
462
520
  on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
463
521
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
464
522
  watch(mid, function () { records('fold', function (r) { r.fit(); }); });
523
+ // the spotlight: a fine pointer, motion welcome, and a browser that can draw the light (@property, color-mix)
524
+ still = mq('(prefers-reduced-motion: reduce)');
525
+ var css = window.CSS;
526
+ lights = !!(css && css.registerProperty && css.supports && css.supports('color', 'color-mix(in srgb, red 50%, blue)'));
527
+ [fine, still].forEach(function (m) { watch(m, function () { records('spot', function (r) { r.fit(); }); }); });
465
528
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
466
529
  try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
467
530
  if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
@@ -492,7 +555,7 @@ function init(ctx) {
492
555
  if (!doc) run([install]);
493
556
  // an element removed from the page since the last init is let go
494
557
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
495
- run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
558
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds, spot], ctx && ctx.querySelectorAll ? ctx : doc);
496
559
  }
497
560
 
498
561
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.8.0",
3
+ "version": "0.10.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/skill/SKILL.md CHANGED
@@ -32,6 +32,8 @@ the kit and its rules are how the page ends up looking designed. Compose from th
32
32
  uses its drill-down mobile menu (`data-gw-menu="drill"`, a head with Back and the title in each group); a site in
33
33
  more than one language keeps its language switcher (`.gw-lang`, `hidden`, empty) and gives every page its
34
34
  `<link rel="alternate" hreflang>`: the switcher lists those, and stays hidden on a page in one language.
35
+ - Spotlight (`data-gw-spot`): only on a link, a button or a card with exactly one action (a plan, a link card, the
36
+ mega menu's feature card, a night band's primary button). Never on a heading or text; the kit draws it.
35
37
  - Changed page: keep its kit classes; change words, order and choice of sections.
36
38
  - Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
37
39
  scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.
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",
@@ -89,14 +89,14 @@
89
89
  "type": {
90
90
  "family": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif",
91
91
  "mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
92
- "weights": [400, 500, 600, 650, 700],
92
+ "weights": [400, 500, 600, 650, 700, 800],
93
93
  "scale": [11, 12, 13, 14, 16, 18, 20, 22, 24, 30],
94
94
  "display_above": 30,
95
95
  "display": {
96
96
  "h1": "clamp(40px, 4.6vw, 68px)",
97
97
  "h2": "clamp(32px, 3.4vw, 52px)",
98
98
  "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.",
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. Weight 800 (0.10.0, as gemboss.ai draws it): the one use of 800 in the kit.",
100
100
  "weight": 600,
101
101
  "tracking": "-0.03em"
102
102
  },
@@ -154,6 +154,18 @@
154
154
  "ease": "cubic-bezier(.22,1,.36,1)",
155
155
  "reveal_ms": 700,
156
156
  "spin_ms": 700,
157
- "$comment": "Animate transform and opacity only. Everything is still under prefers-reduced-motion (a busy button's ring turns once every spin_ms, as the dashboard's spinner, and stands still as an ellipsis)."
157
+ "$comment": "Animate transform and opacity only. Everything is still under prefers-reduced-motion (a busy button's ring turns once every spin_ms, as the dashboard's spinner, and stands still as an ellipsis). One exception, the spotlight (spot): its light is one painted layer of one element, so its radius may grow.",
158
+ "spot": {
159
+ "$comment": "The spotlight (0.9.0, Chris 25/10/2026, issue #74): data-gw-spot on a link, a button or a card with one action. A light in the accent follows a fine pointer: each frame it goes trail of the way that is left, and stops within settle px. Its radius grows to radius in ms with ease; its middle is strength percent of the accent over the element's own fill. A .gw-btn takes radius_action and strength_action (a small, filled thing), the night ground of the mega menu's feature card (the Stage) strength_stage. Under reduced motion it stands still, centred, and does not grow.",
160
+ "radius": 260,
161
+ "radius_action": 120,
162
+ "strength": 22,
163
+ "strength_action": 38,
164
+ "strength_stage": 40,
165
+ "ms": 550,
166
+ "ease": "cubic-bezier(.16,1,.3,1)",
167
+ "trail": 0.18,
168
+ "settle": 0.3
169
+ }
158
170
  }
159
171
  }
@@ -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>