@gemboss/website-ui 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +52 -23
- package/CHANGELOG.md +63 -0
- package/README.md +45 -15
- package/bin/lib/check.mjs +76 -7
- package/bin/lib/contrast.mjs +67 -0
- package/css/components.css +8 -6
- package/css/header.css +37 -5
- package/css/prose.css +3 -2
- package/css/tokens.css +19 -0
- package/demo/brand-profiles.html +199 -0
- package/docs/components.md +56 -11
- package/docs/markup-contract.md +106 -18
- package/docs/philosophy.md +14 -9
- package/docs/theming.md +85 -29
- package/js/website.js +222 -168
- package/js/website.mjs +269 -0
- package/package.json +2 -1
- package/skill/SKILL.md +5 -1
- package/system.json +31 -7
- package/templates/header.html +3 -1
- package/templates/page.html +2 -0
- package/website.css +68 -14
- package/website.min.css +1 -1
package/js/website.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
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.4.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, 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
|
+
function each(ctx, sel, kind, fn) {
|
|
31
|
+
within(sel, ctx).forEach(function (el) {
|
|
32
|
+
if (live.some(function (r) { return r.el === el && r.kind === kind; })) return;
|
|
33
|
+
var offs = [], r = fn(el, function (t, type, f, opt) {
|
|
34
|
+
on(t, type, f, opt);
|
|
35
|
+
offs.push(function () { t.removeEventListener(type, f, opt || false); });
|
|
36
|
+
});
|
|
37
|
+
if (!r) return offs.forEach(function (f) { f(); });
|
|
38
|
+
r.el = el; r.kind = kind; r.offs = offs;
|
|
39
|
+
mark(el, kind, true);
|
|
40
|
+
live.push(r);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function mark(el, kind, add) {
|
|
44
|
+
var l = attr(el, READY, '').split(' ').filter(function (k) { return k && k !== kind; });
|
|
45
|
+
if (add) l.push(kind);
|
|
46
|
+
if (l.length) el.setAttribute(READY, l.join(' ')); else el.removeAttribute(READY);
|
|
47
|
+
}
|
|
48
|
+
function drop(r) {
|
|
49
|
+
if (r.stop) r.stop();
|
|
50
|
+
r.offs.forEach(function (f) { f(); });
|
|
51
|
+
mark(r.el, r.kind);
|
|
52
|
+
live.splice(live.indexOf(r), 1);
|
|
53
|
+
}
|
|
54
|
+
function records(kind, fn) { live.slice().forEach(function (r) { if (r.kind === kind) fn(r); }); }
|
|
55
|
+
|
|
56
|
+
// Mega menu. Opened by hover then clicked, a panel stays open: the click meant "open". One open on the page, across
|
|
57
|
+
// every header init() wired. Each change fires gw:mega on the trigger.
|
|
58
|
+
function setMega(m, show, mode) {
|
|
59
|
+
m.p.hidden = !show;
|
|
60
|
+
m.t.setAttribute('aria-expanded', String(show));
|
|
61
|
+
m.item.classList.toggle('is-open', show);
|
|
62
|
+
m.mode = mode;
|
|
63
|
+
megaOpen = show ? m : megaOpen === m ? null : megaOpen;
|
|
64
|
+
emit(m.t, 'gw:mega', { id: m.p.id, open: show });
|
|
65
|
+
}
|
|
66
|
+
function showMega(m, mode) {
|
|
67
|
+
clearTimeout(megaTimer);
|
|
68
|
+
if (megaOpen === m) { if (mode === 'click') m.mode = mode; return; }
|
|
69
|
+
if (megaOpen) setMega(megaOpen, false);
|
|
70
|
+
setMega(m, true, mode);
|
|
71
|
+
}
|
|
72
|
+
function hideMega(focus) {
|
|
73
|
+
var m = megaOpen;
|
|
74
|
+
clearTimeout(megaTimer);
|
|
75
|
+
if (!m) return;
|
|
76
|
+
setMega(m, false);
|
|
77
|
+
if (focus === true) m.t.focus();
|
|
78
|
+
}
|
|
79
|
+
function inside(m, el) { return !!el && (m.item.contains(el) || m.p.contains(el)); }
|
|
80
|
+
function mega(ctx) {
|
|
81
|
+
each(ctx, '[data-gw-mega]', 'mega', function (t, on) {
|
|
82
|
+
var p = doc.getElementById(attr(t, 'data-gw-mega', attr(t, 'aria-controls', '')));
|
|
83
|
+
if (!p) return;
|
|
84
|
+
var m = { t: t, p: p, item: t.closest('.gw-nav__item') || t.parentNode };
|
|
85
|
+
on(t, 'click', function () { if (megaOpen === m && m.mode !== 'hover') hideMega(); else showMega(m, 'click'); });
|
|
86
|
+
(m.item.contains(p) ? [m.item] : [m.item, p]).forEach(function (el) {
|
|
87
|
+
on(el, 'mouseenter', function () {
|
|
88
|
+
if (fine.matches) { clearTimeout(megaTimer); if (megaOpen !== m) megaTimer = setTimeout(function () { showMega(m, 'hover'); }, 80); }
|
|
89
|
+
});
|
|
90
|
+
on(el, 'mouseleave', function () {
|
|
91
|
+
if (fine.matches) { clearTimeout(megaTimer); if (megaOpen === m && m.mode === 'hover') megaTimer = setTimeout(hideMega, 160); }
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
on(m.item, 'focusout', function (e) { if (megaOpen === m && e.relatedTarget && !inside(m, e.relatedTarget)) hideMega(); });
|
|
95
|
+
on(p, 'click', function (e) { if (link(e)) hideMega(); });
|
|
96
|
+
m.stop = function () { if (megaOpen === m) hideMega(); };
|
|
97
|
+
return m;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Mobile menu. Labels: data-gw-label-open/-close, else the markup's aria-label, else English. A link inside closes it
|
|
102
|
+
// (same-page anchors do not reload). Taken back while open, it closes first: it must not keep the page locked.
|
|
103
|
+
function mobile(ctx) {
|
|
104
|
+
each(ctx, '[data-gw-menu-toggle]', 'menu', function (btn, on) {
|
|
105
|
+
var menu = doc.getElementById(attr(btn, 'aria-controls', 'gw-mobile-menu'));
|
|
106
|
+
if (!menu) return;
|
|
107
|
+
var label = [attr(btn, 'data-gw-label-open', attr(btn, 'aria-label', 'Open menu')), attr(btn, 'data-gw-label-close', 'Close menu')];
|
|
108
|
+
var r = {
|
|
109
|
+
open: function () { return !menu.hidden; },
|
|
110
|
+
set: function (o, focus) {
|
|
111
|
+
menu.hidden = !o;
|
|
112
|
+
btn.setAttribute('aria-expanded', String(o));
|
|
113
|
+
btn.setAttribute('aria-label', label[+o]);
|
|
114
|
+
root.classList.toggle('is-menu-open', o);
|
|
115
|
+
if (focus) btn.focus();
|
|
116
|
+
},
|
|
117
|
+
stop: function () { if (r.open()) r.set(false); }
|
|
118
|
+
};
|
|
119
|
+
on(btn, 'click', function () { r.set(!r.open()); });
|
|
120
|
+
on(menu, 'click', function (e) { if (link(e)) r.set(false); });
|
|
121
|
+
return r;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function closeMenus(focus) { records('menu', function (r) { if (r.open()) r.set(false, focus); }); }
|
|
125
|
+
|
|
126
|
+
// Theme toggle. Labels: data-gw-label-dark/-light, else the markup's aria-label, else English. A press fires gw:theme
|
|
127
|
+
// on <html>; so does a change of the system's scheme while the page follows it.
|
|
128
|
+
function dark() { var t = root.getAttribute('data-theme'); return t ? t === 'dark' : sys.matches; }
|
|
129
|
+
function relabel() { records('theme', function (r) { r.el.setAttribute('aria-label', r.labels[+dark()]); }); }
|
|
130
|
+
function theme(ctx) {
|
|
131
|
+
each(ctx, '[data-gw-theme-toggle]', 'theme', function (b, on) {
|
|
132
|
+
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')] };
|
|
133
|
+
on(b, 'click', function () {
|
|
134
|
+
var next = dark() ? 'light' : 'dark';
|
|
135
|
+
root.setAttribute('data-theme', next);
|
|
136
|
+
try { localStorage.setItem('gw-theme', next); } catch (e) { /* blocked */ }
|
|
137
|
+
relabel();
|
|
138
|
+
emit(root, 'gw:theme', { theme: next });
|
|
139
|
+
});
|
|
140
|
+
// taken back: the markup's own label again, so a later init reads the same words
|
|
141
|
+
r.stop = function () { b.setAttribute('aria-label', r.labels[0]); };
|
|
142
|
+
return r;
|
|
143
|
+
});
|
|
144
|
+
relabel();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Reveal at 15% visible, once. Failsafe: shown 2.5s after the init that found them, or at once without
|
|
148
|
+
// IntersectionObserver or under reduced motion.
|
|
149
|
+
function reveal(ctx) {
|
|
150
|
+
var els = [], io = null;
|
|
151
|
+
each(ctx, '.gw-reveal', 'reveal', function (el) { els.push(el); return { stop: function () { if (io) io.unobserve(el); } }; });
|
|
152
|
+
function showAll() { els.forEach(function (el) { el.classList.add('is-in'); }); if (io) io.disconnect(); }
|
|
153
|
+
if (!els.length) return;
|
|
154
|
+
if (!window.IntersectionObserver || mq('(prefers-reduced-motion: reduce)').matches) return showAll();
|
|
155
|
+
io = new IntersectionObserver(function (list) {
|
|
156
|
+
list.forEach(function (en) { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } });
|
|
157
|
+
}, { threshold: 0.15 });
|
|
158
|
+
els.forEach(function (el) { io.observe(el); });
|
|
159
|
+
setTimeout(showAll, 2500);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Header: .is-scrolled past 8px, read once per frame (one scroll listener for every header).
|
|
163
|
+
function scrolled() {
|
|
164
|
+
var s = window.scrollY > 8;
|
|
165
|
+
queued = false;
|
|
166
|
+
records('header', function (r) { if (r.s !== s) r.el.classList.toggle('is-scrolled', (r.s = s)); });
|
|
167
|
+
}
|
|
168
|
+
function header(ctx) {
|
|
169
|
+
each(ctx, '[data-gw-header]', 'header', function (h) { return { s: false, stop: function () { h.classList.remove('is-scrolled'); } }; });
|
|
170
|
+
scrolled();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// aria-current="page" on nav links to this path, unless the markup marked one. The script's own marks are taken
|
|
174
|
+
// back first, so an init() after a client-side navigation moves them.
|
|
175
|
+
function current(ctx) {
|
|
176
|
+
var links = within('.gw-nav__link[href], .gw-mega__link[href]', ctx);
|
|
177
|
+
within('#gw-mobile-menu, .gw-mobile-menu', ctx).forEach(function (m) { links = links.concat(all('a[href]', m)); });
|
|
178
|
+
function norm(p) { return p.replace(/\/index\.html?$/, '/').replace(/\.html?$/, '').replace(/\/+$/, '') || '/'; }
|
|
179
|
+
links.forEach(function (a) { if (mine.has(a)) { mine.delete(a); a.removeAttribute('aria-current'); } });
|
|
180
|
+
if (links.some(function (a) { return a.hasAttribute('aria-current'); })) return;
|
|
181
|
+
links.forEach(function (a) {
|
|
182
|
+
if (a.classList.contains('gw-btn') || a.hash || a.host !== location.host) return;
|
|
183
|
+
if (norm(a.pathname) === norm(location.pathname)) { a.setAttribute('aria-current', 'page'); mine.add(a); }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Segmented control: a radio group, one Tab stop; arrows (Home, End) move and choose. What one option shows carries
|
|
188
|
+
// data-gw-show="group:value" and is hidden unless that option is chosen; without the script the markup's own choice
|
|
189
|
+
// shows. A choice made by the reader fires gw:segmented on the group; wiring it does not.
|
|
190
|
+
function segmented(ctx) {
|
|
191
|
+
each(ctx, '[data-gw-segmented]', 'segmented', function (g, on) {
|
|
192
|
+
var name = attr(g, 'data-gw-segmented', ''), opts = all('[role="radio"]', g);
|
|
193
|
+
if (!opts.length) return;
|
|
194
|
+
function pick(o, focus, said) {
|
|
195
|
+
var v = attr(o, 'value', '');
|
|
196
|
+
opts.forEach(function (x) { x.setAttribute('aria-checked', String(x === o)); x.setAttribute('tabindex', x === o ? '0' : '-1'); });
|
|
197
|
+
if (focus) o.focus();
|
|
198
|
+
all('[data-gw-show]').forEach(function (el) {
|
|
199
|
+
var own = attr(el, 'data-gw-show', '').split(/\s+/).filter(function (t) { return t.split(':')[0] === name; });
|
|
200
|
+
if (own.length) el.hidden = own.indexOf(name + ':' + v) < 0;
|
|
201
|
+
});
|
|
202
|
+
if (said) emit(g, 'gw:segmented', { name: name, value: v });
|
|
203
|
+
}
|
|
204
|
+
opts.forEach(function (o, i) {
|
|
205
|
+
on(o, 'click', function () { pick(o, false, true); });
|
|
206
|
+
on(o, 'keydown', function (e) {
|
|
207
|
+
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];
|
|
208
|
+
if (k === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
pick(opts[k % n], true, true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
pick(opts.filter(function (o) { return attr(o, 'aria-checked', '') === 'true'; })[0] || opts[0]);
|
|
214
|
+
return {};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Once per page: what belongs to the document, the window or a media query rather than to one element.
|
|
219
|
+
function install() {
|
|
220
|
+
doc = document; root = doc.documentElement; mine = new WeakSet();
|
|
221
|
+
fine = mq('(hover: hover) and (pointer: fine)'); sys = mq('(prefers-color-scheme: dark)');
|
|
222
|
+
var wide = mq('(min-width: 1080px)'), stored = null;
|
|
223
|
+
on(doc, 'click', function (e) { if (megaOpen && !inside(megaOpen, e.target)) hideMega(); });
|
|
224
|
+
on(doc, 'keydown', function (e) { if (megaOpen && esc(e)) hideMega(inside(megaOpen, doc.activeElement) || megaOpen.mode === 'click'); });
|
|
225
|
+
on(doc, 'keydown', function (e) { if (esc(e)) closeMenus(true); });
|
|
226
|
+
watch(wide, function () { if (wide.matches) closeMenus(); });
|
|
227
|
+
on(window, 'pageshow', function (e) { if (e.persisted) closeMenus(); });
|
|
228
|
+
try { stored = localStorage.getItem('gw-theme'); } catch (e) { /* blocked */ }
|
|
229
|
+
if (!root.hasAttribute('data-theme') && /^(light|dark)$/.test(stored)) root.setAttribute('data-theme', stored);
|
|
230
|
+
watch(sys, function () { relabel(); if (!root.hasAttribute('data-theme')) emit(root, 'gw:theme', { theme: dark() ? 'dark' : 'light' }); });
|
|
231
|
+
on(window, 'scroll', function () { if (!queued) { queued = true; requestAnimationFrame(scrolled); } }, { passive: true });
|
|
232
|
+
// A busy button (aria-busy="true") ignores presses, by mouse or keyboard: no double submit. It keeps its focus and
|
|
233
|
+
// name. One listener on the document, in the capture phase, so it covers buttons inserted later too.
|
|
234
|
+
on(doc, 'click', function (e) {
|
|
235
|
+
var b = e.target.closest && e.target.closest('.gw-btn[aria-busy="true"]');
|
|
236
|
+
if (b) { e.preventDefault(); e.stopPropagation(); }
|
|
237
|
+
}, true);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function run(fns, ctx) {
|
|
241
|
+
fns.forEach(function (f) { try { f(ctx); } catch (e) { console.error('[gw-website]', e); } });
|
|
242
|
+
}
|
|
243
|
+
// One copy of the kit owns a page (window.gwWebsite). When another one got there first (the classic script, a second
|
|
244
|
+
// bundle), it does the work, so nothing is wired twice.
|
|
245
|
+
function other() { var g = typeof window != 'undefined' && window.gwWebsite; return g && g !== api && g; }
|
|
246
|
+
|
|
247
|
+
/** Wires the kit's markup under root (default: the whole document). Safe to call again: wires only what is new. */
|
|
248
|
+
function init(ctx) {
|
|
249
|
+
// server rendering: nothing to wire
|
|
250
|
+
if (typeof document == 'undefined') return;
|
|
251
|
+
var g = other();
|
|
252
|
+
if (g) return g.init && g.init(ctx);
|
|
253
|
+
window.gwWebsite = api;
|
|
254
|
+
if (!doc) run([install]);
|
|
255
|
+
// an element removed from the page since the last init is let go
|
|
256
|
+
live.slice().forEach(function (r) { if (r.el.isConnected === false) drop(r); });
|
|
257
|
+
run([mega, mobile, theme, reveal, header, current, segmented], ctx && ctx.querySelectorAll ? ctx : doc);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Takes back what init() wired under root (default: the whole document): listeners and data-gw-ready marks. */
|
|
261
|
+
function destroy(ctx) {
|
|
262
|
+
var g = other();
|
|
263
|
+
if (g) return g.destroy && g.destroy(ctx);
|
|
264
|
+
if (!doc) return;
|
|
265
|
+
ctx = ctx && ctx.querySelectorAll ? ctx : doc;
|
|
266
|
+
live.slice().forEach(function (r) { if (ctx === doc || ctx.contains(r.el)) drop(r); });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export { version, init, destroy };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemboss/website-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
@@ -20,7 +20,11 @@ the kit and its rules are how the page ends up looking designed. Compose from th
|
|
|
20
20
|
- Changed page: keep its kit classes; change words, order and choice of sections.
|
|
21
21
|
- Site CSS only for what the kit cannot know (an illustration, a product mock-up), with the kit's tokens and
|
|
22
22
|
scales, classes in the site's own prefix. Never style a `.gw-*` class, never edit `node_modules`.
|
|
23
|
-
-
|
|
23
|
+
- No JS of your own for kit parts: markup inserted after load (a route change, a block) gets
|
|
24
|
+
`gwWebsite.init(root)`, or `init(root)` from `website.mjs` (`KIT/docs/markup-contract.md`, "Script").
|
|
25
|
+
- Another brand: choose only the accent family, wash hues, logo and favicon, and the three named profiles
|
|
26
|
+
(`data-gw-action` and `data-gw-radius` on `<html>`, `.gw-header--float`), never free values
|
|
27
|
+
(`KIT/docs/theming.md`).
|
|
24
28
|
4. **Words**: real facts only. A missing price, number, name or date becomes a visible `[PLACEHOLDER]` and a
|
|
25
29
|
question to the person. Made-up numbers in a drawn mock-up get `<p class="gw-window__caption">Illustrative
|
|
26
30
|
data.</p>` in its window. No em dash in visible text, no "Learn more" as a whole link.
|
package/system.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The GemBoss website system in one file. css/tokens.css, the docs and `gemboss-website check` all read these values: change a value here, never in one of them alone. Values measured on gemboss.ai and the GemBoss dashboard (DESIGN-LANGUAGE.md of gemboss-www, 15/10/2026).",
|
|
3
|
-
"version":
|
|
3
|
+
"version": 2,
|
|
4
4
|
"color": {
|
|
5
5
|
"light": {
|
|
6
6
|
"bg": "#ffffff",
|
|
@@ -58,18 +58,16 @@
|
|
|
58
58
|
"mint": "#1fcf9f"
|
|
59
59
|
},
|
|
60
60
|
"roles": {
|
|
61
|
-
"action": "The thing you click
|
|
62
|
-
"accent": "Links, focus rings, active state, a chart line, the 8px label square. Never a
|
|
61
|
+
"action": "The thing you click: ink, or the brand's accent when the site chose the accent action profile (profiles.action).",
|
|
62
|
+
"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
63
|
"success/warning": "State only.",
|
|
64
64
|
"wash": "The hero wash and illustrations only."
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"radius": {
|
|
68
|
-
"scale": [0, 4, 6, 8, 12, 16, 20, 999],
|
|
69
|
-
"action": 8,
|
|
68
|
+
"scale": [0, 4, 6, 8, 12, 16, 20, 24, 999],
|
|
70
69
|
"chip": 999,
|
|
71
|
-
"
|
|
72
|
-
"panel": 16
|
|
70
|
+
"$comment": "Chips stay pills in every profile. The radii of actions, cards, panels and popovers come from the site's radius profile (profiles.radius): components read --gw-radius-action|card|panel|popover, never a number."
|
|
73
71
|
},
|
|
74
72
|
"shadow": {
|
|
75
73
|
"rest": "0 1px 2px rgba(31,30,29,.04), 0 2px 6px rgba(31,30,29,.06)",
|
|
@@ -112,6 +110,32 @@
|
|
|
112
110
|
"button_height": 40,
|
|
113
111
|
"tap_target_min": 44
|
|
114
112
|
},
|
|
113
|
+
"profiles": {
|
|
114
|
+
"$comment": "A brand's three knobs (Chris 25/10/2026, issue #61): each one a named profile, chosen once per site, never free values. The first option is the default and needs no markup. A brand chooses these, its accent family, its wash hues and its logo, and nothing else (docs/theming.md); check reports an unknown name.",
|
|
115
|
+
"action": {
|
|
116
|
+
"$comment": "What fills the primary .gw-btn: data-gw-action on <html>. A mapping of roles, not a colour: accent fills the button with the brand's own accent family. The build (with the kit's colours) and check (with the brand's) require on-action to reach 4.5:1 on action and on action-hover, in light and in dark.",
|
|
117
|
+
"attribute": "data-gw-action",
|
|
118
|
+
"options": {
|
|
119
|
+
"ink": { "$comment": "GemBoss, as its dashboard: the thing you click is ink", "action": "action", "action-hover": "action-hover", "on-action": "on-action" },
|
|
120
|
+
"accent": { "$comment": "a brand whose product's primary buttons are its accent (GemPages)", "action": "accent", "action-hover": "accent-strong", "on-action": "on-accent" }
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"radius": {
|
|
124
|
+
"$comment": "The shapes: data-gw-radius on <html>. popover is the mega menu, the one floating card under the bar.",
|
|
125
|
+
"attribute": "data-gw-radius",
|
|
126
|
+
"options": {
|
|
127
|
+
"tight": { "$comment": "GemBoss, as its dashboard", "action": 8, "card": 12, "panel": 16, "popover": 20 },
|
|
128
|
+
"soft": { "$comment": "GemPages (gempages.net measured 25/10/2026: buttons 12, cards 16 to 24, panels 24)", "action": 12, "card": 20, "panel": 24, "popover": 24 }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"header": {
|
|
132
|
+
"$comment": "The header's shape: a class next to gw-header on the <header>. float is an inset card from 1080px (the panel radius of the site's profile, the surface at `surface` percent over a `blur` px blur, one hairline, the raised shadow, `gap` px from the top); below 1080px, and so for the phone menu, it is the flat bar. Solid under prefers-reduced-transparency and where the browser cannot blur.",
|
|
133
|
+
"options": {
|
|
134
|
+
"flat": { "$comment": "a flat 64px bar with one hairline", "class": "gw-header" },
|
|
135
|
+
"float": { "class": "gw-header--float", "gap": 16, "surface": 85, "blur": 10 }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
115
139
|
"motion": {
|
|
116
140
|
"properties": ["transform", "opacity"],
|
|
117
141
|
"ease": "cubic-bezier(.22,1,.36,1)",
|
package/templates/header.html
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
Header: one per site, the same on every page. Keep it in one partial or component, never copied per page.
|
|
3
|
-
A flat 64px bar with one hairline.
|
|
3
|
+
A flat 64px bar with one hairline. A brand that chose the floating header adds one class to the <header>:
|
|
4
|
+
class="gw-header gw-header--float" (an inset card from 1080px, docs/theming.md); nothing else changes.
|
|
5
|
+
Below 1080px the nav and Sign in hide and the menu button opens #gw-mobile-menu,
|
|
4
6
|
which repeats the same links. website.js runs the mega menus, the mobile menu and the theme toggle.
|
|
5
7
|
Limits: at most five top-level items, one .gw-btn (the primary action), one .gw-btn--secondary. Each mega panel:
|
|
6
8
|
two or three columns of four or five links, at most one feature card. The announcement bar is optional: one sentence.
|
package/templates/page.html
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
then link the site's own CSS after the kit. Fill the header and footer once, in a shared partial.
|
|
6
6
|
One <h1> per page (in the hero), sections in the order the page needs, then delete every unused part.
|
|
7
7
|
Replace every [bracketed] placeholder. No lorem ipsum, no invented facts.
|
|
8
|
+
A brand other than GemBoss may add its profiles to <html>, once, here: data-gw-action="accent" (the primary button
|
|
9
|
+
takes the accent) and data-gw-radius="soft" (rounder corners). See docs/theming.md.
|
|
8
10
|
-->
|
|
9
11
|
<html lang="en">
|
|
10
12
|
<head>
|
package/website.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @gemboss/website-ui 0.
|
|
1
|
+
/*! @gemboss/website-ui 0.4.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
|
|
2
2
|
/* @gemboss/website-ui tokens. GENERATED from system.json by scripts/build.mjs: do not edit by hand. */
|
|
3
3
|
:root {
|
|
4
4
|
color-scheme: light;
|
|
@@ -47,11 +47,13 @@
|
|
|
47
47
|
--gw-radius-12: 12px;
|
|
48
48
|
--gw-radius-16: 16px;
|
|
49
49
|
--gw-radius-20: 20px;
|
|
50
|
+
--gw-radius-24: 24px;
|
|
50
51
|
--gw-radius-999: 999px;
|
|
51
52
|
--gw-radius-action: 8px;
|
|
52
53
|
--gw-radius-chip: 999px;
|
|
53
54
|
--gw-radius-card: 12px;
|
|
54
55
|
--gw-radius-panel: 16px;
|
|
56
|
+
--gw-radius-popover: 20px;
|
|
55
57
|
--gw-shadow: 0 1px 2px rgba(31,30,29,.04), 0 2px 6px rgba(31,30,29,.06);
|
|
56
58
|
--gw-shadow-sm: 0 1px 2px rgba(31,30,29,.04);
|
|
57
59
|
--gw-shadow-raised: 0 4px 16px rgba(31,30,29,.10);
|
|
@@ -59,6 +61,9 @@
|
|
|
59
61
|
--gw-wrap-narrow: 780px;
|
|
60
62
|
--gw-gutter: 22px;
|
|
61
63
|
--gw-header-height: 64px;
|
|
64
|
+
--gw-header-gap: 16px;
|
|
65
|
+
--gw-header-surface: 85%;
|
|
66
|
+
--gw-header-blur: 10px;
|
|
62
67
|
--gw-button-height: 40px;
|
|
63
68
|
--gw-tap-min: 44px;
|
|
64
69
|
--gw-ease: cubic-bezier(.22,1,.36,1);
|
|
@@ -199,6 +204,20 @@
|
|
|
199
204
|
--gw-success-soft: #132a22;
|
|
200
205
|
--gw-warning: #e0b341;
|
|
201
206
|
}
|
|
207
|
+
/* action profile "accent": data-gw-action="accent" on <html>. In night bands too, where the dark palette applies. */
|
|
208
|
+
:root[data-gw-action="accent"],
|
|
209
|
+
:root[data-gw-action="accent"] :is(.gw-band--night, .gw-showcase) {
|
|
210
|
+
--gw-action: var(--gw-accent);
|
|
211
|
+
--gw-action-hover: var(--gw-accent-strong);
|
|
212
|
+
--gw-on-action: var(--gw-on-accent);
|
|
213
|
+
}
|
|
214
|
+
/* radius profile "soft": data-gw-radius="soft" on <html> */
|
|
215
|
+
:root[data-gw-radius="soft"] {
|
|
216
|
+
--gw-radius-action: 12px;
|
|
217
|
+
--gw-radius-card: 20px;
|
|
218
|
+
--gw-radius-panel: 24px;
|
|
219
|
+
--gw-radius-popover: 24px;
|
|
220
|
+
}
|
|
202
221
|
|
|
203
222
|
/* base: reset, type, links, focus, skip link, reveal. Colours, radii and shadows only from tokens. */
|
|
204
223
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -370,7 +389,9 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
370
389
|
|
|
371
390
|
/* components: buttons, links, chips, badges, cards, windows, icons, lists, tables, FAQ. */
|
|
372
391
|
|
|
373
|
-
/* actions are rectangles: 40px, radius 8, 14px/600, sentence case
|
|
392
|
+
/* actions are rectangles: 40px, the action radius of the site's profile (8, 12 in soft), 14px/600, sentence case.
|
|
393
|
+
The fill is the action colour: ink, or the brand's accent under data-gw-action="accent" (tokens.css). Never a
|
|
394
|
+
gradient, a wash hue or a shadow. */
|
|
374
395
|
.gw-btn {
|
|
375
396
|
position: relative;
|
|
376
397
|
display: inline-flex;
|
|
@@ -434,7 +455,7 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
434
455
|
@media (prefers-reduced-motion: reduce) {
|
|
435
456
|
.gw-btn[aria-busy="true"]::before {
|
|
436
457
|
border: 0;
|
|
437
|
-
border-radius: 0;
|
|
458
|
+
border-radius: var(--gw-radius-0);
|
|
438
459
|
background:
|
|
439
460
|
radial-gradient(circle at 3px 50%, currentColor 1.5px, transparent 2px),
|
|
440
461
|
radial-gradient(circle at 8px 50%, currentColor 1.5px, transparent 2px),
|
|
@@ -480,8 +501,8 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
480
501
|
}
|
|
481
502
|
|
|
482
503
|
/* segmented control: one choice out of two to four (Monthly / Yearly), a radio group of buttons. The dashboard's look
|
|
483
|
-
(@gemboss/ui .ui-seg): one hairline group, radius
|
|
484
|
-
so it follows a brand's accent. The weight never changes with the choice: the labels do not move. */
|
|
504
|
+
(@gemboss/ui .ui-seg): one hairline group, the action radius, a hairline between options, the chosen one on the
|
|
505
|
+
accent tint, so it follows a brand's accent. The weight never changes with the choice: the labels do not move. */
|
|
485
506
|
.gw-segmented {
|
|
486
507
|
display: inline-flex;
|
|
487
508
|
max-width: 100%;
|
|
@@ -540,8 +561,8 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
540
561
|
.gw-badge--success::before, .gw-badge--warning::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: var(--gw-radius-chip); background: var(--gw-success); }
|
|
541
562
|
.gw-badge--warning::before { background: var(--gw-warning); }
|
|
542
563
|
|
|
543
|
-
/* cards sit: white, 1px hairline, radius
|
|
544
|
-
app); no hover lift. A soft card is a tint and carries no shadow. */
|
|
564
|
+
/* cards sit: white, 1px hairline, the card radius of the site's profile (12, 20 in soft), the dashboard's resting
|
|
565
|
+
shadow (Chris 24/10/2026: one language with the app); no hover lift. A soft card is a tint and carries no shadow. */
|
|
545
566
|
.gw-card {
|
|
546
567
|
padding: 24px;
|
|
547
568
|
border: 1px solid var(--gw-line);
|
|
@@ -694,7 +715,8 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
694
715
|
}
|
|
695
716
|
|
|
696
717
|
/* header: announcement, a flat 64px bar with one hairline, mega menu, mobile menu below 1080px.
|
|
697
|
-
Not a floating pill: no radius, no blur, no shadow on the bar. Nothing tucks under it.
|
|
718
|
+
Not a floating pill: no radius, no blur, no shadow on the bar. Nothing tucks under it. A brand may choose the one
|
|
719
|
+
other shape, .gw-header--float (an inset card from 1080px, at the end of this file). */
|
|
698
720
|
.gw-announce {
|
|
699
721
|
padding: 8px var(--gw-gutter);
|
|
700
722
|
border-bottom: 1px solid var(--gw-line);
|
|
@@ -778,9 +800,9 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
778
800
|
text-underline-offset: 8px;
|
|
779
801
|
}
|
|
780
802
|
|
|
781
|
-
/* mega menu: a floating card centred under the bar, as on gemboss.ai (Chris 24/10/2026): one hairline,
|
|
782
|
-
the raised shadow (it is a popover), 32px inside. The bridge
|
|
783
|
-
the 8px gap from the trigger. */
|
|
803
|
+
/* mega menu: a floating card centred under the bar, as on gemboss.ai (Chris 24/10/2026): one hairline, the popover
|
|
804
|
+
radius of the site's profile (20, 24 in soft), the raised shadow (it is a popover), 32px inside. The bridge
|
|
805
|
+
(::before) keeps the pointer inside while it crosses the 8px gap from the trigger. */
|
|
784
806
|
.gw-mega {
|
|
785
807
|
position: absolute;
|
|
786
808
|
top: calc(100% + 8px);
|
|
@@ -790,7 +812,7 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
790
812
|
overflow-y: auto;
|
|
791
813
|
transform: translateX(-50%);
|
|
792
814
|
border: 1px solid var(--gw-line);
|
|
793
|
-
border-radius:
|
|
815
|
+
border-radius: var(--gw-radius-popover);
|
|
794
816
|
background: var(--gw-surface);
|
|
795
817
|
box-shadow: var(--gw-shadow-raised);
|
|
796
818
|
}
|
|
@@ -925,6 +947,37 @@ html.is-menu-open .gw-header { position: fixed; top: 0; left: 0; right: 0; }
|
|
|
925
947
|
.gw-theme-toggle, .gw-menu-toggle { width: var(--gw-tap-min); height: var(--gw-tap-min); }
|
|
926
948
|
}
|
|
927
949
|
|
|
950
|
+
/* floating header (0.3.0), a brand's header profile: <header class="gw-header gw-header--float">. The same bar, mega
|
|
951
|
+
menu and script, drawn from 1080px as an inset card: the panel radius of the site's profile (16, 24 in soft), the
|
|
952
|
+
surface at 85% over a 10px blur, one hairline, the raised shadow, a 16px gap above it and when it sticks. It is
|
|
953
|
+
wrap + 2 x 24px + its border wide, so the bar's content lines up with the page's from 1170px. Below 1080px it is
|
|
954
|
+
the flat bar, so the phone menu is unchanged. Solid for a visitor who asks for less transparency, and where the
|
|
955
|
+
browser cannot blur. No other floating or blurred header: it is a profile, not a style to copy. */
|
|
956
|
+
@media (min-width: 1080px) {
|
|
957
|
+
.gw-header--float {
|
|
958
|
+
top: var(--gw-header-gap);
|
|
959
|
+
width: min(100% - 32px, var(--gw-wrap) + 50px);
|
|
960
|
+
margin: var(--gw-header-gap) auto 0;
|
|
961
|
+
border: 1px solid var(--gw-line);
|
|
962
|
+
border-radius: var(--gw-radius-panel);
|
|
963
|
+
background: color-mix(in srgb, var(--gw-surface) var(--gw-header-surface), transparent);
|
|
964
|
+
-webkit-backdrop-filter: blur(var(--gw-header-blur));
|
|
965
|
+
backdrop-filter: blur(var(--gw-header-blur));
|
|
966
|
+
box-shadow: var(--gw-shadow-raised);
|
|
967
|
+
}
|
|
968
|
+
.gw-header--float.is-scrolled { border-color: var(--gw-line-strong); }
|
|
969
|
+
.gw-header--float > .gw-header__bar { width: auto; padding-inline: 24px; }
|
|
970
|
+
.gw-header--float .gw-mega { max-height: calc(100vh - var(--gw-header-height) - var(--gw-header-gap) - 24px); }
|
|
971
|
+
/* an anchor lands below the card, not under it */
|
|
972
|
+
:root:has(.gw-header--float) [id] { scroll-margin-top: calc(var(--gw-header-height) + var(--gw-header-gap) + 18px); }
|
|
973
|
+
}
|
|
974
|
+
@media (min-width: 1080px) and (prefers-reduced-transparency: reduce) {
|
|
975
|
+
.gw-header--float { background: var(--gw-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
976
|
+
}
|
|
977
|
+
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
978
|
+
.gw-header--float { background: var(--gw-surface); }
|
|
979
|
+
}
|
|
980
|
+
|
|
928
981
|
/* footer: brand block, link columns, bottom row. One hairline on top, white like the page. */
|
|
929
982
|
.gw-footer {
|
|
930
983
|
padding-block: 64px 40px;
|
|
@@ -1214,7 +1267,8 @@ html.is-menu-open .gw-header { position: fixed; top: 0; left: 0; right: 0; }
|
|
|
1214
1267
|
:where(.gw-prose) > hr { height: 0; border: 0; border-top: 1px solid var(--gw-line); }
|
|
1215
1268
|
:where(.gw-prose) :is(sup, sub) { line-height: 0; }
|
|
1216
1269
|
|
|
1217
|
-
/* code: inline, a tint chip that wraps on a phone
|
|
1270
|
+
/* code: inline, a tint chip that wraps on a phone (radius 6 in every profile: it follows the text, not the shapes);
|
|
1271
|
+
a block scrolls inside its own box */
|
|
1218
1272
|
:where(.gw-prose) :is(pre, code, kbd, samp) { font-family: var(--gw-font-mono); }
|
|
1219
1273
|
:where(.gw-prose) code {
|
|
1220
1274
|
padding: 2px 6px;
|
|
@@ -1242,7 +1296,7 @@ html.is-menu-open .gw-header { position: fixed; top: 0; left: 0; right: 0; }
|
|
|
1242
1296
|
line-height: 1.6;
|
|
1243
1297
|
tab-size: 2;
|
|
1244
1298
|
}
|
|
1245
|
-
:where(.gw-prose) pre code { padding: 0; border-radius: 0; background: none; font-size: inherit; overflow-wrap: normal; }
|
|
1299
|
+
:where(.gw-prose) pre code { padding: 0; border-radius: var(--gw-radius-0); background: none; font-size: inherit; overflow-wrap: normal; }
|
|
1246
1300
|
|
|
1247
1301
|
/* images and captions (an image inside a .gw-window keeps the window's square edges) */
|
|
1248
1302
|
:where(.gw-prose) > :is(img, video),
|