@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/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.2.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
- - Another brand: change only the accent family, wash hues, logo and favicon (`KIT/docs/theming.md`).
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": 1,
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. Ink, not the accent.",
62
- "accent": "Links, focus rings, active state, a chart line, the 8px label square. Never a button fill, never body text.",
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
- "card": 12,
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)",
@@ -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. Below 1080px the nav and Sign in hide and the menu button opens #gw-mobile-menu,
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.
@@ -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.2.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
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, ink fill (never the accent) */
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 8, a hairline between options, the chosen one on the accent tint,
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 12, the dashboard's resting shadow (Chris 24/10/2026: one language with the
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, radius 20,
782
- the raised shadow (it is a popover), 32px inside. The bridge (::before) keeps the pointer inside while it crosses
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: 20px;
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; a block scrolls inside its own box */
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),