@gemboss/website-ui 0.8.0 → 0.9.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.
@@ -36,7 +36,7 @@ Don't remove the skip link, and don't leave out `id="main"`.
36
36
 
37
37
  `js/website.js` (a `<script defer>`) or `js/website.mjs` (an `import`) wires every part that moves: the mega menu,
38
38
  the mobile menu, the theme toggle, reveal, the header's scroll state, the current page, the segmented control, the
39
- busy button, an article's contents list and the scroll box of its tables (0.5.0), the footer's folds (0.7.0), and the mobile menu's drill-down and the language switcher (0.8.0). Markup that arrives after the page loaded (a client-side route change, a builder block, a CMS
39
+ busy button, an article's contents list and the scroll box of its tables (0.5.0), the footer's folds (0.7.0), the mobile menu's drill-down and the language switcher (0.8.0), and the spotlight (0.9.0). Markup that arrives after the page loaded (a client-side route change, a builder block, a CMS
40
40
  preview) is wired by calling `init` on it, as often as needed: an element is wired once.
41
41
 
42
42
  ```js
@@ -833,6 +833,53 @@ not make public with the review; masonry or a carousel that moves by itself.
833
833
  Put it on a block (a grid, a list, a window), not on each card in a grid. Don't use it on the hero or anything in
834
834
  the first screen, and don't animate anything but `transform` and `opacity`.
835
835
 
836
+ ### Spotlight `data-gw-spot` (0.9.0)
837
+
838
+ A soft light in the brand's accent that follows a mouse over a thing you click (Chris, 25/10/2026). Put
839
+ `data-gw-spot` on the `<a href>` or the `<button>` itself, or on a card that holds exactly one of them, and nowhere
840
+ else:
841
+
842
+ | where | markup |
843
+ |---|---|
844
+ | a plan card (its one `.gw-btn` is the action) | `<div class="gw-plan" data-gw-spot>…<a class="gw-btn" href="/signup">…</a>…</div>` |
845
+ | a link card | `<a class="gw-card gw-card--link" href="/mcp" data-gw-spot>…</a>` |
846
+ | the mega menu's feature card (the Stage) | `<a class="gw-mega__feature" href="/engine" data-gw-spot>…</a>` |
847
+ | the primary button of a night band | `<a class="gw-btn" href="/signup" data-gw-spot>Start free</a>` inside `.gw-band--night` |
848
+ | an inner element only (the art of a card) | `<a class="gw-card gw-card--link" href="/engine" data-gw-spot=".gp-card-art"><span class="gp-card-art" aria-hidden="true"></span>…</a>` |
849
+
850
+ What it does: on hover the light grows from where the pointer comes in (0.55s, an expo-out ease), then trails the
851
+ pointer a little (18% of the way each frame) and goes out when it leaves. It sits under the words, in the element's own
852
+ corners, and moves nothing. It is `--gw-accent` (22% on a card, 38% on a button, 40% on the Stage's night ground), the
853
+ `-dark` family in dark mode and night bands, so another brand's page lights in its own accent by itself. While a thing
854
+ is lit, its muted words read `--gw-ink-2`, so they keep 4.5:1 at the light's middle; they turn back once the light has
855
+ gone. Nothing moves for it, and nothing in your markup changes.
856
+
857
+ - Opt-in, element by element: a page without `data-gw-spot` does not change. Use it on the parts above, and more only
858
+ where it is a link or a button; one lit thing per card.
859
+ - Pointer devices only: nothing changes on a phone or a tablet without a mouse.
860
+ - Keyboard focus shows the same light in the middle, as large as on hover: on the focused link or button, and on a card
861
+ whose one action has the focus.
862
+ - Reduced motion: the light stands still in the middle and appears at once (the muted words with it); the script
863
+ follows no pointer.
864
+ - A browser without `@property` or `color-mix()` shows no light at all (nothing half drawn).
865
+ - The script wires it like every other part (`data-gw-ready="spot"`, `init`/`destroy`) and puts `.gw-spot` on the
866
+ element that carries the light, with the pointer's place in `--gw-spot-x` and `--gw-spot-y`. No event. Its values are
867
+ tokens (`system.json` `motion.spot`: `--gw-spot-radius`, `-radius-action`, `-strength`, `-strength-action`,
868
+ `-strength-stage`, `-ms`, `-ease`).
869
+
870
+ Don't: put it on a heading, a paragraph, a list of text or a section (`check` reports `spot-target`), or on a card
871
+ with no action or with two; light words, or a heading, with a gradient or the accent (still forbidden: a heading
872
+ that glows under the pointer was looked at and turned down); a light of more than one colour, or a colour of your own;
873
+ write `.gw-spot` or `--gw-spot-*` yourself, or follow the pointer with your own script.
874
+
875
+ ## Additions to the markup contract in 0.9.0
876
+
877
+ The spotlight: the `data-gw-spot` hook (empty, or a selector for an inner element), the script's `spot` part
878
+ (`data-gw-ready="spot"`) and its `.gw-spot` class on the element that carries the light, with `--gw-spot-x` and
879
+ `--gw-spot-y` on it. Tokens: `--gw-spot-radius`, `--gw-spot-radius-action`, `--gw-spot-strength`,
880
+ `--gw-spot-strength-action`, `--gw-spot-strength-stage`, `--gw-spot-ms`, `--gw-spot-ease`. No event. No markup or
881
+ class of 0.8.0 changes.
882
+
836
883
  ## Additions to the markup contract in 0.8.0
837
884
 
838
885
  The drill-down mobile menu: `data-gw-menu="drill"` on `.gw-mobile-menu`, `.gw-mobile-menu__head` (`__back`, `__title`),
@@ -28,8 +28,8 @@ BEM-ish: `.gw-block`, `.gw-block__part`, `.gw-block--variant`.
28
28
 
29
29
  One script wires every part below: the mega menu, the mobile menu, the theme toggle, reveal, the header's scroll
30
30
  state, the current page, the segmented control, the busy button, an article's contents list and the scroll box of its
31
- prose tables (0.5.0), the footer's folds (0.7.0), and the mobile menu's drill-down and the language switcher (0.8.0).
32
- It comes in two entries built from one source
31
+ prose tables (0.5.0), the footer's folds (0.7.0), the mobile menu's drill-down and the language switcher (0.8.0), and
32
+ the spotlight (0.9.0). It comes in two entries built from one source
33
33
  (`js/website.mjs`; `scripts/build.mjs` writes `js/website.js` from it and `--check` fails when they differ):
34
34
 
35
35
  | entry | file | how it starts |
@@ -44,9 +44,9 @@ It comes in two entries built from one source
44
44
  to the page (Escape, the outside click, the busy-button guard, the system theme, the scroll) are installed once.
45
45
  - **`destroy(root = document)`** takes back what `init` wired under `root`: its listeners and its marks. An open
46
46
  mega menu or mobile menu is closed first, so an unmounted menu cannot leave the page locked.
47
- - **`version`**: `'0.8.0'`. The classic script exposes the three as `window.gwWebsite`.
47
+ - **`version`**: `'0.9.0'`. The classic script exposes the three as `window.gwWebsite`.
48
48
  - **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `lang`, `theme`, `reveal`,
49
- `header`, `segmented`, `toc`, `table`, `fold`). A drill-down menu is wired with its toggle (`menu`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
49
+ `header`, `segmented`, `toc`, `table`, `fold`, `spot`). A drill-down menu is wired with its toggle (`menu`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
50
50
  is still wired.
51
51
  - **One copy per page.** The first copy that runs owns the page (`window.gwWebsite`); another one (the classic
52
52
  script next to a bundle, two bundles) hands its calls to it, so nothing is wired twice.
@@ -412,6 +412,39 @@ CSS only: the script does not touch a form. The page (or its server) owns valida
412
412
  carry the same number (`stars-markup`), and a `.gw-rating-badge` or `.gw-testimonial` without an `<a href>`
413
413
  (`rating-source`, a warning).
414
414
 
415
+ ## Spotlight (0.9.0)
416
+
417
+ ```html
418
+ <div class="gw-plan" data-gw-spot> … <a class="gw-btn" href="/signup">Start free</a> … </div>
419
+ <a class="gw-card gw-card--link" href="/mcp" data-gw-spot> … </a>
420
+ <a class="gw-mega__feature" href="/engine" data-gw-spot> … </a>
421
+ <a class="gw-btn" href="/signup" data-gw-spot>Start free</a> <!-- inside .gw-band--night -->
422
+ <a class="gw-card gw-card--link" href="/engine" data-gw-spot=".gp-card-art"> <span class="gp-card-art"></span> … </a>
423
+ ```
424
+
425
+ - Markup: `data-gw-spot` on an `<a href>`, a `<button>`, or a `<div>`, `<article>` or `<li>` that holds exactly one
426
+ link or button. Empty, the host carries the light; a selector (`data-gw-spot=".gp-card-art"`) names the inner
427
+ element that carries it instead (the first match). Never a heading, as the host or as the selector's match. `check`
428
+ reports anything else (`spot-target`).
429
+ - Script behaviour (`spot`): in a browser with `@property` and `color-mix()` (else nothing is wired), each host is
430
+ wired once (`data-gw-ready="spot"`), and the element that carries the light gets `.gw-spot` (kept on `destroy` when
431
+ the markup wrote it). On a fine pointer (`(hover: hover) and (pointer: fine)`) without reduced motion, and again on
432
+ every change of either, the host has a `pointerenter` and a `pointermove` listener: entering writes the pointer's
433
+ place at once, in px from the padding box of the element that carries the light, as `--gw-spot-x` and
434
+ `--gw-spot-y` on it; a move asks for one animation frame, and each frame moves the place 18% of the way to the
435
+ pointer until it is within 0.3px (`system.json` `motion.spot.trail`, `.settle`). A touch pointer is ignored.
436
+ `destroy` removes the listeners, cancels the frame, takes `.gw-spot` and the two properties away. No event.
437
+ - CSS (`css/effects.css`, all of it under `(hover: hover) and (pointer: fine)`): the host's `--gw-spot-r` (a registered,
438
+ inherited length) is 0 at rest and `--gw-spot-radius` on hover, on `:focus-visible` and while a descendant has
439
+ `:focus-visible` (`--gw-spot-radius-action` on a `.gw-btn`); the `::before` of `.gw-spot` draws a radial gradient of
440
+ `--gw-accent` at that radius and place, under the words, with `border-radius: inherit`, and transitions the radius
441
+ (`--gw-spot-ms`, `--gw-spot-ease`). Keyboard focus and reduced motion put the light in the middle (on the
442
+ `::before`, so the script's place does not count); reduced motion also drops the transition. Not on a busy button
443
+ (its `::before` is the ring). While the host is lit (hover, `:focus-visible`, a descendant with `:focus-visible`) it
444
+ sets `--gw-muted: var(--gw-ink-2)`, so muted words inside it keep 4.5:1 at the light's middle; descendants take back
445
+ the muted colour only once the light has gone (`transition: color 0s` with the light's time as delay, in `:where()`,
446
+ so a part's own transition wins; none under reduced motion).
447
+
415
448
  ## Brand profiles (0.3.0)
416
449
 
417
450
  - `data-gw-action` and `data-gw-radius` sit on `<html>` only, with a name from `system.json` `profiles` (`ink` or
package/docs/theming.md CHANGED
@@ -41,6 +41,11 @@ Why the `-light` / `-dark` names and not `--gw-accent` itself: the kit sets `--g
41
41
  band. Override the eight primitives once and all of them follow. Overriding `--gw-accent` directly works only until
42
42
  someone presses the theme toggle.
43
43
 
44
+ The spotlight (0.9.0, `data-gw-spot`) is the accent too: its light is `--gw-accent` mixed over what it lights, so a
45
+ brand's page lights in the brand's own colour, in light, in dark and in night bands, with nothing to set. The light
46
+ takes the corners of the element it lights, so both radius profiles hold. A brand does not choose its colour, its
47
+ strength or its size (they are tokens), and never a second colour.
48
+
44
49
  The logo and the favicon are files, not variables: the `<img class="gw-brand__mark">` (26×26, SVG preferred, with
45
50
  `alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
46
51
  partials, and `<link rel="icon">` in the page head.
@@ -94,6 +99,7 @@ the panels; in `tight` that is the 16px the header was designed with.
94
99
  - **Accent-coloured section bands.** A section is white, `--gw-bg-soft` or a night band; the accent only lights a
95
100
  night band's top edge.
96
101
  - **All-caps labels.** Labels are sentence case, muted, led by the 8px accent square.
102
+ - **A light on a heading, or in more than one colour.** The spotlight lights things you click, in the accent only.
97
103
 
98
104
  These make a page louder without making it clearer, and `check` reports the last one (`uppercase-label`) and any
99
105
  button filled from site CSS (`accent-button`).
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.9.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.9.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,6 +378,51 @@ 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)');
@@ -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.9.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 }; }
@@ -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();
@@ -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.9.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
@@ -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
  }
package/website.css CHANGED
@@ -1,4 +1,4 @@
1
- /*! @gemboss/website-ui 0.8.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
1
+ /*! @gemboss/website-ui 0.9.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
2
2
  /* @gemboss/website-ui tokens. GENERATED from system.json by scripts/build.mjs: do not edit by hand. */
3
3
  :root {
4
4
  color-scheme: light;
@@ -70,6 +70,13 @@
70
70
  --gw-ease: cubic-bezier(.22,1,.36,1);
71
71
  --gw-reveal-ms: 700ms;
72
72
  --gw-spin-ms: 700ms;
73
+ --gw-spot-radius: 260px;
74
+ --gw-spot-radius-action: 120px;
75
+ --gw-spot-strength: 22%;
76
+ --gw-spot-strength-action: 38%;
77
+ --gw-spot-strength-stage: 40%;
78
+ --gw-spot-ms: 550ms;
79
+ --gw-spot-ease: cubic-bezier(.16,1,.3,1);
73
80
  --gw-wash-pink: #ff6f5e;
74
81
  --gw-wash-violet: #7c8af7;
75
82
  --gw-wash-sky: #43b0ff;
@@ -2229,3 +2236,75 @@ a.gw-rating-badge:hover { border-color: var(--gw-line-strong); }
2229
2236
  .gw-rating-badge__body { display: grid; gap: 2px; min-width: 0; }
2230
2237
  .gw-rating-badge__score { display: flex; align-items: center; gap: 8px; color: var(--gw-ink); font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
2231
2238
  .gw-rating-badge__count { color: var(--gw-muted); }
2239
+
2240
+ /* effects: the spotlight (0.9.0, Chris 25/10/2026, issue #74), a named hover effect for the things you click.
2241
+ data-gw-spot on an <a href>, a <button>, or a card that holds exactly one of them (a plan card, the mega menu's
2242
+ feature card, the primary button of a night band, a .gw-card--link): a soft light in the brand's accent follows the
2243
+ pointer over it. Opt-in per element: a page without the attribute does not change.
2244
+ website.js wires the host (data-gw-ready="spot") and puts .gw-spot on the element that carries the light: the host,
2245
+ or the one its data-gw-spot="<selector>" names inside it. It writes the pointer's place there (--gw-spot-x,
2246
+ --gw-spot-y, trailing it a little every frame); this file draws the light on its ::before, under its words, in its
2247
+ own corners (border-radius: inherit, so the radius profile holds), and grows the light's radius on hover.
2248
+ - Pointer devices only: nothing here applies without (hover: hover) and (pointer: fine).
2249
+ - Keyboard focus (on the host, or on the one action of a card) shows the same light, centred.
2250
+ - Reduced motion: the light stands still, centred, and appears without a transition.
2251
+ - Words under the light: while a thing is lit, its muted words read --gw-ink-2 (the token, set as --gw-muted on the
2252
+ host), so they keep 4.5:1 at the light's middle; they turn back once the light has gone. A colour, no movement.
2253
+ - Without @property or color-mix(), website.js puts .gw-spot nowhere: no light at all, nothing half drawn.
2254
+ - One colour, the accent family, as the page has it (the -dark family in dark mode and in night bands). Never on a
2255
+ heading or running text: a heading stays ink, with no gradient and no accent words (check: spot-target).
2256
+ The radius is the one animated property that is neither transform nor opacity: it repaints one layer of one
2257
+ element. Registered, so it can transition; inherited, so the ::before (and an inner element) reads the host's. */
2258
+ @property --gw-spot-r {
2259
+ syntax: "<length>";
2260
+ inherits: true;
2261
+ initial-value: 0px;
2262
+ }
2263
+
2264
+ @media (hover: hover) and (pointer: fine) {
2265
+ /* the host: no light at rest (a lit card does not light a lit button inside it), the light's size on hover and focus */
2266
+ [data-gw-ready~="spot"] { --gw-spot-r: 0px; }
2267
+ [data-gw-ready~="spot"]:is(:hover, :focus-visible) { --gw-spot-r: var(--gw-spot-radius); --gw-muted: var(--gw-ink-2); }
2268
+ /* a card whose one action has the keyboard focus; a rule of its own, so a browser without :has() drops only it */
2269
+ [data-gw-ready~="spot"]:has(:focus-visible) { --gw-spot-r: var(--gw-spot-radius); --gw-muted: var(--gw-ink-2); }
2270
+ .gw-btn[data-gw-ready~="spot"]:is(:hover, :focus-visible) { --gw-spot-r: var(--gw-spot-radius-action); }
2271
+
2272
+ /* the element that carries the light: positioned for its layer, isolated so the layer sits under its words.
2273
+ :where(), so a site's own position for an inner element wins. */
2274
+ :where(.gw-spot) { position: relative; isolation: isolate; }
2275
+ /* the light. Not on a busy button: its ::before is the ring. */
2276
+ .gw-spot:not([aria-busy="true"])::before {
2277
+ --gw-spot-mix: var(--gw-spot-strength);
2278
+ content: "";
2279
+ position: absolute;
2280
+ inset: 0;
2281
+ z-index: -1;
2282
+ border-radius: inherit;
2283
+ background: radial-gradient(circle var(--gw-spot-r) at var(--gw-spot-x, 50%) var(--gw-spot-y, 50%), color-mix(in srgb, var(--gw-accent) var(--gw-spot-mix), transparent), transparent 70%);
2284
+ pointer-events: none;
2285
+ transition: --gw-spot-r var(--gw-spot-ms) var(--gw-spot-ease);
2286
+ }
2287
+ /* a button is small and filled: a stronger light. Its words keep their contrast: the light is the accent over the
2288
+ button's fill, so under the accent action profile it lies between the accent and the accent-strong, the two fills
2289
+ the build checks the words on. */
2290
+ .gw-btn.gw-spot:not([aria-busy="true"])::before { --gw-spot-mix: var(--gw-spot-strength-action); }
2291
+ /* the mega menu's feature card is the Stage, a night ground: a stronger light, or it would not show */
2292
+ .gw-mega__feature.gw-spot:not([aria-busy="true"])::before { --gw-spot-mix: var(--gw-spot-strength-stage); }
2293
+
2294
+ /* words under the light: lit, muted words are ink-2 at once (rules above); back to muted only once the light has gone
2295
+ out (a colour that waits the light's time, no movement). :where(), so a part's own transition wins. */
2296
+ :where([data-gw-ready~="spot"] *) { transition: color 0s linear var(--gw-spot-ms); }
2297
+ :where([data-gw-ready~="spot"]:is(:hover, :focus-visible) *) { transition-delay: 0s; }
2298
+ :where([data-gw-ready~="spot"]:has(:focus-visible) *) { transition-delay: 0s; }
2299
+
2300
+ /* keyboard focus: the light in the middle, so focus is never weaker than hover */
2301
+ :is([data-gw-ready~="spot"]:focus-visible, [data-gw-ready~="spot"]:focus-visible *).gw-spot::before { --gw-spot-x: 50%; --gw-spot-y: 50%; }
2302
+ :is([data-gw-ready~="spot"]:has(:focus-visible), [data-gw-ready~="spot"]:has(:focus-visible) *).gw-spot::before { --gw-spot-x: 50%; --gw-spot-y: 50%; }
2303
+ }
2304
+
2305
+ /* reduced motion: a still light in the middle, there at once and gone at once, the words with it (website.js follows
2306
+ no pointer then) */
2307
+ @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: reduce) {
2308
+ .gw-spot:not([aria-busy="true"])::before { --gw-spot-x: 50%; --gw-spot-y: 50%; transition: none; }
2309
+ :where([data-gw-ready~="spot"] *) { transition: none; }
2310
+ }