@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.
- package/AGENTS.md +8 -3
- package/CHANGELOG.md +60 -0
- package/README.md +5 -5
- package/bin/lib/check.mjs +19 -0
- package/css/effects.css +71 -0
- package/css/tokens.css +7 -0
- package/demo/index.html +1 -0
- package/demo/spotlight.html +291 -0
- package/docs/components.md +48 -1
- package/docs/markup-contract.md +37 -4
- package/docs/theming.md +6 -0
- package/js/website.js +53 -3
- package/js/website.mjs +65 -2
- package/package.json +1 -1
- package/skill/SKILL.md +2 -0
- package/system.json +13 -1
- package/website.css +80 -1
- package/website.min.css +1 -1
package/docs/components.md
CHANGED
|
@@ -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),
|
|
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`),
|
package/docs/markup-contract.md
CHANGED
|
@@ -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),
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|