@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.
package/js/website.mjs CHANGED
@@ -6,10 +6,10 @@
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.6.0';
9
+ var version = '0.8.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
- var doc, root, mine, fine, sys, wide, live = [], megaOpen = null, megaTimer = 0, queued = false;
12
+ var doc, root, mine, fine, sys, wide, mid, live = [], megaOpen = null, megaTimer = 0, queued = false;
13
13
  var READY = 'data-gw-ready';
14
14
 
15
15
  function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
@@ -101,30 +101,168 @@ function mega(ctx) {
101
101
  }
102
102
 
103
103
  // Mobile menu. Labels: data-gw-label-open/-close, else the markup's aria-label, else English. A link inside closes it
104
- // (same-page anchors do not reload). Taken back while open, it closes first: it must not keep the page locked.
104
+ // (same-page anchors do not reload). Taken back while open, it closes first: it must not keep the page locked. Opening
105
+ // and closing fire gw:menu on the menu ({ open, level: '' }); closing also closes a language list inside it.
106
+ //
107
+ // Drill-down (0.8.0, gap #41): a menu with data-gw-menu="drill". Each group in it (a <details class="gw-mobile-menu__group">
108
+ // with its <summary> and a .gw-mobile-menu__title) is a level. A press on the summary shows that level alone
109
+ // (.is-drilled on the menu, the group open, the menu scrolled to its top) and puts the focus on the level's title; its
110
+ // .gw-mobile-menu__back shows the top level again (.is-back, for the CSS's slide) and puts the focus back on the summary.
111
+ // Each move fires gw:menu ({ open: true, level }: the title's words, '' for the top level); closing the menu goes back
112
+ // to the top level without a word of its own. While it is open, Tab and Shift+Tab go round the toggle and the menu (the
113
+ // focus trap), and the menu starts where the header ends (--gw-menu-top, measured as it opens), so an announcement bar
114
+ // above the header stays where it is. .is-drill on the menu says it is wired: without the script (or before init) the
115
+ // groups are the 0.7.0 <details> that open in place. destroy gives back the groups' own open state.
105
116
  function mobile(ctx) {
106
117
  each(ctx, '[data-gw-menu-toggle]', 'menu', function (btn, on) {
107
118
  var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
108
119
  if (!menu) return;
109
120
  var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
121
+ var groups = attr(menu, 'data-gw-menu', '') !== 'drill' ? [] : all('.gw-mobile-menu__group', menu).filter(function (g) { return g.querySelector('summary') && head(g); });
122
+ var own = groups.map(function (g) { return g.open; }), cur = null;
123
+ function head(g) { return g.querySelector('.gw-mobile-menu__title'); }
110
124
  var r = {
111
125
  open: function () { return !menu.hidden; },
112
126
  set: function (o, focus) {
127
+ if (o && groups.length) menu.style.setProperty('--gw-menu-top', Math.round((btn.closest('[data-gw-header]') || menu.parentNode).getBoundingClientRect().bottom) + 'px');
113
128
  menu.hidden = !o;
114
129
  btn.setAttribute('aria-expanded', String(o));
115
130
  btn.setAttribute('aria-label', label[+o]);
116
131
  root.classList.toggle('is-menu-open', o);
132
+ if (!o) {
133
+ r.go(null);
134
+ records('lang', function (l) { if (menu.contains(l.el)) l.set(false); });
135
+ }
117
136
  if (focus) btn.focus();
137
+ emit(menu, 'gw:menu', { open: o, level: '' });
138
+ },
139
+ // the level in view: a group, or null for the top level
140
+ go: function (g, focus) {
141
+ var from = cur;
142
+ if (g === cur) return;
143
+ cur = g;
144
+ groups.forEach(function (x) { x.open = x === g; });
145
+ menu.classList.toggle('is-drilled', !!g);
146
+ menu.classList.toggle('is-back', !g && r.open());
147
+ menu.scrollTop = 0;
148
+ if (focus) (g ? head(g) : from.querySelector('summary')).focus();
149
+ if (r.open()) emit(menu, 'gw:menu', { open: true, level: g ? words(head(g)) : '' });
150
+ },
151
+ // Tab on the last stop goes to the toggle, Shift+Tab on the toggle to the last stop; focus found outside comes in
152
+ trap: function (e) {
153
+ var stops = [btn].concat(all('a[href], button, summary, input, select, textarea, [tabindex="0"]', menu).filter(function (el) { return !el.disabled && el.getClientRects().length; }));
154
+ var a = doc.activeElement, last = stops[stops.length - 1];
155
+ if ((a !== btn && !menu.contains(a)) || a === (e.shiftKey ? btn : last)) { e.preventDefault(); (e.shiftKey ? last : btn).focus(); }
118
156
  },
119
- stop: function () { if (r.open()) r.set(false); }
157
+ stop: function () {
158
+ if (r.open()) r.set(false);
159
+ if (!groups.length) return;
160
+ menu.classList.remove('is-drill');
161
+ menu.style.removeProperty('--gw-menu-top');
162
+ groups.forEach(function (g, i) { g.open = own[i]; });
163
+ }
120
164
  };
121
165
  on(btn, 'click', function () { r.set(!r.open()); });
122
166
  on(menu, 'click', function (e) { if (link(e)) r.set(false); });
167
+ groups.forEach(function (g) {
168
+ var back = g.querySelector('.gw-mobile-menu__back');
169
+ on(g.querySelector('summary'), 'click', function (e) { e.preventDefault(); r.go(g, true); });
170
+ if (back) on(back, 'click', function () { r.go(null, true); });
171
+ g.open = false;
172
+ });
173
+ if (groups.length) menu.classList.add('is-drill'); else r.trap = null;
123
174
  return r;
124
175
  });
125
176
  }
126
177
  function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
127
178
 
179
+ // Language switcher (0.8.0, gap #29): a .gw-lang with data-gw-lang, its button .gw-lang__btn and the list its
180
+ // aria-controls names. The languages are the page's own <link rel="alternate" hreflang href> (not x-default): the links
181
+ // search engines read, so the switcher offers exactly the translations they are told about. The one whose hreflang is
182
+ // <html lang> (else the first with the same base language: zh for zh-Hans) is the current one: the button shows its
183
+ // short code. Each other one is a link in the list (href, hreflang, lang), named in its own language: the <link>'s
184
+ // title, else the kit's name (en, vi, zh), else the browser's (Intl.DisplayNames), else its code. A link to the host of
185
+ // <link rel="canonical"> (else to this host) keeps only its path, so a preview deployment stays on itself. With no other
186
+ // language the switcher keeps `hidden`. Every init reads the links again: a route change brings the new page's.
187
+ // The button opens and closes the list. ArrowDown / ArrowUp on it open the list on its first / last link; in the list
188
+ // they move, wrapping, and Home / End go to the ends. Escape closes it and gives the focus back to the button (and goes
189
+ // no further: an open mobile menu around it stays open); the focus leaving it, or a click outside, closes it.
190
+ // Labels: data-gw-label-language on the button, else its aria-label, else "Language": the button is named
191
+ // "<label>: <the current language>", the list "<label>". destroy empties the list and hides the switcher again.
192
+ var LANGS = { en: ['English', 'EN'], vi: ['Ti\u1ebfng Vi\u1ec7t', 'VI'], zh: ['\u4e2d\u6587', '\u4e2d'], 'zh-hans': ['\u4e2d\u6587', '\u4e2d'] };
193
+ function base(code) { return code.toLowerCase().split(/[-_]/)[0]; }
194
+ function speak(x) {
195
+ var k = LANGS[x.code.toLowerCase()], n = x.title || (k && k[0]);
196
+ try { n = n || new Intl.DisplayNames([x.code], { type: 'language' }).of(x.code); } catch (e) { /* an old browser, a bad code */ }
197
+ n = n || x.code;
198
+ return n.charAt(0).toUpperCase() + n.slice(1);
199
+ }
200
+ function alternates() {
201
+ var here = attr(root, 'lang', '').toLowerCase(), c = doc.querySelector('link[rel="canonical"][href]'), host = location.host, seen = {}, list = [];
202
+ try { if (c) host = new URL(attr(c, 'href', ''), location.href).host; } catch (e) { /* this host */ }
203
+ all('link[rel="alternate"][hreflang][href]').forEach(function (l) {
204
+ var code = attr(l, 'hreflang', ''), k = code.toLowerCase(), u;
205
+ try { u = new URL(attr(l, 'href', ''), location.href); } catch (e) { return; }
206
+ if (k === 'x-default' || seen[k]) return;
207
+ seen[k] = 1;
208
+ list.push({ code: code, title: attr(l, 'title', ''), here: k === here, href: u.host === host ? u.pathname + u.search + u.hash : u.href });
209
+ });
210
+ if (!list.some(function (x) { return x.here; })) list.some(function (x) { return (x.here = base(x.code) === base(here)); });
211
+ return list;
212
+ }
213
+ function lang(ctx) {
214
+ each(ctx, '[data-gw-lang]', 'lang', function (box, on) {
215
+ var btn = box.querySelector('.gw-lang__btn'), list = btn && doc.getElementById(attr(btn, 'aria-controls', ''));
216
+ if (!list) return;
217
+ var short = box.querySelector('.gw-lang__short'), name = attr(btn, 'aria-label', null), word = attr(btn, 'data-gw-label-language', name || 'Language');
218
+ function links() { return all('a[href]', list); }
219
+ var r = {
220
+ open: function () { return !list.hidden; },
221
+ set: function (o, focus) { list.hidden = !o; btn.setAttribute('aria-expanded', String(o)); if (focus) btn.focus(); },
222
+ fill: function () {
223
+ var alts = alternates(), cur = alts.filter(function (x) { return x.here; })[0] || { code: attr(root, 'lang', '') }, k = LANGS[cur.code.toLowerCase()];
224
+ list.textContent = '';
225
+ alts.forEach(function (x) {
226
+ if (x === cur) return;
227
+ var li = doc.createElement('li'), a = doc.createElement('a');
228
+ a.className = 'gw-lang__link';
229
+ a.setAttribute('href', x.href);
230
+ a.setAttribute('hreflang', x.code);
231
+ a.setAttribute('lang', x.code);
232
+ a.textContent = speak(x);
233
+ li.appendChild(a);
234
+ list.appendChild(li);
235
+ });
236
+ box.hidden = !links().length;
237
+ if (box.hidden) r.set(false);
238
+ if (short) { short.textContent = k ? k[1] : base(cur.code).toUpperCase(); short.setAttribute('lang', cur.code); }
239
+ btn.setAttribute('aria-label', word + ': ' + speak(cur));
240
+ list.setAttribute('aria-label', word);
241
+ },
242
+ stop: function () {
243
+ r.set(false);
244
+ list.textContent = '';
245
+ box.hidden = true;
246
+ if (short) { short.textContent = ''; short.removeAttribute('lang'); }
247
+ if (name === null) btn.removeAttribute('aria-label'); else btn.setAttribute('aria-label', name);
248
+ }
249
+ };
250
+ on(btn, 'click', function () { r.set(!r.open()); });
251
+ on(box, 'keydown', function (e) {
252
+ if (esc(e)) { if (r.open()) { e.stopPropagation(); r.set(false, true); } return; }
253
+ var l = links(), n = l.length, i = l.indexOf(doc.activeElement);
254
+ var k = { ArrowDown: i + 1, ArrowUp: (i < 0 ? 0 : i) - 1 + n, Home: 0, End: n - 1 }[e.key];
255
+ if (k === undefined || !n || (i < 0 && (e.target !== btn || /^(Home|End)$/.test(e.key))) || e.altKey || e.ctrlKey || e.metaKey) return;
256
+ e.preventDefault();
257
+ r.set(true);
258
+ l[k % n].focus();
259
+ });
260
+ on(box, 'focusout', function (e) { if (e.relatedTarget && !box.contains(e.relatedTarget)) r.set(false); });
261
+ return r;
262
+ });
263
+ records('lang', function (r) { r.fill(); });
264
+ }
265
+
128
266
  // Theme toggle. Labels: data-gw-label-dark/-light, else the markup's aria-label, else English. A press fires gw:theme
129
267
  // on <html>; so does a change of the system's scheme while the page follows it.
130
268
  function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
@@ -286,6 +424,27 @@ function tables(ctx) {
286
424
  });
287
425
  }
288
426
 
427
+ // Footer folds (0.7.0, gap #40): each <details class="gw-footer__fold"> (a column, a family row). Below 768px it keeps
428
+ // the markup's own state (folded unless the markup says open) and the browser opens and closes it. From 768px it is a
429
+ // plain column: the script sets `open`, takes the <summary> out of the Tab order (tabindex="-1") and ignores a click on
430
+ // it, so the heading cannot fold the list away. Again on every change of width; destroy gives back the markup's state.
431
+ // The CSS shows the list from 768px without the script too (::details-content), so this is for state and for browsers
432
+ // without that.
433
+ function folds(ctx) {
434
+ each(ctx, '.gw-footer__fold', 'fold', function (d, on) {
435
+ var s = d.querySelector('summary'), own = d.open, tab = s && s.getAttribute('tabindex');
436
+ if (!s) return;
437
+ function tabs(v) { if (v === null) s.removeAttribute('tabindex'); else s.setAttribute('tabindex', v); }
438
+ var r = {
439
+ fit: function () { d.open = mid.matches || own; tabs(mid.matches ? '-1' : tab); },
440
+ stop: function () { d.open = own; tabs(tab); }
441
+ };
442
+ on(s, 'click', function (e) { if (mid.matches) e.preventDefault(); });
443
+ r.fit();
444
+ return r;
445
+ });
446
+ }
447
+
289
448
  // Once per page: what belongs to the document, the window or a media query rather than to one element.
290
449
  function install() {
291
450
  doc = document; root = doc.documentElement; mine = new WeakSet();
@@ -293,10 +452,16 @@ function install() {
293
452
  var stored = null;
294
453
  // 1080px, the kit's desktop width: the header shows its nav, the contents sit beside the article
295
454
  wide = mq('(min-width: 1080px)');
455
+ // 768px: the footer's columns stand open from here, and fold below it
456
+ mid = mq('(min-width: 768px)');
296
457
  on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
297
458
  on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
298
459
  on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
460
+ // a drill-down menu that is open keeps the focus (its trap); a click outside a language list closes it
461
+ on(doc, 'keydown', function (e) { if (e.key === 'Tab') records('menu', function (r) { if (r.trap && r.open()) r.trap(e); }); });
462
+ on(doc, 'click', function (e) { records('lang', function (r) { if (r.open() && !r.el.contains(e.target)) r.set(false); }); });
299
463
  watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
464
+ watch(mid, function () { records('fold', function (r) { r.fit(); }); });
300
465
  on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
301
466
  try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
302
467
  if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
@@ -327,7 +492,7 @@ function init(ctx) {
327
492
  if (!doc) run([install]);
328
493
  // an element removed from the page since the last init is let go
329
494
  live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
330
- run([mega, mobile, theme, reveal, header, current, segmented, toc, tables], ctx && ctx.querySelectorAll ? ctx : doc);
495
+ run([mega, mobile, lang, theme, reveal, header, current, segmented, toc, tables, folds], ctx && ctx.querySelectorAll ? ctx : doc);
331
496
  }
332
497
 
333
498
  /** 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.6.0",
3
+ "version": "0.8.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
@@ -25,6 +25,13 @@ the kit and its rules are how the page ends up looking designed. Compose from th
25
25
  words under it with `aria-invalid="true"`; the page decides when, the kit draws it.
26
26
  - Ratings and testimonials (`KIT/templates/sections/testimonials.html`): only real ones, each linked to the page it
27
27
  comes from, the reviewer's words exactly; stars carry their value in words (`aria-label="4.9 out of 5"`).
28
+ - The footer is `KIT/templates/footer.html` (the footer of gemboss.ai), filled once in the site's shared partial:
29
+ fill its rows (columns, family rows, words), delete the optional parts you do not use (Ask AI, newsletter, family
30
+ rows, theme toggle, wordmark), never invent a footer part. Columns fold on phones by themselves (`<details>`).
31
+ - The header is `KIT/templates/header.html`, filled once in the site's shared partial. A site with deep sections
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
+ more than one language keeps its language switcher (`.gw-lang`, `hidden`, empty) and gives every page its
34
+ `<link rel="alternate" hreflang>`: the switcher lists those, and stays hidden on a page in one language.
28
35
  - Changed page: keep its kit classes; change words, order and choice of sections.
29
36
  - Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
30
37
  scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.
package/system.json CHANGED
@@ -95,6 +95,8 @@
95
95
  "display": {
96
96
  "h1": "clamp(40px, 4.6vw, 68px)",
97
97
  "h2": "clamp(32px, 3.4vw, 52px)",
98
+ "wordmark": "clamp(40px, 9vw, 128px)",
99
+ "$comment": "wordmark (0.7.0): the footer's last line on gemboss.ai, the brand's name, faint, at the end of the page. Decoration only, never a heading.",
98
100
  "weight": 600,
99
101
  "tracking": "-0.03em"
100
102
  },
@@ -1,11 +1,18 @@
1
1
  <!--
2
- Footer: one per site, the same on every page. A brand block, three to five link columns, a bottom row.
3
- Limits: column headings are one or two words in sentence case; four to seven links per column; the bottom row
4
- holds the copyright, the legal links and the social links, nothing else. Replace every [bracketed] placeholder.
5
- The newsletter form in the brand block is optional (0.6.0): keep it only when it is wired to a list that keeps the
6
- address and sends what the note promises; otherwise delete the <form>. The note says how often and how to stop, and
7
- links to the privacy policy. No box ticked for the reader, no "No thanks, I hate saving money". On send: the page
8
- sets aria-invalid="true" and the error's words, or aria-busy on the button and then a role="status" line.
2
+ Footer: one per site, the same on every page, built from this template (AGENTS.md: brands fill its rows, they never
3
+ invent a footer part). It is the footer of gemboss.ai (0.7.0). In order:
4
+ 1. the brand block: logo, one sentence; OPTIONAL the Ask AI row; OPTIONAL the newsletter form (0.6.0)
5
+ 2. three to six link columns, each a <details class="gw-footer__fold">: folded rows on phones (below 768px), open
6
+ columns from 768px. Headings one or two words in sentence case, four to seven links each.
7
+ 3. OPTIONAL product-family rows: a label and two to eight products, name and one line each; repeat the <nav> for a
8
+ second family
9
+ 4. the bottom row: copyright, legal links, OPTIONAL theme toggle, social links. Nothing else.
10
+ 5. OPTIONAL the wordmark: the brand's mark and name, large and faint, decoration only
11
+ Replace every [bracketed] placeholder and delete the optional parts you do not use. website.js wires the folds and
12
+ the toggle; without it the footer still works (the browser folds the <details> by itself).
13
+
14
+ Links that leave the site carry <span class="gw-footer__ext" aria-hidden="true"></span> (a drawn ↗). A link with
15
+ target="_blank" also carries <span class="gw-visually-hidden"> (opens in a new tab)</span>; a link without it does not.
9
16
  -->
10
17
  <footer class="gw-footer">
11
18
  <div class="gw-wrap">
@@ -13,6 +20,22 @@
13
20
  <div class="gw-footer__brand">
14
21
  <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="26" height="26"><span class="gw-brand__name">[Brand]</span></a>
15
22
  <p>[One sentence on what the brand does for the reader.]</p>
23
+ <!-- OPTIONAL Ask AI: each link opens an assistant with the same neutral question already typed, URL-encoded after
24
+ ?q= (spaces as %20). Ask what the brand is and what it does for the reader, never a leading question. Words,
25
+ not the assistants' logos. -->
26
+ <div class="gw-footer__ask">
27
+ <p class="gw-footer__ask-label" id="footer-ask">Ask AI about [Brand]</p>
28
+ <ul aria-labelledby="footer-ask">
29
+ <li><a href="https://chatgpt.com/?q=[URL-encoded question]" target="_blank" rel="noopener">ChatGPT<span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
30
+ <li><a href="https://claude.ai/new?q=[URL-encoded question]" target="_blank" rel="noopener">Claude<span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
31
+ <li><a href="https://www.perplexity.ai/search/new?q=[URL-encoded question]" target="_blank" rel="noopener">Perplexity<span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
32
+ <li><a href="https://www.google.com/search?udm=50&amp;q=[URL-encoded question]" target="_blank" rel="noopener">Google AI Mode<span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
33
+ </ul>
34
+ </div>
35
+ <!-- OPTIONAL newsletter (0.6.0): keep it only when it is wired to a list that keeps the address and sends what the
36
+ note promises. The note says how often and how to stop, and links to the privacy policy. No box ticked for
37
+ the reader, no "No thanks, I hate saving money". On send: the page sets aria-invalid="true" and the error's
38
+ words, or aria-busy on the button and then a role="status" line. -->
16
39
  <form class="gw-footer__newsletter" action="[/newsletter endpoint]" method="post" novalidate>
17
40
  <div class="gw-field">
18
41
  <label class="gw-field__label" for="footer-email">[Product news by email]</label>
@@ -25,31 +48,96 @@
25
48
  </div>
26
49
  </form>
27
50
  </div>
28
- <nav class="gw-footer__col" aria-label="[Column heading]">
29
- <h2 class="gw-footer__heading">[Column heading]</h2>
30
- <ul>
31
- <li><a href="[/page]">[Page title]</a></li>
32
- <li><a href="[/page]">[Page title]</a></li>
33
- <li><a href="[/page]">[Page title]</a></li>
34
- </ul>
51
+ <!-- the columns: three to six. Each is a <nav> named like its heading, and the heading sits inside the <summary>. -->
52
+ <nav class="gw-footer__col" aria-label="[Product]">
53
+ <details class="gw-footer__fold">
54
+ <summary><h2 class="gw-footer__heading">[Product]</h2></summary>
55
+ <ul>
56
+ <li><a href="[/page]">[Page title]</a></li>
57
+ <li><a href="[/page]">[Page title]</a></li>
58
+ <li><a href="[/page]">[Page title]</a></li>
59
+ <li><a href="[/pricing]">[Pricing]</a></li>
60
+ </ul>
61
+ </details>
35
62
  </nav>
36
- <nav class="gw-footer__col" aria-label="[Column heading]">
37
- <h2 class="gw-footer__heading">[Column heading]</h2>
38
- <ul>
39
- <li><a href="[/page]">[Page title]</a></li>
40
- <li><a href="[/page]">[Page title]</a></li>
41
- </ul>
63
+ <nav class="gw-footer__col" aria-label="[Use cases]">
64
+ <details class="gw-footer__fold">
65
+ <summary><h2 class="gw-footer__heading">[Use cases]</h2></summary>
66
+ <ul>
67
+ <li><a href="[/page]">[Page title]</a></li>
68
+ <li><a href="[/page]">[Page title]</a></li>
69
+ <li><a href="[/page]">[Page title]</a></li>
70
+ <li><a href="[/page]">[Page title]</a></li>
71
+ </ul>
72
+ </details>
73
+ </nav>
74
+ <nav class="gw-footer__col" aria-label="[Solutions]">
75
+ <details class="gw-footer__fold">
76
+ <summary><h2 class="gw-footer__heading">[Solutions]</h2></summary>
77
+ <ul>
78
+ <li><a href="[/page]">[Page title]</a></li>
79
+ <li><a href="[/page]">[Page title]</a></li>
80
+ <li><a href="[/page]">[Page title]</a></li>
81
+ <li><a href="[/page]">[Page title]</a></li>
82
+ </ul>
83
+ </details>
84
+ </nav>
85
+ <nav class="gw-footer__col" aria-label="[Resources]">
86
+ <details class="gw-footer__fold">
87
+ <summary><h2 class="gw-footer__heading">[Resources]</h2></summary>
88
+ <ul>
89
+ <li><a href="[/blog]">[Blog]</a></li>
90
+ <li><a href="[/docs]">[Docs]</a></li>
91
+ <li><a href="[/page]">[Page title]</a></li>
92
+ <li><a href="[https://another site of the brand]" rel="noopener">[Changelog]<span class="gw-footer__ext" aria-hidden="true"></span></a></li>
93
+ </ul>
94
+ </details>
95
+ </nav>
96
+ <nav class="gw-footer__col" aria-label="[Company]">
97
+ <details class="gw-footer__fold">
98
+ <summary><h2 class="gw-footer__heading">[Company]</h2></summary>
99
+ <ul>
100
+ <li><a href="[/about]">[About]</a></li>
101
+ <li><a href="[/careers]">[Careers]</a></li>
102
+ <li><a href="[/partners]">[Partners]</a></li>
103
+ <li><a href="[/contact]">[Contact]</a></li>
104
+ </ul>
105
+ </details>
42
106
  </nav>
43
107
  </div>
108
+ <!-- OPTIONAL product family: one <nav> per row, named like its label. Each item is the product's name (<b>) and one
109
+ line (<i>), a space between them. Another GemCommerce brand lists its siblings here (a GemPages site: GemBoss,
110
+ GemX, GemUpsells). -->
111
+ <nav class="gw-footer__family" aria-label="[More from Brand]">
112
+ <details class="gw-footer__fold">
113
+ <summary><h2 class="gw-footer__heading">[More from Brand]</h2></summary>
114
+ <ul>
115
+ <li><a href="[/product]"><b>[Product]</b> <i>[One line on what it is]</i></a></li>
116
+ <li><a href="[/product]"><b>[Product]</b> <i>[One line on what it is]</i></a></li>
117
+ <li><a href="[https://another site of the brand]" rel="noopener"><b>[Product]<span class="gw-footer__ext" aria-hidden="true"></span></b> <i>[One line on what it is]</i></a></li>
118
+ <li><a href="[https://a sibling brand's site]" target="_blank" rel="noopener"><b>[Product]<span class="gw-footer__ext" aria-hidden="true"></span></b> <i>[One line on what it is]</i><span class="gw-visually-hidden"> (opens in a new tab)</span></a></li>
119
+ </ul>
120
+ </details>
121
+ </nav>
44
122
  <div class="gw-footer__bottom">
45
123
  <span>© [Year] [Company]. All rights reserved.</span>
46
124
  <ul aria-label="Legal">
47
125
  <li><a href="[/privacy]">Privacy</a></li>
48
126
  <li><a href="[/terms]">Terms</a></li>
49
127
  </ul>
128
+ <!-- OPTIONAL theme toggle: the header's toggle, here instead of (or as well as) in the header -->
129
+ <button class="gw-theme-toggle gw-footer__toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor"/></svg></button>
50
130
  <ul aria-label="Social">
51
131
  <li><a href="[https://social profile]" rel="noopener">[Network]</a></li>
132
+ <li><a href="[https://social profile]" rel="noopener">[Network]</a></li>
52
133
  </ul>
53
134
  </div>
135
+ <!-- OPTIONAL wordmark: decoration (aria-hidden="true", the name is already in the brand block). The mark is an
136
+ inline SVG drawn in currentColor, so it takes the faint colour; an <img> cannot, so leave the mark out and keep
137
+ the name. Not shown on phones. -->
138
+ <div class="gw-footer__wordmark" aria-hidden="true">
139
+ <svg viewBox="[0 0 width height]">[the brand's mark, every fill and stroke "currentColor"]</svg>
140
+ <span>[Brand]</span>
141
+ </div>
54
142
  </div>
55
143
  </footer>
@@ -3,9 +3,20 @@
3
3
  A flat 64px bar with one hairline. A brand that chose the floating header adds one class to the <header>:
4
4
  class="gw-header gw-header--float" (an inset card from 1080px, docs/theming.md); nothing else changes.
5
5
  Below 1080px the nav and Sign in hide and the menu button opens #gw-mobile-menu,
6
- which repeats the same links. website.js runs the mega menus, the mobile menu and the theme toggle.
6
+ which repeats the same links. website.js runs the mega menus, the mobile menu, the language switcher and the theme
7
+ toggle.
7
8
  Limits: at most five top-level items, one .gw-btn (the primary action), one .gw-btn--secondary. Each mega panel:
8
9
  two or three columns of four or five links, at most one feature card. The announcement bar is optional: one sentence.
10
+ Two options (0.8.0):
11
+ - The mobile menu drills down (data-gw-menu="drill" on #gw-mobile-menu, as gemboss.ai's phone menu): the top level
12
+ lists the sections, a tap on one shows its level alone with Back and its title, and the actions stay in a bar at
13
+ the bottom of the sheet. Each group then carries its head (the Back button and the title, in the page's language).
14
+ Without the attribute and the heads it is the 0.7.0 menu, whose groups open in place; without the script the drill
15
+ menu is that one too.
16
+ - The language switcher (.gw-lang, twice: in the actions from 1080px, in the mobile menu's action bar below). It
17
+ lists the page's translations from its <link rel="alternate" hreflang="…" href="…"> in <head> and stays hidden
18
+ while the page has none: keep `hidden` on it, the script takes it away. Delete both copies only if the site will
19
+ never be translated. Put the word for "Language" in the page's language in data-gw-label-language.
9
20
  Replace every [bracketed] placeholder; delete what you do not use.
10
21
  -->
11
22
  <div class="gw-announce" role="region" aria-label="Announcement">[One sentence of news.] <a href="[link]">[Where the link goes]</a></div>
@@ -36,25 +47,42 @@
36
47
  </nav>
37
48
  <div class="gw-header__actions">
38
49
  <button class="gw-theme-toggle" type="button" data-gw-theme-toggle aria-label="Switch to dark theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor"/></svg></button>
50
+ <!-- optional (0.8.0): the language switcher, shown by the script when <head> lists another language -->
51
+ <div class="gw-lang" data-gw-lang hidden>
52
+ <button class="gw-lang__btn" type="button" aria-expanded="false" aria-controls="gw-lang-menu" data-gw-label-language="Language"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M3 10h14M10 3c2 2 2.8 4.3 2.8 7s-.8 5-2.8 7c-2-2-2.8-4.3-2.8-7s.8-5 2.8-7z"/></svg><span class="gw-lang__short"></span></button>
53
+ <ul class="gw-lang__menu" id="gw-lang-menu" hidden></ul>
54
+ </div>
39
55
  <a class="gw-nav__link gw-header__signin" href="[sign-in URL]">Sign in</a>
40
56
  <a class="gw-btn gw-btn--secondary gw-btn--sm" href="[/contact]">[Book a demo]</a>
41
57
  <a class="gw-btn gw-btn--sm" href="[sign-up URL]">[Start free]</a>
42
58
  <button class="gw-menu-toggle" type="button" aria-expanded="false" aria-controls="gw-mobile-menu" data-gw-menu-toggle aria-label="Open menu"><svg class="gw-menu-toggle__open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg><svg class="gw-menu-toggle__close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
43
59
  </div>
44
60
  </div>
45
- <div class="gw-mobile-menu" id="gw-mobile-menu" hidden>
61
+ <!-- data-gw-menu="drill" (0.8.0): leave it out, and the .gw-mobile-menu__head of each group, for the 0.7.0 menu -->
62
+ <div class="gw-mobile-menu" id="gw-mobile-menu" data-gw-menu="drill" hidden>
46
63
  <nav class="gw-wrap" aria-label="Main, mobile">
47
64
  <details class="gw-mobile-menu__group">
48
65
  <summary>[Product menu item]</summary>
66
+ <!-- the level's head: Back and the title (the same words as the summary), focused when the level opens -->
67
+ <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 menu item]</h2></div>
68
+ <!-- optional: a heading over a run of links, as in the mega panel's columns -->
69
+ <p class="gw-mobile-menu__heading">[Group]</p>
49
70
  <ul class="gw-mobile-menu__list">
50
- <li><a class="gw-mobile-menu__link" href="[/page]">[Page title]</a></li>
51
- <li><a class="gw-mobile-menu__link" href="[/page]">[Page title]</a></li>
71
+ <!-- a link with an icon and one line (both optional: <a class="gw-mobile-menu__link" href="…">[Page title]</a> is enough) -->
72
+ <li><a class="gw-mobile-menu__link" href="[/page]"><span class="gw-mobile-menu__icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg></span><span><b>[Page title]</b><i>[One line on what it does]</i></span></a></li>
73
+ <li><a class="gw-mobile-menu__link" href="[/page]"><span class="gw-mobile-menu__icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 15l4-4 3 3 5-6"/></svg></span><span><b>[Page title]</b><i>[One line on what it does]</i></span></a></li>
52
74
  </ul>
53
75
  </details>
54
76
  <a class="gw-mobile-menu__top" href="[/pricing]">[Pricing]</a>
77
+ <!-- the action bar: what is not a button on the first line, two things at most (the language on the left, Sign in
78
+ on the right), the two buttons under it. Stays at the bottom of the sheet in the drill menu. -->
55
79
  <div class="gw-mobile-menu__actions">
56
- <a class="gw-btn gw-btn--block" href="[sign-up URL]">[Start free]</a>
57
- <a class="gw-btn gw-btn--secondary gw-btn--block" href="[/contact]">[Book a demo]</a>
80
+ <div class="gw-lang" data-gw-lang hidden>
81
+ <button class="gw-lang__btn" type="button" aria-expanded="false" aria-controls="gw-lang-menu-mobile" data-gw-label-language="Language"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M3 10h14M10 3c2 2 2.8 4.3 2.8 7s-.8 5-2.8 7c-2-2-2.8-4.3-2.8-7s.8-5 2.8-7z"/></svg><span class="gw-lang__short"></span></button>
82
+ <ul class="gw-lang__menu" id="gw-lang-menu-mobile" hidden></ul>
83
+ </div>
84
+ <a class="gw-btn gw-btn--lg gw-btn--block" href="[sign-up URL]">[Start free]</a>
85
+ <a class="gw-btn gw-btn--secondary gw-btn--lg gw-btn--block" href="[/contact]">[Book a demo]</a>
58
86
  <a class="gw-mobile-menu__link" href="[sign-in URL]">Sign in</a>
59
87
  </div>
60
88
  </nav>
@@ -95,6 +95,7 @@
95
95
  </section>
96
96
  </main>
97
97
 
98
+ <!-- the short footer; the full one (Ask AI, family rows, theme toggle, wordmark) is templates/footer.html -->
98
99
  <footer class="gw-footer">
99
100
  <div class="gw-wrap">
100
101
  <div class="gw-footer__grid">
@@ -103,19 +104,23 @@
103
104
  <p>[One sentence on what the brand does for the reader.]</p>
104
105
  </div>
105
106
  <nav class="gw-footer__col" aria-label="[Column heading]">
106
- <h2 class="gw-footer__heading">[Column heading]</h2>
107
- <ul>
108
- <li><a href="[/page]">[Page title]</a></li>
109
- <li><a href="[/page]">[Page title]</a></li>
110
- <li><a href="[/page]">[Page title]</a></li>
111
- </ul>
107
+ <details class="gw-footer__fold">
108
+ <summary><h2 class="gw-footer__heading">[Column heading]</h2></summary>
109
+ <ul>
110
+ <li><a href="[/page]">[Page title]</a></li>
111
+ <li><a href="[/page]">[Page title]</a></li>
112
+ <li><a href="[/page]">[Page title]</a></li>
113
+ </ul>
114
+ </details>
112
115
  </nav>
113
116
  <nav class="gw-footer__col" aria-label="[Column heading]">
114
- <h2 class="gw-footer__heading">[Column heading]</h2>
115
- <ul>
116
- <li><a href="[/page]">[Page title]</a></li>
117
- <li><a href="[/page]">[Page title]</a></li>
118
- </ul>
117
+ <details class="gw-footer__fold">
118
+ <summary><h2 class="gw-footer__heading">[Column heading]</h2></summary>
119
+ <ul>
120
+ <li><a href="[/page]">[Page title]</a></li>
121
+ <li><a href="[/page]">[Page title]</a></li>
122
+ </ul>
123
+ </details>
119
124
  </nav>
120
125
  </div>
121
126
  <div class="gw-footer__bottom">