@gemboss/website-ui 0.6.0 → 0.8.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). 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), 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
40
40
  preview) is wired by calling `init` on it, as often as needed: an element is wired once.
41
41
 
42
42
  ```js
@@ -51,7 +51,7 @@ import { init } from '@gemboss/website-ui/website.mjs';
51
51
  ```
52
52
 
53
53
  `destroy(root)` takes it back when a framework unmounts. The Next.js recipe, the `data-gw-ready` mark and the
54
- events (`gw:mega`, `gw:theme`, `gw:segmented`, `gw:toc`) are in `docs/markup-contract.md`, "Script".
54
+ events (`gw:mega`, `gw:theme`, `gw:segmented`, `gw:toc`, `gw:menu`) are in `docs/markup-contract.md`, "Script".
55
55
 
56
56
  Don't: wire a kit part with your own script (a click handler on a trigger, a class toggled by hand), or load two
57
57
  copies of the script and expect both to work (the first owns the page).
@@ -343,10 +343,67 @@ Don't: hide content a reader needs to decide (prices, terms) only inside a close
343
343
  | `.gw-mobile-menu__group` | a `<details>` group with a `<summary>` row |
344
344
  | `.gw-mobile-menu__list`, `__link` | the links inside a group, 44px rows |
345
345
  | `.gw-mobile-menu__top` | a top-level row without a group (Pricing) |
346
- | `.gw-mobile-menu__actions` | the buttons and Sign in at the end of the sheet |
346
+ | `.gw-mobile-menu__actions` | the buttons and Sign in at the end of the sheet; in the drill-down menu, a bar that stays at the bottom of the sheet |
347
+ | `.gw-mobile-menu[data-gw-menu="drill"]` | 0.8.0, optional: the drill-down menu (below) |
348
+ | `.gw-mobile-menu__head`, `__back`, `__title` | 0.8.0: a level's head, Back and the level's title (`tabindex="-1"`, the focus lands there); drawn only for the open level |
349
+ | `.gw-mobile-menu__heading`, `__icon`, `__all` | 0.8.0, optional in a level: a muted heading over a run of links; a 32px icon tile before a link's `<b>` name and `<i>` line; the last link, to the section's own page, in the accent with its arrow |
350
+ | `.gw-lang` (`data-gw-lang`, `hidden`), `__btn`, `__short`, `__menu`, `__link` | 0.8.0, optional: the language switcher (below): a ghost button with the globe and the current language's code, its list of the other languages |
347
351
 
348
352
  Header buttons: the secondary one shows from 768px, the primary one from 480px; below that they live in the mobile
349
- menu. Full markup: `templates/header.html`.
353
+ menu. Full markup: `templates/header.html`. See both 0.8.0 options in `demo/header.html`.
354
+
355
+ ### Drill-down mobile menu (0.8.0)
356
+
357
+ gemboss.ai's phone menu: the top level lists the sections in big rows; a tap on one shows its level alone, with Back
358
+ and its title; the actions stay in a bar at the bottom of the sheet while the list scrolls. Put `data-gw-menu="drill"`
359
+ on the `.gw-mobile-menu` and a head in each group; everything else is the 0.7.0 menu.
360
+
361
+ ```html
362
+ <details class="gw-mobile-menu__group">
363
+ <summary>Product</summary>
364
+ <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>
365
+ <p class="gw-mobile-menu__heading">Build</p>
366
+ <ul class="gw-mobile-menu__list">
367
+ <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>
368
+ </ul>
369
+ <a class="gw-mobile-menu__all" href="/product">All of Product</a>
370
+ </details>
371
+ ```
372
+
373
+ - The actions bar (`.gw-mobile-menu__actions`): the language switcher (optional) and Sign in on the first line (two
374
+ things at most: the first on the left, the last on the right), the two buttons as `gw-btn--lg gw-btn--block` under it.
375
+ Words in the page's language, Back included.
376
+ - The script moves the focus to the title as a level opens and back to the section on Back, closes the sheet on
377
+ Escape from anywhere, keeps Tab inside the open sheet and its toggle, and fires `gw:menu` (`{ open, level }`). A level
378
+ slides in (none under reduced motion). Without the script the groups open in place, as in 0.7.0.
379
+ - When to use it: a site whose sections hold more than a handful of links each (gemboss.ai: four sections, six to
380
+ twelve links). A site with a few links per section can keep the 0.7.0 menu.
381
+
382
+ Don't: a level without its head (`check`: `menu-markup`); Back in English on a translated page; a level that only
383
+ repeats the section's own page; your own script on the summaries.
384
+
385
+ ### Language switcher (0.8.0)
386
+
387
+ The header's language button, as gemboss.ai's: the globe and the current language's code; its list links to the same
388
+ page in the other languages, each named in its own language. It reads the page's `<link rel="alternate" hreflang>`, so
389
+ it shows only where the page has another language and stays hidden everywhere else.
390
+
391
+ ```html
392
+ <div class="gw-lang" data-gw-lang hidden>
393
+ <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>
394
+ <ul class="gw-lang__menu" id="gw-lang-menu" hidden></ul>
395
+ </div>
396
+ ```
397
+
398
+ - Two copies: one in `.gw-header__actions` (drawn from 1080px), one in the mobile menu's actions (its list opens
399
+ upward), each with its own list `id`. Keep `hidden` on both and the list empty: the script fills them.
400
+ - The head is the source: one `<link rel="alternate" hreflang="…" href="…">` per language the page has (its own
401
+ included), and `x-default` if you like (it is not listed). A `title` on a link names that language in the list.
402
+ - `data-gw-label-language` is the word for "Language" in the page's language ("Ngôn ngữ", "语言").
403
+
404
+ Don't: links typed into the list, a switcher shown by the markup, a translation's `<link rel="alternate">` without
405
+ `hreflang` (`check`: `lang-markup`); a flag for a language; a switcher that saves a choice the page's address does not
406
+ carry.
350
407
 
351
408
  Don't: a floating, rounded or blurred header of your own (the one floating header is `.gw-header--float`, chosen
352
409
  for the whole site); more than five top-level items; a second filled button in the bar; a
@@ -354,18 +411,62 @@ hero pulled up under the header.
354
411
 
355
412
  ## Footer
356
413
 
414
+ The footer of gemboss.ai (0.7.0): one per site, the same on every page, from `templates/footer.html`. A brand fills
415
+ its rows (its columns, its family, its words) and deletes the optional parts it does not use; it never adds a footer
416
+ part of its own. See it in `demo/footer.html`, with a switch to the same footer for a GemPages-style brand.
417
+
357
418
  | class | for |
358
419
  |---|---|
359
420
  | `.gw-footer` | the footer: one hairline on top, white like the page |
360
- | `.gw-footer__grid` | brand block and link columns: 2 columns on phones, 3 from 768px, one row from 1080px |
421
+ | `.gw-footer__grid` | brand block and link columns: 2 columns on phones, 3 from 768px, one row from 1080px. With folding columns (0.7.0): one track of rows below 768px, and from 1080px the brand's track as on gemboss.ai (193.5px beside 1120px), the columns sharing the rest: three to six |
361
422
  | `.gw-footer__brand` | the brand and one sentence, full width on phones |
362
423
  | `.gw-footer__col`, `__heading` | a `<nav>` column: a sentence-case heading and a `<ul>` of links |
424
+ | `.gw-footer__fold` | 0.7.0: the column's `<details>`, its `<summary>` holding the `__heading`. Below 768px a 52px row with a chevron, folded until tapped (44px links); from 768px the open column, the summary only its heading (no box, no Tab stop). Works without the script |
425
+ | `.gw-footer__ask`, `__ask-label` | 0.7.0, optional, in the brand block: "Ask AI about [Brand]" and links that open ChatGPT, Claude, Perplexity and Google AI Mode with one neutral question typed in |
426
+ | `.gw-footer__family` | 0.7.0, optional, after the grid: a `<nav>` row of products, a fold whose heading is its label, a list of `<a><b>Name</b> <i>One line</i></a>`; two a line on phones, four from 768px, from 1080px the label beside the items. Repeat it for a second family |
427
+ | `.gw-footer__ext` | 0.7.0: the drawn ↗ after a link that leaves the site, `aria-hidden="true"` |
363
428
  | `.gw-footer__bottom` | copyright, legal links, social links; alone (no grid above), it drops its top rule |
429
+ | `.gw-footer__toggle` | 0.7.0, optional: with `.gw-theme-toggle` and `data-gw-theme-toggle`, the theme toggle in the bottom row, a 32px square with one hairline (44px on touch), before the social links |
430
+ | `.gw-footer__wordmark` | 0.7.0, optional, last: the brand's mark (inline SVG in `currentColor`) and name, ink at 10%, `--gw-display-wordmark`; `aria-hidden="true"`; not on phones |
364
431
  | `.gw-footer__newsletter` | 0.6.0, optional: a `<form>` in the brand block, 24px under its sentence: a `.gw-field` with its label, a `.gw-form-inline` row (the email field and a `--secondary` button) and the note |
432
+ | `.gw-visually-hidden` | 0.7.0, anywhere: words for a screen reader only, " (opens in a new tab)" in a link with `target="_blank"` |
365
433
 
366
- Full markup: `templates/footer.html`. The newsletter note says how often you write and how to stop, and links to the
367
- privacy policy. Don't: uppercase column headings; a newsletter form that stores nothing; a consent box ticked for the
368
- reader; a filled primary button in the footer (the closing CTA above it is the page's action).
434
+ ```html
435
+ <nav class="gw-footer__col" aria-label="Product">
436
+ <details class="gw-footer__fold">
437
+ <summary><h2 class="gw-footer__heading">Product</h2></summary>
438
+ <ul><li><a href="/funnel">Funnel builder</a></li> …</ul>
439
+ </details>
440
+ </nav>
441
+ …
442
+ <nav class="gw-footer__family" aria-label="More from GemBoss">
443
+ <details class="gw-footer__fold">
444
+ <summary><h2 class="gw-footer__heading">More from GemBoss</h2></summary>
445
+ <ul>
446
+ <li><a href="/custom-theme"><b>GemThemes</b> <i>Custom theme</i></a></li>
447
+ <li><a href="https://community.gemboss.ai" rel="noopener"><b>Community<span class="gw-footer__ext" aria-hidden="true"></span></b> <i>Brands building with GemBoss</i></a></li>
448
+ </ul>
449
+ </details>
450
+ </nav>
451
+ ```
452
+
453
+ Ask AI: each link carries the same question, URL-encoded after `?q=`: `https://chatgpt.com/?q=…`,
454
+ `https://claude.ai/new?q=…`, `https://www.perplexity.ai/search/new?q=…`, `https://www.google.com/search?udm=50&q=…`.
455
+ gemboss.ai asks "What is GemBoss (gemboss.ai), and how does it help Shopify brands turn ad clicks into sales?": what the
456
+ brand is and what it does for the reader, never a leading question, so the answer comes from what the web says.
457
+ Words, not the assistants' logos (another company's mark on a page is a permission question).
458
+
459
+ Links that leave the site carry the ↗ (`.gw-footer__ext`); a link with `target="_blank"` also says so to a screen
460
+ reader: `<span class="gw-visually-hidden"> (opens in a new tab)</span>` inside the `<a>`. A link without
461
+ `target="_blank"` does not.
462
+
463
+ A GemCommerce brand lists its siblings in its family row: a GemPages site might show GemBoss, GemX and GemUpsells.
464
+
465
+ The newsletter note says how often you write and how to stop, and links to the privacy policy. Don't: uppercase
466
+ column headings; a footer part that is not in the template (a second logo row, a badge strip, a map); a column
467
+ without its heading in the `<summary>`; a wordmark a screen reader reads (`check`: `footer-markup`); the assistants'
468
+ logos in the Ask AI row; a newsletter form that stores nothing; a consent box ticked for the reader; a filled primary
469
+ button in the footer (the closing CTA above it is the page's action).
369
470
 
370
471
  ## Sections
371
472
 
@@ -732,6 +833,22 @@ not make public with the review; masonry or a carousel that moves by itself.
732
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
733
834
  the first screen, and don't animate anything but `transform` and `opacity`.
734
835
 
836
+ ## Additions to the markup contract in 0.8.0
837
+
838
+ The drill-down mobile menu: `data-gw-menu="drill"` on `.gw-mobile-menu`, `.gw-mobile-menu__head` (`__back`, `__title`),
839
+ `.gw-mobile-menu__heading`, `__icon`, `__all`, the script's `.is-drill`, `.is-drilled` and `.is-back` on the sheet and
840
+ its `--gw-menu-top`. The language switcher: `.gw-lang` (`data-gw-lang`, `__btn`, `__short`, `__menu`, `__link`) and the
841
+ script's `lang` part. One event, `gw:menu` (`{ open, level }`), on every mobile menu. No markup or class of 0.7.0
842
+ changes: the 0.7.0 header still works and looks the same.
843
+
844
+ ## Additions to the markup contract in 0.7.0
845
+
846
+ The footer of gemboss.ai: `.gw-footer__fold` (a `<details>` in a `.gw-footer__col` or a `.gw-footer__family`, its
847
+ `<summary>` holding the `.gw-footer__heading`) with the script's `fold` part (`data-gw-ready="fold"`), `.gw-footer__ask`
848
+ (`__ask-label`), `.gw-footer__family`, `.gw-footer__ext`, `.gw-footer__toggle` (on a `.gw-theme-toggle`),
849
+ `.gw-footer__wordmark`, and `.gw-visually-hidden`. One token: `--gw-display-wordmark`. No event. No markup or class of
850
+ 0.6.0 changes: the 0.6.0 footer markup still works and looks the same.
851
+
735
852
  ## Additions to the markup contract in 0.6.0
736
853
 
737
854
  Forms: `.gw-field` (`__label`, `__error`, `__hint`), `.gw-input`, `.gw-select`, `.gw-textarea`, `.gw-choices`, `.gw-check`
@@ -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). It comes in two entries built from one source
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
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). It comes in two entries built from one source
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.6.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`). It is a mark for people and tests, not a switch: markup copied from a live page, attribute and all,
47
+ - **`version`**: `'0.8.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`). 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,153 @@ 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`).
259
+
260
+ ## Footer (0.7.0)
261
+
262
+ ```html
263
+ <footer class="gw-footer">
264
+ <div class="gw-wrap">
265
+ <div class="gw-footer__grid">
266
+ <div class="gw-footer__brand">
267
+ <a class="gw-brand" href="/">…</a><p>One sentence.</p>
268
+ <div class="gw-footer__ask"> <!-- optional -->
269
+ <p class="gw-footer__ask-label" id="footer-ask">Ask AI about Brand</p>
270
+ <ul aria-labelledby="footer-ask"><li><a href="https://chatgpt.com/?q=…" target="_blank" rel="noopener">ChatGPT<span class="gw-visually-hidden"> (opens in a new tab)</span></a></li> …</ul>
271
+ </div>
272
+ <form class="gw-footer__newsletter" …>…</form> <!-- optional (0.6.0) -->
273
+ </div>
274
+ <nav class="gw-footer__col" aria-label="Product"> <!-- three to six -->
275
+ <details class="gw-footer__fold"> <!-- no open: folded on phones -->
276
+ <summary><h2 class="gw-footer__heading">Product</h2></summary>
277
+ <ul><li><a href="…">…</a></li> …</ul>
278
+ </details>
279
+ </nav>
280
+ </div>
281
+ <nav class="gw-footer__family" aria-label="More from Brand"> <!-- optional, repeatable -->
282
+ <details class="gw-footer__fold">
283
+ <summary><h2 class="gw-footer__heading">More from Brand</h2></summary>
284
+ <ul><li><a href="…"><b>Name<span class="gw-footer__ext" aria-hidden="true"></span>?</b> <i>One line</i></a></li> …</ul>
285
+ </details>
286
+ </nav>
287
+ <div class="gw-footer__bottom">
288
+ <span>© …</span><ul aria-label="Legal">…</ul>
289
+ <button class="gw-theme-toggle gw-footer__toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme">svg</button> <!-- optional -->
290
+ <ul aria-label="Social">…</ul>
291
+ </div>
292
+ <div class="gw-footer__wordmark" aria-hidden="true"><svg viewBox="…">…currentColor…</svg><span>Brand</span></div> <!-- optional -->
293
+ </div>
294
+ </footer>
295
+ ```
296
+
297
+ - **Folds** (`.gw-footer__fold`, a `<details>` whose `<summary>` holds the `.gw-footer__heading`): the CSS folds them
298
+ below 768px (52px rows, the browser opens and closes them) and draws the open column from 768px, where the summary
299
+ is `display: contents` and the list shows even while the `<details>` is closed (`::details-content`). The script,
300
+ for each fold with a summary:
301
+ - from 768px (`matchMedia('(min-width: 768px)')`) sets `open`, puts `tabindex="-1"` on the summary and cancels a
302
+ click on it (`preventDefault`), so a heading never folds its column away, in a browser without
303
+ `::details-content` too;
304
+ - below 768px gives back the markup's state: `open` as the markup had it (folded unless it said `open`), the
305
+ summary's own `tabindex` (none, usually); again on every change of width;
306
+ - `destroy`: the markup's state, its listener and the `fold` mark go. No event.
307
+ - **Theme toggle**: a `[data-gw-theme-toggle]` in the bottom row is wired like the header's (above); with both on the
308
+ page, a press on either relabels both.
309
+ - **Not the script's**: the Ask AI row, the family rows, the ↗ and the wordmark are markup and CSS only. The question
310
+ in the Ask AI links is written in the markup, URL-encoded.
311
+ - `check` reports a `.gw-footer__fold` that is not a `<details>`, has no `<summary>`, or whose summary does not hold
312
+ the `.gw-footer__heading`, and a `.gw-footer__wordmark` without `aria-hidden="true"` (`footer-markup`).
162
313
 
163
314
  ## Article: contents and prose tables (0.5.0)
164
315
 
@@ -329,6 +480,7 @@ CSS only: the script does not touch a form. The page (or its server) owns valida
329
480
  | `.gw-table` (+ `--compare`) | data / comparison table; scrolls inside its box on phones |
330
481
  | `.gw-faq` (`<details class="gw-faq__item"><summary>`) | FAQ accordion with no script |
331
482
  | `.gw-prose` (+ `--sm`) | long-form reading: an article's own HTML, no classes inside; its bare tables get a `.gw-prose__table` box from the script (0.5.0) |
483
+ | `.gw-visually-hidden` | words for a screen reader only, " (opens in a new tab)" in a link with `target="_blank"` (0.7.0) |
332
484
  | `.gw-tabs` (`role="tablist"`) | NOT in the kit yet (a reported gap would add it) |
333
485
 
334
486
  ## Sections
@@ -349,4 +501,4 @@ CSS only: the script does not touch a form. The page (or its server) owns valida
349
501
  | `.gw-testimonials` (`.gw-card.gw-testimonial`: `__quote`, `__by`, `__avatar`) | a wall of short reviews, each linked to its source (0.6.0) |
350
502
  | `.gw-pricing` (`.gw-plan`, `--featured`) | plan cards; the featured one has a hairline in the accent, not a fill |
351
503
  | `.gw-cta` | closing call to action band |
352
- | `.gw-footer` (`__grid`, `__col`, `__heading`, `__bottom`, `__newsletter`) | footer columns + bottom row; an optional newsletter form in the brand block (0.6.0) |
504
+ | `.gw-footer` (`__grid`, `__col`, `__heading`, `__bottom`, `__newsletter`, and 0.7.0: `__fold`, `__ask`, `__ask-label`, `__family`, `__ext`, `__toggle`, `__wordmark`) | footer columns + bottom row; an optional newsletter form in the brand block (0.6.0); the footer of gemboss.ai: columns that fold on phones, Ask AI, family rows, the toggle, the wordmark (0.7.0, above) |
package/docs/theming.md CHANGED
@@ -14,8 +14,10 @@ every component follows. Everything else stays the kit's: the neutrals, the type
14
14
  widths. That is what makes the sites look like one family, and why a site built by someone with no design training
15
15
  still looks careful. `npx gemboss-website check` reports anything outside this set.
16
16
 
17
- Working examples: `demo/theme-example.html` (the accent only) and `demo/brand-profiles.html` (a GemPages-style brand:
18
- the accent, all three profiles and the floating header).
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), `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).
19
21
 
20
22
  ## 1 to 3. Accent, wash, logo
21
23
 
@@ -43,6 +45,11 @@ The logo and the favicon are files, not variables: the `<img class="gw-brand__ma
43
45
  `alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
44
46
  partials, and `<link rel="icon">` in the page head.
45
47
 
48
+ The footer (0.7.0, `templates/footer.html`) is the same for every brand; a brand fills it. Its family row lists the
49
+ brand's siblings (a GemPages site: GemBoss, GemX, GemUpsells), its Ask AI row asks about the brand, and its wordmark
50
+ is the brand's own mark as an inline SVG drawn in `currentColor` (so it takes the faint colour) and its name. The
51
+ radius profile reaches the family items and the toggle (8px, 12px in `soft`); nothing else of the footer changes.
52
+
46
53
  ## 4 to 6. The three profiles
47
54
 
48
55
  Chosen once, in the page skeleton (the layout or partial every page shares). The first of each is the default and
package/js/website.js CHANGED
@@ -1,11 +1,11 @@
1
- /*! @gemboss/website-ui 0.6.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.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 */
2
2
  (function () {
3
3
  'use strict';
4
4
  if (window.gwWebsite) return;
5
5
 
6
- var version = '0.6.0';
6
+ var version = '0.8.0';
7
7
  var api = { version: version, init: init, destroy: destroy };
8
- var doc, root, mine, fine, sys, wide, live = [], megaOpen = null, megaTimer = 0, queued = false;
8
+ var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
9
9
  var READY = 'data-gw-ready';
10
10
 
11
11
  function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
@@ -93,24 +93,136 @@ function mobile(ctx) {
93
93
  var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
94
94
  if (!menu) return;
95
95
  var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
96
+ var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
97
+ var own = groups.map(function (g) { return g.open; }), cur = null;
98
+ function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
96
99
  var r = {
97
100
  open: function () { return !menu.hidden; },
98
101
  set: function (o, focus) {
102
+ if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
99
103
  menu.hidden = !o;
100
104
  btn.setAttribute('aria-expanded', String(o));
101
105
  btn.setAttribute('aria-label', label[+o]);
102
106
  root.classList.toggle('is-menu-open', o);
107
+ if (!o) {
108
+ r.go(null);
109
+ records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
110
+ }
103
111
  if (focus) btn.focus();
112
+ emit(menu, 'gw:menu', { open: o, level: '' });
104
113
  },
105
- stop: function () { if (r.open()) r.set(false); }
114
+ go: function (g, focus) {
115
+ var from = cur;
116
+ if (g === cur) return;
117
+ cur = g;
118
+ groups.forEach(function (x) { x.open = x === g; });
119
+ menu.classList.toggle('is-drilled', !!g);
120
+ menu.classList.toggle('is-back', !g && r.open());
121
+ menu.scrollTop = 0;
122
+ if (focus) (g ? head(g) : from.querySelector('summary')).focus();
123
+ if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
124
+ },
125
+ trap: function (e) {
126
+ var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
127
+ var a = doc.activeElement, last = stops[stops.length - 1];
128
+ if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
129
+ },
130
+ stop: function () {
131
+ if (r.open()) r.set(false);
132
+ if (!groups.length) return;
133
+ menu.classList.remove('is-drill');
134
+ menu.style.removeProperty('--gw-menu-top');
135
+ groups.forEach(function (g, i) { g.open = own[i]; });
136
+ }
106
137
  };
107
138
  on(btn, 'click', function () { r.set(!r.open()); });
108
139
  on(menu, 'click', function (e) { if (link(e)) r.set(false); });
140
+ groups.forEach(function (g) {
141
+ var back = g.querySelector('.gw-mobile-menu__back');
142
+ on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
143
+ if (back) on(back, 'click', function () { r.go(null, true); });
144
+ g.open = false;
145
+ });
146
+ if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
109
147
  return r;
110
148
  });
111
149
  }
112
150
  function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
113
151
 
152
+ var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
153
+ function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
154
+ function speak(x) {
155
+ var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
156
+ try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
157
+ n = n || x.code;
158
+ return n.charAt(0).toUpperCase() + n.slice(1);
159
+ }
160
+ function alternates() {
161
+ var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
162
+ try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
163
+ all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
164
+ var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
165
+ try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
166
+ if (k === 'x-default' || seen[k]) return;
167
+ seen[k] = 1;
168
+ list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
169
+ });
170
+ if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
171
+ return list;
172
+ }
173
+ function lang(ctx) {
174
+ each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
175
+ var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
176
+ if (!list) return;
177
+ var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
178
+ function links() { return all('a[href]', list); }
179
+ var r = {
180
+ open: function () { return !list.hidden; },
181
+ set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
182
+ fill: function () {
183
+ var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
184
+ list.textContent = '';
185
+ alts.forEach(function (x) {
186
+ if (x === cur) return;
187
+ var li = doc.createElement('li'), a = doc.createElement('a');
188
+ a.className = 'gw-lang__link';
189
+ a.setAttribute('href', x.href);
190
+ a.setAttribute('hreflang', x.code);
191
+ a.setAttribute('lang', x.code);
192
+ a.textContent = speak(x);
193
+ li.appendChild(a);
194
+ list.appendChild(li);
195
+ });
196
+ box.hidden = !links().length;
197
+ if (box.hidden) r.set(false);
198
+ if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
199
+ btn.setAttribute('aria-label', word + ': ' + speak(cur));
200
+ list.setAttribute('aria-label', word);
201
+ },
202
+ stop: function () {
203
+ r.set(false);
204
+ list.textContent = '';
205
+ box.hidden = true;
206
+ if (short) { short.textContent = ''; short.removeAttribute('lang'); }
207
+ if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
208
+ }
209
+ };
210
+ on(btn, 'click', function () { r.set(!r.open()); });
211
+ on(box, 'keydown', function (e) {
212
+ if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
213
+ var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
214
+ var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
215
+ if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
216
+ e.preventDefault();
217
+ r.set(true);
218
+ l[k % n].focus();
219
+ });
220
+ on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
221
+ return r;
222
+ });
223
+ records('lang', function (r) { r.fill(); });
224
+ }
225
+
114
226
  function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
115
227
  function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
116
228
  function theme(ctx) {
@@ -250,15 +362,34 @@ function tables(ctx) {
250
362
  });
251
363
  }
252
364
 
365
+ function folds(ctx) {
366
+ each(ctx, '.gw-footer__fold', 'fold', function (d, on) {
367
+ var s = d.querySelector('summary'), own = d.open, tab = s && s.getAttribute('tabindex');
368
+ if (!s) return;
369
+ function tabs(v) { if (v === null) s.removeAttribute('tabindex'); else s.setAttribute('tabindex', v); }
370
+ var r = {
371
+ fit: function () { d.open = mid.matches || own; tabs(mid.matches ? '-1' : tab); },
372
+ stop: function () { d.open = own; tabs(tab); }
373
+ };
374
+ on(s, 'click', function (e) { if (mid.matches) e.preventDefault(); });
375
+ r.fit();
376
+ return r;
377
+ });
378
+ }
379
+
253
380
  function install() {
254
381
  doc = document; root = doc.documentElement; mine = new WeakSet();
255
382
  fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
256
383
  var stored = null;
257
384
  wide = mq('(min-width: 1080px)');
385
+ mid = mq('(min-width: 768px)');
258
386
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
259
387
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
260
388
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
389
+ on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
390
+ on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
261
391
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
392
+ watch(mid, function () { records('fold', function (r) { r.fit(); }); });
262
393
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
263
394
  try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
264
395
  if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
@@ -283,7 +414,7 @@ function init(ctx) {
283
414
  window.gwWebsite = api;
284
415
  if (!doc) run([install]);
285
416
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
286
- run([mega, mobile, theme, reveal, header, current, segmented, toc, tables], ctx && ctx.querySelectorAll ? ctx : doc);
417
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
287
418
  }
288
419
 
289
420
  /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */