@gemboss/website-ui 0.3.0 → 0.5.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 ADDED
@@ -0,0 +1,342 @@
1
+ /*! @gemboss/website-ui website.mjs | spec: docs/markup-contract.md */
2
+ // The ESM entry: import { init, destroy, version } from '@gemboss/website-ui/website.mjs'. Importing it does nothing;
3
+ // call init() once the markup is in the page, and again after every route change or insert. No dependencies.
4
+ //
5
+ // js/website.js, the classic script, is GENERATED from this file by scripts/build.mjs (this code in an IIFE, without
6
+ // the export line and the // comments, plus auto-init): edit this file, then run the build. Keep comments on lines of
7
+ // their own, so the build can leave them out.
8
+
9
+ var version = '0.5.0';
10
+ var api = { version: version, init: init, destroy: destroy };
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;
13
+ var READY = 'data-gw-ready';
14
+
15
+ function mq(q) { return window.matchMedia ? matchMedia(q) : { matches: false }; }
16
+ function watch(m, fn) { if (m.addEventListener) m.addEventListener('change', fn); }
17
+ function all(sel, ctx) { return [].slice.call((ctx || doc).querySelectorAll(sel)); }
18
+ // Like all(), and ctx itself when it matches: init(header) wires the header it is given.
19
+ function within(sel, ctx) { var l = all(sel, ctx); if (ctx.matches && ctx.matches(sel)) l.unshift(ctx); return l; }
20
+ function on(el, type, fn, opt) { el.addEventListener(type, fn, opt || false); }
21
+ function attr(el, name, dflt) { return el.getAttribute(name) || dflt; }
22
+ function esc(e) { return e.key === 'Escape'; }
23
+ function link(e) { return e.target.closest('a[href]'); }
24
+ function emit(el, type, detail) { if (window.CustomEvent) el.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: detail })); }
25
+
26
+ // Wiring. Each element under ctx that matches sel is wired once as `kind`: fn(el, on) adds the element's listeners
27
+ // with the `on` it is given (so destroy can remove them) and returns a record, or nothing when the element cannot be
28
+ // wired yet (the next init tries again). Records live in `live`. data-gw-ready lists the kinds wired on an element;
29
+ // the record, not the attribute, decides, so markup copied from a live page, attribute and all, is still wired.
30
+ // wire() does the same for a list of elements a selector cannot name (the tables straight inside a .gw-prose).
31
+ function each(ctx, sel, kind, fn) { wire(within(sel, ctx), kind, fn); }
32
+ function wire(els, kind, fn) {
33
+ els.forEach(function (el) {
34
+ if (live.some(function (r) { return r.el === el && r.kind === kind; })) return;
35
+ var offs = [], r = fn(el, function (t, type, f, opt) {
36
+ on(t, type, f, opt);
37
+ offs.push(function () { t.removeEventListener(type, f, opt || false); });
38
+ });
39
+ if (!r) return offs.forEach(function (f) { f(); });
40
+ r.el = el; r.kind = kind; r.offs = offs;
41
+ mark(el, kind, true);
42
+ live.push(r);
43
+ });
44
+ }
45
+ function mark(el, kind, add) {
46
+ var l = attr(el, READY, '').split(' ').filter(function (k) { return k && k !== kind; });
47
+ if (add) l.push(kind);
48
+ if (l.length) el.setAttribute(READY, l.join(' ')); else el.removeAttribute(READY);
49
+ }
50
+ function drop(r) {
51
+ if (r.stop) r.stop();
52
+ r.offs.forEach(function (f) { f(); });
53
+ mark(r.el, r.kind);
54
+ live.splice(live.indexOf(r), 1);
55
+ }
56
+ function records(kind, fn) { live.slice().forEach(function (r) { if (r.kind === kind) fn(r); }); }
57
+
58
+ // Mega menu. Opened by hover then clicked, a panel stays open: the click meant "open". One open on the page, across
59
+ // every header init() wired. Each change fires gw:mega on the trigger.
60
+ function setMega(m, show, mode) {
61
+ m.p.hidden = !show;
62
+ m.t.setAttribute('aria-expanded', String(show));
63
+ m.item.classList.toggle('is-open', show);
64
+ m.mode = mode;
65
+ megaOpen = show ? m : megaOpen === m ? null : megaOpen;
66
+ emit(m.t, 'gw:mega', { id: m.p.id, open: show });
67
+ }
68
+ function showMega(m, mode) {
69
+ clearTimeout(megaTimer);
70
+ if (megaOpen === m) { if (mode === 'click') m.mode = mode; return; }
71
+ if (megaOpen) setMega(megaOpen, false);
72
+ setMega(m, true, mode);
73
+ }
74
+ function hideMega(focus) {
75
+ var m = megaOpen;
76
+ clearTimeout(megaTimer);
77
+ if (!m) return;
78
+ setMega(m, false);
79
+ if (focus === true) m.t.focus();
80
+ }
81
+ function inside(m, el) { return !!el && (m.item.contains(el) || m.p.contains(el)); }
82
+ function mega(ctx) {
83
+ each(ctx, '[data-gw-mega]', 'mega', function (t, on) {
84
+ var p = doc.getElementById(attr(t, 'data-gw-mega', attr(t, 'aria-controls', '')));
85
+ if (!p) return;
86
+ var m = { t: t, p: p, item: t.closest('.gw-nav__item') || t.parentNode };
87
+ on(t, 'click', function () { if (megaOpen === m && m.mode !== 'hover') hideMega(); else showMega(m, 'click'); });
88
+ (m.item.contains(p) ? [m.item] : [m.item, p]).forEach(function (el) {
89
+ on(el, 'mouseenter', function () {
90
+ if (fine.matches) { clearTimeout(megaTimer); if (megaOpen !== m) megaTimer = setTimeout(function () { showMega(m, 'hover'); }, 80); }
91
+ });
92
+ on(el, 'mouseleave', function () {
93
+ if (fine.matches) { clearTimeout(megaTimer); if (megaOpen === m && m.mode === 'hover') megaTimer = setTimeout(hideMega, 160); }
94
+ });
95
+ });
96
+ on(m.item, 'focusout', function (e) { if (megaOpen === m && e.relatedTarget && !inside(m, e.relatedTarget)) hideMega(); });
97
+ on(p, 'click', function (e) { if (link(e)) hideMega(); });
98
+ m.stop = function () { if (megaOpen === m) hideMega(); };
99
+ return m;
100
+ });
101
+ }
102
+
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.
105
+ function mobile(ctx) {
106
+ each(ctx, '[data-gw-menu-toggle]', 'menu', function (btn, on) {
107
+ var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
108
+ if (!menu) return;
109
+ var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
110
+ var r = {
111
+ open: function () { return !menu.hidden; },
112
+ set: function (o, focus) {
113
+ menu.hidden = !o;
114
+ btn.setAttribute('aria-expanded', String(o));
115
+ btn.setAttribute('aria-label', label[+o]);
116
+ root.classList.toggle('is-menu-open', o);
117
+ if (focus) btn.focus();
118
+ },
119
+ stop: function () { if (r.open()) r.set(false); }
120
+ };
121
+ on(btn, 'click', function () { r.set(!r.open()); });
122
+ on(menu, 'click', function (e) { if (link(e)) r.set(false); });
123
+ return r;
124
+ });
125
+ }
126
+ function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
127
+
128
+ // Theme toggle. Labels: data-gw-label-dark/-light, else the markup's aria-label, else English. A press fires gw:theme
129
+ // on <html>; so does a change of the system's scheme while the page follows it.
130
+ function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
131
+ function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
132
+ function theme(ctx) {
133
+ each(ctx, '[data-gw-theme-toggle]', 'theme', function (b, on) {
134
+ var r = { labels: [attr(b, 'data-gw-label-dark', attr(b, 'aria-label', 'Switch to dark theme')), attr(b, 'data-gw-label-light', 'Switch to light theme')] };
135
+ on(b, 'click', function () {
136
+ var next = dark() ? 'light' : 'dark';
137
+ root.setAttribute('data-theme', next);
138
+ try { localStorage.setItem('gw-theme', next); } catch (e) { /* blocked */ }
139
+ relabel();
140
+ emit(root, 'gw:theme', { theme: next });
141
+ });
142
+ // taken back: the markup's own label again, so a later init reads the same words
143
+ r.stop = function () { b.setAttribute('aria-label', r.labels[0]); };
144
+ return r;
145
+ });
146
+ relabel();
147
+ }
148
+
149
+ // Reveal at 15% visible, once. Failsafe: shown 2.5s after the init that found them, or at once without
150
+ // IntersectionObserver or under reduced motion.
151
+ function reveal(ctx) {
152
+ var els = [], io = null;
153
+ each(ctx, '.gw-reveal', 'reveal', function (el) { els.push(el); return { stop: function () { if (io) io.unobserve(el); } }; });
154
+ function showAll() { els.forEach(function (el) { el.classList.add('is-in'); }); if (io) io.disconnect(); }
155
+ if (!els.length) return;
156
+ if (!window.IntersectionObserver || mq('(prefers-reduced-motion: reduce)').matches) return showAll();
157
+ io = new IntersectionObserver(function (list) {
158
+ list.forEach(function (en) { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } });
159
+ }, { threshold: 0.15 });
160
+ els.forEach(function (el) { io.observe(el); });
161
+ setTimeout(showAll, 2500);
162
+ }
163
+
164
+ // Header: .is-scrolled past 8px, read once per frame (one scroll listener for every header).
165
+ function scrolled() {
166
+ var s = window.scrollY > 8;
167
+ queued = false;
168
+ records('header', function (r) { if (r.s !== s) r.el.classList.toggle('is-scrolled', (r.s = s)); });
169
+ }
170
+ function header(ctx) {
171
+ each(ctx, '[data-gw-header]', 'header', function (h) { return { s: false, stop: function () { h.classList.remove('is-scrolled'); } }; });
172
+ scrolled();
173
+ }
174
+
175
+ // aria-current="page" on nav links to this path, unless the markup marked one. The script's own marks are taken
176
+ // back first, so an init() after a client-side navigation moves them.
177
+ function current(ctx) {
178
+ var links = within('.gw-nav__link[href], .gw-mega__link[href]', ctx);
179
+ within('#gw-mobile-menu, .gw-mobile-menu', ctx).forEach(function (m) { links = links.concat(all('a[href]', m)); });
180
+ function norm(p) { return p.replace(/\/index\.html?$/, '/').replace(/\.html?$/, '').replace(/\/+$/, '') || '/'; }
181
+ links.forEach(function (a) { if (mine.has(a)) { mine.delete(a); a.removeAttribute('aria-current'); } });
182
+ if (links.some(function (a) { return a.hasAttribute('aria-current'); })) return;
183
+ links.forEach(function (a) {
184
+ if (a.classList.contains('gw-btn') || a.hash || a.host !== location.host) return;
185
+ if (norm(a.pathname) === norm(location.pathname)) { a.setAttribute('aria-current', 'page'); mine.add(a); }
186
+ });
187
+ }
188
+
189
+ // Segmented control: a radio group, one Tab stop; arrows (Home, End) move and choose. What one option shows carries
190
+ // data-gw-show="group:value" and is hidden unless that option is chosen; without the script the markup's own choice
191
+ // shows. A choice made by the reader fires gw:segmented on the group; wiring it does not.
192
+ function segmented(ctx) {
193
+ each(ctx, '[data-gw-segmented]', 'segmented', function (g, on) {
194
+ var name = attr(g, 'data-gw-segmented', ''), opts = all('[role="radio"]', g);
195
+ if (!opts.length) return;
196
+ function pick(o, focus, said) {
197
+ var v = attr(o, 'value', '');
198
+ opts.forEach(function (x) { x.setAttribute('aria-checked', String(x === o)); x.setAttribute('tabindex', x === o ? '0' : '-1'); });
199
+ if (focus) o.focus();
200
+ all('[data-gw-show]').forEach(function (el) {
201
+ var own = attr(el, 'data-gw-show', '').split(/\s+/).filter(function (t) { return t.split(':')[0] === name; });
202
+ if (own.length) el.hidden = own.indexOf(name + ':' + v) < 0;
203
+ });
204
+ if (said) emit(g, 'gw:segmented', { name: name, value: v });
205
+ }
206
+ opts.forEach(function (o, i) {
207
+ on(o, 'click', function () { pick(o, false, true); });
208
+ on(o, 'keydown', function (e) {
209
+ var n = opts.length, k = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
210
+ if (k === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
211
+ e.preventDefault();
212
+ pick(opts[k % n], true, true);
213
+ });
214
+ });
215
+ pick(opts.filter(function (o) { return attr(o, 'aria-checked', '') === 'true'; })[0] || opts[0]);
216
+ return {};
217
+ });
218
+ }
219
+
220
+ // Contents (.gw-toc, data-gw-toc): links to the #ids of the article's headings, plain links that work without the
221
+ // script. The section in view is the one whose heading last went above the line 30% down the window: its link gets
222
+ // aria-current="true" and the nav fires gw:toc ({ id }, '' above the first heading). One IntersectionObserver per
223
+ // list sees a heading cross that line, both ways; its root runs from far above the window down to the line, so a jump
224
+ // across many sections (a link, End, a dragged scrollbar) still counts. Without IntersectionObserver nothing is
225
+ // marked. The <details> is open from 1080px (the list sits beside the article), below that it keeps the markup's own
226
+ // state (folded) and a link inside folds it before the page moves.
227
+ function toc(ctx) {
228
+ each(ctx, '[data-gw-toc]', 'toc', function (nav, on) {
229
+ var box = nav.querySelector('details'), own = !!(box && box.open), cur = null, io = null;
230
+ var items = all('a[href]', nav).map(function (a) {
231
+ var id = attr(a, 'href', '');
232
+ if (id.charAt(0) !== '#') return null;
233
+ try { id = decodeURIComponent(id.slice(1)); } catch (e) { id = id.slice(1); }
234
+ return { a: a, h: doc.getElementById(id) };
235
+ }).filter(function (x) { return x && x.h; });
236
+ if (!items.length) return;
237
+ function update() {
238
+ var line = window.innerHeight * 0.3, top = -Infinity, next = null;
239
+ items.forEach(function (x) { var t = x.h.getBoundingClientRect().top; if (t <= line && t > top) { top = t; next = x; } });
240
+ if (next === cur) return;
241
+ if (cur) cur.a.removeAttribute('aria-current');
242
+ if (next) next.a.setAttribute('aria-current', 'true');
243
+ cur = next;
244
+ emit(nav, 'gw:toc', { id: next ? next.h.id : '' });
245
+ }
246
+ var r = {
247
+ fit: function () { if (box) box.open = wide.matches || own; },
248
+ stop: function () { if (io) io.disconnect(); if (cur) cur.a.removeAttribute('aria-current'); if (box) box.open = own; }
249
+ };
250
+ on(nav, 'click', function (e) { if (box && !wide.matches && link(e)) box.open = false; });
251
+ if (window.IntersectionObserver) {
252
+ io = new IntersectionObserver(update, { rootMargin: '100000px 0px -70% 0px' });
253
+ items.forEach(function (x) { io.observe(x.h); });
254
+ }
255
+ r.fit();
256
+ return r;
257
+ });
258
+ }
259
+
260
+ // Prose tables (gap #63): each <table> straight inside a .gw-prose goes into a scroll box of its own,
261
+ // div.gw-prose__table, a region named after its caption, else its first header cell, else its first cell, with
262
+ // tabindex="0" so a keyboard can scroll it. The table is marked .is-wrapped and keeps display: table. A table with no
263
+ // words to name it is left as it is (it scrolls by itself, as in 0.2.0). destroy() takes the box away again.
264
+ function words(el) { return el ? el.textContent.replace(/\s+/g, ' ').trim() : ''; }
265
+ function tables(ctx) {
266
+ var list = [];
267
+ within('.gw-prose', ctx).forEach(function (p) {
268
+ list = list.concat([].filter.call(p.children, function (t) { return t.tagName === 'TABLE'; }));
269
+ });
270
+ wire(list, 'table', function (t) {
271
+ var name = words(t.querySelector('caption')) || words(t.querySelector('th')) || words(t.querySelector('td'));
272
+ if (!name) return;
273
+ var box = doc.createElement('div'), had = t.classList.contains('is-wrapped');
274
+ box.className = 'gw-prose__table';
275
+ box.setAttribute('role', 'region');
276
+ box.setAttribute('tabindex', '0');
277
+ box.setAttribute('aria-label', name);
278
+ t.parentNode.insertBefore(box, t);
279
+ box.appendChild(t);
280
+ t.classList.add('is-wrapped');
281
+ return { stop: function () {
282
+ var p = box.parentNode;
283
+ if (p) { p.insertBefore(t, box); p.removeChild(box); }
284
+ if (!had) t.classList.remove('is-wrapped');
285
+ } };
286
+ });
287
+ }
288
+
289
+ // Once per page: what belongs to the document, the window or a media query rather than to one element.
290
+ function install() {
291
+ doc = document; root = doc.documentElement; mine = new WeakSet();
292
+ fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
293
+ var stored = null;
294
+ // 1080px, the kit's desktop width: the header shows its nav, the contents sit beside the article
295
+ wide = mq('(min-width: 1080px)');
296
+ on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
297
+ on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
298
+ on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
299
+ watch(wide, function () { if (wide.matches) closeMenus(); records('toc', function (r) { r.fit(); }); });
300
+ on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
301
+ try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
302
+ if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
303
+ watch(sys, function () { relabel(); if (!root.hasAttribute('data-theme')) emit(root, 'gw:theme', { theme: dark() ? 'dark' : 'light' }); });
304
+ on(window, 'scroll', function () { if (!queued) { queued = true; requestAnimationFrame(scrolled); } }, { passive: true });
305
+ // A busy button (aria-busy="true") ignores presses, by mouse or keyboard: no double submit. It keeps its focus and
306
+ // name. One listener on the document, in the capture phase, so it covers buttons inserted later too.
307
+ on(doc, 'click', function (e) {
308
+ var b = e.target.closest && e.target.closest('.gw-btn[aria-busy="true"]');
309
+ if (b) { e.preventDefault(); e.stopPropagation(); }
310
+ }, true);
311
+ }
312
+
313
+ function run(fns, ctx) {
314
+ fns.forEach(function (f) { try { f(ctx); } catch (e) { console.error('[gw-website]', e); } });
315
+ }
316
+ // One copy of the kit owns a page (window.gwWebsite). When another one got there first (the classic script, a second
317
+ // bundle), it does the work, so nothing is wired twice.
318
+ function other() { var g = typeof window != 'undefined' && window.gwWebsite; return g && g !== api && g; }
319
+
320
+ /** Wires the kit's markup under root (default: the whole document). Safe to call again: wires only what is new. */
321
+ function init(ctx) {
322
+ // server rendering: nothing to wire
323
+ if (typeof document == 'undefined') return;
324
+ var g = other();
325
+ if (g) return g.init && g.init(ctx);
326
+ window.gwWebsite = api;
327
+ if (!doc) run([install]);
328
+ // an element removed from the page since the last init is let go
329
+ 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);
331
+ }
332
+
333
+ /** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
334
+ function destroy(ctx) {
335
+ var g = other();
336
+ if (g) return g.destroy && g.destroy(ctx);
337
+ if (!doc) return;
338
+ ctx = ctx && ctx.querySelectorAll ? ctx : doc;
339
+ live.slice().forEach(function (r) { if (ctx === doc || ctx.contains(r.el)) drop(r); });
340
+ }
341
+
342
+ export { version, init, destroy };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -23,6 +23,7 @@
23
23
  "./website.css": "./website.css",
24
24
  "./website.min.css": "./website.min.css",
25
25
  "./website.js": "./js/website.js",
26
+ "./website.mjs": "./js/website.mjs",
26
27
  "./system.json": "./system.json",
27
28
  "./css/*": "./css/*",
28
29
  "./package.json": "./package.json"
package/skill/SKILL.md CHANGED
@@ -17,9 +17,14 @@ the kit and its rules are how the page ends up looking designed. Compose from th
17
17
  3. **Build**:
18
18
  - New page: copy `KIT/templates/page.html`, then add sections from `KIT/templates/sections/` in the order the page
19
19
  needs. Fill the words, delete what you did not use.
20
+ - An article page (a blog post, a guide) is `KIT/templates/sections/article.html`: breadcrumbs, the meta row, a
21
+ contents list from the article's h2s, the prose, callouts, the FAQ last. Never a callout told by its colour
22
+ alone (it keeps its label: Note, Tip, Warning). The blog index is `KIT/templates/sections/post-list.html`.
20
23
  - Changed page: keep its kit classes; change words, order and choice of sections.
21
24
  - Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
22
25
  scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.
26
+ - No JS of your own for kit parts: markup inserted after load (a route change, a block) gets
27
+ `gwWebsite.init(root)`, or `init(root)` from `website.mjs` (`KIT/docs/markup-contract.md`, "Script").
23
28
  - Another brand: choose only the accent family, wash hues, logo and favicon, and the three named profiles
24
29
  (`data-gw-action` and `data-gw-radius` on `<html>`, `.gw-header--float`), never free values
25
30
  (`KIT/docs/theming.md`).
package/system.json CHANGED
@@ -21,6 +21,7 @@
21
21
  "success": "#007f5f",
22
22
  "success-soft": "#e3f1df",
23
23
  "warning": "#9a6700",
24
+ "warning-soft": "#fbf1dc",
24
25
  "night": "#0c0e14",
25
26
  "night-2": "#141722",
26
27
  "on-night": "#e3e4e8",
@@ -45,6 +46,7 @@
45
46
  "success": "#34c38f",
46
47
  "success-soft": "#132a22",
47
48
  "warning": "#e0b341",
49
+ "warning-soft": "#2b2410",
48
50
  "night": "#0a0b10",
49
51
  "night-2": "#12141c",
50
52
  "on-night": "#e3e4e8",
@@ -60,7 +62,7 @@
60
62
  "roles": {
61
63
  "action": "The thing you click: ink, or the brand's accent when the site chose the accent action profile (profiles.action).",
62
64
  "accent": "Links, focus rings, active state, a chart line, the 8px label square; the primary button only through the accent action profile. Never body text, never words in a heading, never a section band.",
63
- "success/warning": "State only.",
65
+ "success/warning": "State only. Their -soft tints are grounds: a tip or a warning callout, a success badge.",
64
66
  "wash": "The hero wash and illustrations only."
65
67
  }
66
68
  },
@@ -0,0 +1,93 @@
1
+ <!--
2
+ Article: a blog post or a guide, the whole <main> of the page. In this order: breadcrumbs, the title (the page's one
3
+ <h1>), the meta row, a hero image, the contents list, the article's own HTML in .gw-prose, the FAQ last.
4
+ - Breadcrumbs: every level above the page, then the page itself with aria-current="page". No separator characters:
5
+ the kit draws them. A BreadcrumbList JSON-LD is up to the site (example: docs/components.md, Breadcrumbs).
6
+ - Meta row: each item a <span>; the date as <time datetime="YYYY-MM-DD">, "Updated" when it changed after it was
7
+ published; the read time at about 200 words a minute. The avatar is optional (alt="": the name is written next
8
+ to it).
9
+ - Hero: the post's own image, with width and height, not lazy (it is the first screen). Leave the figure out when
10
+ the post has none.
11
+ - Contents: one link per <h2> of the article (a nested <ol> for its <h3>s, if any), each to the heading's id, in the
12
+ article's order, with the heading's words. data-gw-toc lets website.js mark the section in view and fold the list
13
+ on phones; the links work without it. check reports a link to an id that is not on the page.
14
+ - Callouts: a note, a tip or a warning, one idea each, at most one every few sections. Always the label in words
15
+ (Note, Tip, Warning, or the page's language): never a callout told by its colour alone.
16
+ - FAQ: the last <h2> of the article, real questions with short answers. FAQPage JSON-LD only if every answer is
17
+ visible on the page (example: docs/components.md, Article).
18
+ Limits: one article per page; headings in order (h2, then h3), each <h2> with an id; tables straight in the prose
19
+ are boxed by website.js (gap #63), give them a <caption>.
20
+ Then a post list (templates/sections/post-list.html, "More from the blog") and the closing CTA, if the page needs one.
21
+ -->
22
+ <article class="gw-wrap gw-article">
23
+ <header class="gw-article__head">
24
+ <nav class="gw-breadcrumbs" aria-label="Breadcrumb">
25
+ <ol>
26
+ <li><a href="[/blog]">[Blog]</a></li>
27
+ <li><a href="[/blog/category]">[Category]</a></li>
28
+ <li><a href="[/blog/this-post]" aria-current="page">[The post's title]</a></li>
29
+ </ol>
30
+ </nav>
31
+ <h1>[The post's title]</h1>
32
+ <p class="gw-article-meta">
33
+ <span><img class="gw-article-meta__avatar" src="[/img/author.webp]" alt="" width="24" height="24">[Author or team]</span>
34
+ <span><a href="[/blog/category]">[Category]</a></span>
35
+ <span>[Updated] <time datetime="[2026-10-25]">[October 25, 2026]</time></span>
36
+ <span>[5] min read</span>
37
+ </p>
38
+ </header>
39
+ <figure class="gw-article__hero">
40
+ <img src="[/img/post-hero.webp]" alt="[What the picture shows]" width="[1200]" height="[675]" fetchpriority="high">
41
+ </figure>
42
+ <nav class="gw-toc" aria-label="[On this page]" data-gw-toc>
43
+ <details>
44
+ <summary>[On this page]</summary>
45
+ <ol>
46
+ <li><a href="#[first-section]">[The first section's heading]</a></li>
47
+ <li><a href="#[second-section]">[The second section's heading]</a>
48
+ <ol>
49
+ <li><a href="#[a-step]">[A step inside it]</a></li>
50
+ </ol>
51
+ </li>
52
+ <li><a href="#faq">[Frequently asked questions]</a></li>
53
+ </ol>
54
+ </details>
55
+ </nav>
56
+ <div class="gw-prose">
57
+ <p>[The first paragraph: what the reader will know or be able to do by the end.]</p>
58
+ <h2 id="[first-section]">[The first section's heading]</h2>
59
+ <p>[Paragraphs of two to five sentences. Links go where they say: <a href="[link]">[the setup guide]</a>.]</p>
60
+ <div class="gw-callout gw-callout--note" role="note">
61
+ <p class="gw-callout__label">[Note]</p>
62
+ <p>[Something the reader should keep in mind, in one or two sentences.]</p>
63
+ </div>
64
+ <h2 id="[second-section]">[The second section's heading]</h2>
65
+ <p>[A paragraph.]</p>
66
+ <h3 id="[a-step]">[A step inside it]</h3>
67
+ <p>[A paragraph.]</p>
68
+ <table>
69
+ <caption>[What the table compares]</caption>
70
+ <thead><tr><th scope="col">[Column]</th><th scope="col">[Column]</th></tr></thead>
71
+ <tbody><tr><th scope="row">[Row]</th><td>[Value]</td></tr></tbody>
72
+ </table>
73
+ <div class="gw-callout gw-callout--tip" role="note">
74
+ <p class="gw-callout__label">[Tip]</p>
75
+ <p>[A shortcut or a better way, in one or two sentences.]</p>
76
+ </div>
77
+ <div class="gw-callout gw-callout--warning" role="note">
78
+ <p class="gw-callout__label">[Warning]</p>
79
+ <p>[What goes wrong if the reader skips this, in one or two sentences.]</p>
80
+ </div>
81
+ <h2 id="faq">[Frequently asked questions]</h2>
82
+ <div class="gw-faq">
83
+ <details class="gw-faq__item">
84
+ <summary>[A question the reader asks?]</summary>
85
+ <p>[The answer in two to four sentences.]</p>
86
+ </details>
87
+ <details class="gw-faq__item">
88
+ <summary>[A question the reader asks?]</summary>
89
+ <p>[The answer.]</p>
90
+ </details>
91
+ </div>
92
+ </div>
93
+ </article>
@@ -0,0 +1,65 @@
1
+ <!--
2
+ Post list: the blog index, a category page, or "More from the blog" under an article. Cards with a picture
3
+ (.gw-card__media, gap #33), the category as a .gw-label, the title, one or two lines of excerpt and the meta row.
4
+ - The title's link covers the whole card, so the card is one target and its name is the title alone: no other link
5
+ or button inside a card. The picture is alt="" here (the title says what the post is) and loading="lazy".
6
+ - The title is one level below the section's heading (h3 under an h2; h2 on the index, under its h1).
7
+ - --wide on the picture for 1200 x 630 share images (1.91:1), --square for 1:1; the default is 16:9.
8
+ - .gw-post-list--compact: rows without cards or pictures, for a long list or a sidebar.
9
+ - Pagination: previous and next as links (rel="prev" / rel="next"), the page number between them as words. Leave
10
+ out the link that does not exist (no "Newer posts" on page 1).
11
+ Limits: three, six or nine cards in the grid; the same picture shape for every card of a list; real posts, real
12
+ dates.
13
+ -->
14
+ <section class="gw-section" aria-labelledby="[posts-h]">
15
+ <div class="gw-wrap">
16
+ <div class="gw-section__head">
17
+ <p class="gw-label">[Blog]</p>
18
+ <h2 id="[posts-h]">[More from the blog]</h2>
19
+ </div>
20
+ <ul class="gw-post-list" role="list">
21
+ <li>
22
+ <article class="gw-card gw-post">
23
+ <div class="gw-card__media"><img src="[/img/post.webp]" alt="" width="[640]" height="[360]" loading="lazy"></div>
24
+ <p class="gw-label">[Category]</p>
25
+ <h3 class="gw-post__title"><a href="[/blog/post]">[The post's title]</a></h3>
26
+ <p>[One or two lines on what the reader gets from it.]</p>
27
+ <p class="gw-article-meta"><span><time datetime="[2026-10-25]">[Oct 25, 2026]</time></span><span>[5] min read</span></p>
28
+ </article>
29
+ </li>
30
+ <li>
31
+ <article class="gw-card gw-post">
32
+ <div class="gw-card__media"><img src="[/img/post.webp]" alt="" width="[640]" height="[360]" loading="lazy"></div>
33
+ <p class="gw-label">[Category]</p>
34
+ <h3 class="gw-post__title"><a href="[/blog/post]">[The post's title]</a></h3>
35
+ <p>[One or two lines on what the reader gets from it.]</p>
36
+ <p class="gw-article-meta"><span><time datetime="[2026-10-25]">[Oct 25, 2026]</time></span><span>[5] min read</span></p>
37
+ </article>
38
+ </li>
39
+ <li>
40
+ <article class="gw-card gw-post">
41
+ <div class="gw-card__media"><img src="[/img/post.webp]" alt="" width="[640]" height="[360]" loading="lazy"></div>
42
+ <p class="gw-label">[Category]</p>
43
+ <h3 class="gw-post__title"><a href="[/blog/post]">[The post's title]</a></h3>
44
+ <p>[One or two lines on what the reader gets from it.]</p>
45
+ <p class="gw-article-meta"><span><time datetime="[2026-10-25]">[Oct 25, 2026]</time></span><span>[5] min read</span></p>
46
+ </article>
47
+ </li>
48
+ </ul>
49
+ <nav class="gw-pagination" aria-label="[Blog pages]">
50
+ <a class="gw-btn gw-btn--secondary" href="[/blog?page=1]" rel="prev">[Newer posts]</a>
51
+ <p class="gw-pagination__status">[Page 2 of 12]</p>
52
+ <a class="gw-btn gw-btn--secondary" href="[/blog?page=3]" rel="next">[Older posts]</a>
53
+ </nav>
54
+ </div>
55
+ </section>
56
+
57
+ <!-- the compact list: rows, no picture -->
58
+ <ul class="gw-post-list gw-post-list--compact" role="list">
59
+ <li>
60
+ <article class="gw-card gw-post">
61
+ <h3 class="gw-post__title"><a href="[/blog/post]">[The post's title]</a></h3>
62
+ <p class="gw-article-meta"><span>[Category]</span><span><time datetime="[2026-10-25]">[Oct 25, 2026]</time></span><span>[5] min read</span></p>
63
+ </article>
64
+ </li>
65
+ </ul>