@gemboss/website-ui 0.7.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.
@@ -28,7 +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), and the footer's folds (0.7.0). 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
32
33
  (`js/website.mjs`; `scripts/build.mjs` writes `js/website.js` from it and `--check` fails when they differ):
33
34
 
34
35
  | entry | file | how it starts |
@@ -43,9 +44,9 @@ prose tables (0.5.0), and the footer's folds (0.7.0). It comes in two entries bu
43
44
  to the page (Escape, the outside click, the busy-button guard, the system theme, the scroll) are installed once.
44
45
  - **`destroy(root = document)`** takes back what `init` wired under `root`: its listeners and its marks. An open
45
46
  mega menu or mobile menu is closed first, so an unmounted menu cannot leave the page locked.
46
- - **`version`**: `'0.7.0'`. The classic script exposes the three as `window.gwWebsite`.
47
- - **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `theme`, `reveal`, `header`,
48
- `segmented`, `toc`, `table`, `fold`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
47
+ - **`version`**: `'0.9.0'`. The classic script exposes the three as `window.gwWebsite`.
48
+ - **`data-gw-ready`**: on each wired element, the parts wired on it (`mega`, `menu`, `lang`, `theme`, `reveal`,
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,
49
50
  is still wired.
50
51
  - **One copy per page.** The first copy that runs owns the page (`window.gwWebsite`); another one (the classic
51
52
  script next to a bundle, two bundles) hands its calls to it, so nothing is wired twice.
@@ -94,11 +95,13 @@ The script tells the page's own code what changed. Every event is a `CustomEvent
94
95
  | `gw:mega` | the trigger (`[data-gw-mega]`) | `{ id, open }`: the panel's `id`, `true` when it opened | a panel opens or closes: click, hover, Escape, a click outside, focus leaving, a link inside, `destroy` of an open one. Moving to another menu fires a close, then an open |
95
96
  | `gw:theme` | `<html>` | `{ theme: 'light' \| 'dark' }`: the theme the page now shows | a press on a theme toggle; also a change of the system's scheme while the page follows it (no `data-theme`) |
96
97
  | `gw:segmented` | the group (`[data-gw-segmented]`) | `{ name, value }`: the group's name, the chosen option's `value` | the reader chooses an option (click, arrows, Home, End) |
98
+ | `gw:menu` | the mobile menu (`.gw-mobile-menu`) | `{ open, level }`: `true` while it is open; `level` is the title of the drill-down level in view, `''` for the top level (and for a 0.7.0 menu) | the menu opens or closes (the toggle, Escape, a link inside, resizing past 1080px, `destroy` of an open one), and a drill-down menu moves to a level or back to the top. Closing from a level fires only the close (0.8.0) |
97
99
  | `gw:toc` | the contents (`[data-gw-toc]`) | `{ id }`: the id of the heading whose section is now marked, `''` above the first one | the section in view changes: scrolling, a contents link, a jump. Also once after load when the page opens inside a section (a link to `#id`): the observer reports after `init`, never during it |
98
100
 
99
101
  ```js
100
102
  document.addEventListener('gw:theme', function (e) { chart.setTheme(e.detail.theme); });
101
103
  document.addEventListener('gw:toc', function (e) { analytics.track('section', e.detail.id); });
104
+ document.addEventListener('gw:menu', function (e) { if (e.detail.level) analytics.track('menu', e.detail.level); });
102
105
  ```
103
106
 
104
107
  ## Header
@@ -125,13 +128,14 @@ document.addEventListener('gw:toc', function (e) { analytics.track('section', e.
125
128
  </nav>
126
129
  <div class="gw-header__actions">
127
130
  <button class="gw-theme-toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme">svg</button>
131
+ <div class="gw-lang" data-gw-lang hidden> … </div> <!-- optional: the language switcher (0.8.0, below) -->
128
132
  <a class="gw-nav__link gw-header__signin" href="…">Sign in</a>
129
133
  <a class="gw-btn gw-btn--secondary gw-btn--sm" href="…">Book a demo</a>
130
134
  <a class="gw-btn gw-btn--sm" href="…">Start free</a>
131
135
  <button class="gw-menu-toggle" type="button" aria-expanded="false" aria-controls="gw-mobile-menu" data-gw-menu-toggle aria-label="Open menu">svg</button>
132
136
  </div>
133
137
  </div>
134
- <div class="gw-mobile-menu" id="gw-mobile-menu" hidden> …the same links as lists, and the actions… </div>
138
+ <div class="gw-mobile-menu" id="gw-mobile-menu" data-gw-menu?="drill" hidden> …the same links as lists, and the actions… </div>
135
139
  </header>
136
140
  ```
137
141
 
@@ -159,6 +163,99 @@ Script behaviour:
159
163
  - **Current page**: the script marks `aria-current="page"` on `.gw-nav__link` / `.gw-mega__link` and the mobile
160
164
  menu's links whose `href` pathname equals `location.pathname` (when the markup did not already). Each `init` takes
161
165
  its own marks back first, so after a client-side navigation they follow the new path; a mark in the markup stays.
166
+ - **`gw:menu`** (0.8.0): opening and closing a mobile menu fire `gw:menu` on it (`{ open, level: '' }`; Events above).
167
+
168
+ ### Drill-down mobile menu (0.8.0)
169
+
170
+ Opt-in on the sheet, `data-gw-menu="drill"`; the markup is the 0.7.0 menu plus a head in each group. The groups, the
171
+ top-level links and the actions are children of the sheet's `<nav>`.
172
+
173
+ ```html
174
+ <div class="gw-mobile-menu" id="gw-mobile-menu" data-gw-menu="drill" hidden>
175
+ <nav class="gw-wrap" aria-label="Main, mobile">
176
+ <details class="gw-mobile-menu__group">
177
+ <summary>Product</summary>
178
+ <div class="gw-mobile-menu__head"><button class="gw-mobile-menu__back" type="button">Back</button><h2 class="gw-mobile-menu__title" tabindex="-1">Product</h2></div>
179
+ <p class="gw-mobile-menu__heading">Build</p> <!-- optional -->
180
+ <ul class="gw-mobile-menu__list">
181
+ <li><a class="gw-mobile-menu__link" href="/funnel"><span class="gw-mobile-menu__icon" aria-hidden="true">svg</span><span><b>Funnel builder</b><i>Angles, advertorials, offers</i></span></a></li>
182
+ <li><a class="gw-mobile-menu__link" href="/data">Analytics &amp; data</a></li> <!-- the icon and the line are optional -->
183
+ </ul>
184
+ <a class="gw-mobile-menu__all" href="/product">All of Product</a> <!-- optional -->
185
+ </details>
186
+ <a class="gw-mobile-menu__top" href="/pricing">Pricing</a>
187
+ <div class="gw-mobile-menu__actions"> <!-- the bar at the bottom -->
188
+ <div class="gw-lang" data-gw-lang hidden> … </div> <!-- optional: first line, left -->
189
+ <a class="gw-btn gw-btn--lg gw-btn--block" href="…">Start free</a>
190
+ <a class="gw-btn gw-btn--secondary gw-btn--lg gw-btn--block" href="…">Book a demo</a>
191
+ <a class="gw-mobile-menu__link" href="…">Sign in</a> <!-- first line, right -->
192
+ </div>
193
+ </nav>
194
+ </div>
195
+ ```
196
+
197
+ - **CSS, from the attribute** (works before and without the script): the sheet is a column with no bottom padding,
198
+ clipped sideways; top-level rows of 64px with 24px words; a level's links 56px with a hairline, their `<b>` and
199
+ `<i>`, the 32px `__icon` tile, the muted `__heading`; the actions are a bar that stays at the bottom of the sheet
200
+ (`position: sticky`): what is not a `.gw-btn` on the first line (two things at most), the last of those on the right,
201
+ the buttons side by side on the second line. Without the script the groups are `<details>` that open in place (the 0.7.0 menu), their
202
+ chevron pointing down, and the heads are not drawn.
203
+ - **Script** (`.is-drill` on the sheet once wired, with the toggle's `menu` record). Each group that has a `<summary>`
204
+ and a `.gw-mobile-menu__title` is a level; the script closes them all (and gives the markup's `open` back on
205
+ `destroy`). A press on a summary (click, tap, Enter, Space; its default is cancelled) opens that group alone, puts
206
+ `.is-drilled` on the sheet (the CSS then draws only that level and its head, and hides its summary), scrolls the
207
+ sheet to its top, moves the focus to the title and fires `gw:menu` (`{ open: true, level: <the title's words> }`).
208
+ A press on `.gw-mobile-menu__back` shows the top level (`.is-back` on the sheet, for the slide back), puts the focus
209
+ on the summary it came from and fires `gw:menu` (`level: ''`). Closing the sheet (the toggle, Escape from anywhere in
210
+ it, a link, resizing past 1080px, `destroy`) shows the top level again without an event of its own.
211
+ - **While it is open**: Tab on the sheet's last shown stop goes to the toggle, Shift+Tab on the toggle to that stop, and
212
+ a focus found outside comes back in (the trap; a 0.7.0 menu has none). The page is locked as in 0.7.0
213
+ (`html.is-menu-open`). As it opens, the script sets `--gw-menu-top` on the sheet to where the header ends, and the
214
+ header stays `sticky` (not `fixed`), so the sheet starts under the header and an announcement bar above it stays.
215
+ - **Motion**: a level slides in from the right, the sections back from the left (`transform` and `opacity`, 0.24s, the
216
+ kit's ease), only under `prefers-reduced-motion: no-preference`.
217
+ - `check` reports a group of a drill menu that is not a `<details>`, has no `<summary>`, no back button, no title or a
218
+ title without `tabindex="-1"`, and `data-gw-menu` with another value (`menu-markup`).
219
+
220
+ ### Language switcher (0.8.0)
221
+
222
+ ```html
223
+ <head>
224
+ <link rel="alternate" hreflang="en" href="https://example.com/pricing">
225
+ <link rel="alternate" hreflang="vi" href="https://example.com/vi/pricing">
226
+ <link rel="alternate" hreflang="x-default" href="https://example.com/pricing">
227
+ </head>
228
+ …
229
+ <div class="gw-lang" data-gw-lang hidden>
230
+ <button class="gw-lang__btn" type="button" aria-expanded="false" aria-controls="gw-lang-menu" data-gw-label-language="Language"><svg aria-hidden="true">globe</svg><span class="gw-lang__short"></span></button>
231
+ <ul class="gw-lang__menu" id="gw-lang-menu" hidden></ul>
232
+ </div>
233
+ ```
234
+
235
+ - **Where**: in `.gw-header__actions`, before Sign in (drawn from 1080px), and a second one, with its own list `id`, in
236
+ the mobile menu's `.gw-mobile-menu__actions` (its list opens upward there). Both are filled the same way.
237
+ - **The languages are the head's** `<link rel="alternate" hreflang href>`, not `x-default`, the first link of each
238
+ `hreflang`: the links search engines read. The current language is the one whose `hreflang` is `<html lang>`, else the
239
+ first with the same base language (`<html lang="zh">` is `zh-Hans`). The script writes each other language into the
240
+ list as `<li><a class="gw-lang__link" href hreflang lang>Name</a></li>`, the name in its own language: the `<link>`'s
241
+ `title`, else the kit's (`en` English, `vi` Tiếng Việt, `zh` and `zh-Hans` 中文), else the browser's
242
+ (`Intl.DisplayNames`, first letter capitalised), else the code. A link whose host is the host of
243
+ `<link rel="canonical">` (else this page's host) keeps only its path, search and hash, so a preview deployment stays
244
+ on itself. The button's `.gw-lang__short` gets the current language's short code (`EN`, `VI`, `中`, else its base code
245
+ in capitals) and its `lang`.
246
+ - **Hidden** while the list is empty: a page with no other language (no alternates, or only its own) keeps `hidden`;
247
+ the script takes it away when there is one. Every `init` reads the links again, so a route change that brings other
248
+ alternates updates both switchers.
249
+ - **Words**: `data-gw-label-language` on the button (else its `aria-label`, else "Language"): the button is named
250
+ "Language: English" (the word, then the current language's name), the list "Language". `destroy` gives back the
251
+ button's own label, empties the list and hides the switcher.
252
+ - **Keys and pointer**: the button opens and closes the list (`aria-expanded`); ArrowDown and ArrowUp on it open the
253
+ list on its first or last link; in the list ArrowDown and ArrowUp move (wrapping), Home and End go to the ends;
254
+ Escape closes it, gives the focus back to the button and stops there (an open mobile menu around it stays open); the
255
+ focus leaving it, or a click outside, closes it. Closing the mobile menu closes the list inside it. No event.
256
+ - `check` reports a `.gw-lang` that is not `hidden`, has no `data-gw-lang`, no `.gw-lang__btn`, a button whose
257
+ `aria-controls` names no list inside it or without `aria-expanded`, links typed into the list, and, on a page with a
258
+ switcher, a `<link rel="alternate">` without `type` that lacks `hreflang` or `href` (`lang-markup`).
162
259
 
163
260
  ## Footer (0.7.0)
164
261
 
@@ -315,6 +412,39 @@ CSS only: the script does not touch a form. The page (or its server) owns valida
315
412
  carry the same number (`stars-markup`), and a `.gw-rating-badge` or `.gw-testimonial` without an `<a href>`
316
413
  (`rating-source`, a warning).
317
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
+
318
448
  ## Brand profiles (0.3.0)
319
449
 
320
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
@@ -15,8 +15,9 @@ widths. That is what makes the sites look like one family, and why a site built
15
15
  still looks careful. `npx gemboss-website check` reports anything outside this set.
16
16
 
17
17
  Working examples: `demo/theme-example.html` (the accent only), `demo/brand-profiles.html` (a GemPages-style brand:
18
- the accent, all three profiles and the floating header) and `demo/footer.html` (the footer, with a switch to the same
19
- footer for a GemPages-style brand).
18
+ the accent, all three profiles and the floating header), `demo/footer.html` (the footer, with a switch to the same
19
+ footer for a GemPages-style brand) and `demo/header.html` (the header with the drill-down phone menu and the language
20
+ switcher, with the same switch: the bar's buttons and the language list follow the radius and action profiles).
20
21
 
21
22
  ## 1 to 3. Accent, wash, logo
22
23
 
@@ -40,6 +41,11 @@ Why the `-light` / `-dark` names and not `--gw-accent` itself: the kit sets `--g
40
41
  band. Override the eight primitives once and all of them follow. Overriding `--gw-accent` directly works only until
41
42
  someone presses the theme toggle.
42
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
+
43
49
  The logo and the favicon are files, not variables: the `<img class="gw-brand__mark">` (26×26, SVG preferred, with
44
50
  `alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
45
51
  partials, and `<link rel="icon">` in the page head.
@@ -93,6 +99,7 @@ the panels; in `tight` that is the 16px the header was designed with.
93
99
  - **Accent-coloured section bands.** A section is white, `--gw-bg-soft` or a night band; the accent only lights a
94
100
  night band's top edge.
95
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.
96
103
 
97
104
  These make a page louder without making it clearer, and `check` reports the last one (`uppercase-label`) and any
98
105
  button filled from site CSS (`accent-button`).
package/js/website.js CHANGED
@@ -1,11 +1,12 @@
1
- /*! @gemboss/website-ui 0.7.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.7.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 }; }
@@ -93,24 +94,136 @@ function mobile(ctx) {
93
94
  var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
94
95
  if (!menu) return;
95
96
  var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
97
+ var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
98
+ var own = groups.map(function (g) { return g.open; }), cur = null;
99
+ function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
96
100
  var r = {
97
101
  open: function () { return !menu.hidden; },
98
102
  set: function (o, focus) {
103
+ if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
99
104
  menu.hidden = !o;
100
105
  btn.setAttribute('aria-expanded', String(o));
101
106
  btn.setAttribute('aria-label', label[+o]);
102
107
  root.classList.toggle('is-menu-open', o);
108
+ if (!o) {
109
+ r.go(null);
110
+ records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
111
+ }
103
112
  if (focus) btn.focus();
113
+ emit(menu, 'gw:menu', { open: o, level: '' });
104
114
  },
105
- stop: function () { if (r.open()) r.set(false); }
115
+ go: function (g, focus) {
116
+ var from = cur;
117
+ if (g === cur) return;
118
+ cur = g;
119
+ groups.forEach(function (x) { x.open = x === g; });
120
+ menu.classList.toggle('is-drilled', !!g);
121
+ menu.classList.toggle('is-back', !g && r.open());
122
+ menu.scrollTop = 0;
123
+ if (focus) (g ? head(g) : from.querySelector('summary')).focus();
124
+ if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
125
+ },
126
+ trap: function (e) {
127
+ var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
128
+ var a = doc.activeElement, last = stops[stops.length - 1];
129
+ if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
130
+ },
131
+ stop: function () {
132
+ if (r.open()) r.set(false);
133
+ if (!groups.length) return;
134
+ menu.classList.remove('is-drill');
135
+ menu.style.removeProperty('--gw-menu-top');
136
+ groups.forEach(function (g, i) { g.open = own[i]; });
137
+ }
106
138
  };
107
139
  on(btn, 'click', function () { r.set(!r.open()); });
108
140
  on(menu, 'click', function (e) { if (link(e)) r.set(false); });
141
+ groups.forEach(function (g) {
142
+ var back = g.querySelector('.gw-mobile-menu__back');
143
+ on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
144
+ if (back) on(back, 'click', function () { r.go(null, true); });
145
+ g.open = false;
146
+ });
147
+ if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
109
148
  return r;
110
149
  });
111
150
  }
112
151
  function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
113
152
 
153
+ var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
154
+ function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
155
+ function speak(x) {
156
+ var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
157
+ try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
158
+ n = n || x.code;
159
+ return n.charAt(0).toUpperCase() + n.slice(1);
160
+ }
161
+ function alternates() {
162
+ var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
163
+ try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
164
+ all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
165
+ var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
166
+ try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
167
+ if (k === 'x-default' || seen[k]) return;
168
+ seen[k] = 1;
169
+ list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
170
+ });
171
+ if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
172
+ return list;
173
+ }
174
+ function lang(ctx) {
175
+ each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
176
+ var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
177
+ if (!list) return;
178
+ var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
179
+ function links() { return all('a[href]', list); }
180
+ var r = {
181
+ open: function () { return !list.hidden; },
182
+ set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
183
+ fill: function () {
184
+ var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
185
+ list.textContent = '';
186
+ alts.forEach(function (x) {
187
+ if (x === cur) return;
188
+ var li = doc.createElement('li'), a = doc.createElement('a');
189
+ a.className = 'gw-lang__link';
190
+ a.setAttribute('href', x.href);
191
+ a.setAttribute('hreflang', x.code);
192
+ a.setAttribute('lang', x.code);
193
+ a.textContent = speak(x);
194
+ li.appendChild(a);
195
+ list.appendChild(li);
196
+ });
197
+ box.hidden = !links().length;
198
+ if (box.hidden) r.set(false);
199
+ if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
200
+ btn.setAttribute('aria-label', word + ': ' + speak(cur));
201
+ list.setAttribute('aria-label', word);
202
+ },
203
+ stop: function () {
204
+ r.set(false);
205
+ list.textContent = '';
206
+ box.hidden = true;
207
+ if (short) { short.textContent = ''; short.removeAttribute('lang'); }
208
+ if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
209
+ }
210
+ };
211
+ on(btn, 'click', function () { r.set(!r.open()); });
212
+ on(box, 'keydown', function (e) {
213
+ if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
214
+ var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
215
+ var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
216
+ if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
217
+ e.preventDefault();
218
+ r.set(true);
219
+ l[k % n].focus();
220
+ });
221
+ on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
222
+ return r;
223
+ });
224
+ records('lang', function (r) { r.fill(); });
225
+ }
226
+
114
227
  function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
115
228
  function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
116
229
  function theme(ctx) {
@@ -265,6 +378,51 @@ function folds(ctx) {
265
378
  });
266
379
  }
267
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
+
268
426
  function install() {
269
427
  doc = document; root = doc.documentElement; mine = new WeakSet();
270
428
  fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
@@ -274,8 +432,14 @@ function install() {
274
432
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
275
433
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
276
434
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
435
+ on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
436
+ on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
277
437
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
278
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(); }); }); });
279
443
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
280
444
  try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
281
445
  if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
@@ -300,7 +464,7 @@ function init(ctx) {
300
464
  window.gwWebsite = api;
301
465
  if (!doc) run([install]);
302
466
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
303
- run([mega, mobile, 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);
304
468
  }
305
469
 
306
470
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */