@gemboss/website-ui 0.2.0 → 0.3.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 CHANGED
@@ -15,18 +15,25 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
15
15
  | `templates/page.html` | the page skeleton: start every new page from it |
16
16
  | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose… |
17
17
  | `demo/index.html` | every component on one page: open it to see what exists before you build anything |
18
+ | `demo/brand-profiles.html` | a GemPages-style brand: its accent, the accent button, soft corners, the floating header |
18
19
  | `docs/components.md` | every class, what it is for, its variants |
19
- | `docs/theming.md` | how a brand other than GemBoss sets its accent and logo, and what it must not change |
20
+ | `docs/theming.md` | what a brand other than GemBoss chooses (accent, wash, logo, three named profiles), and what it must not change |
20
21
 
21
22
  ## 1. The ten rules
22
23
 
23
24
  1. **Compose, do not invent.** A page is the page skeleton plus kit sections plus your words. Look in `demo/` and
24
25
  `docs/components.md` first. A new class is a last resort (section 9).
25
- 2. **White canvas, ink actions, one accent.** The background is white (`--gw-bg`). The thing you click is near-black
26
- (`.gw-btn`). The accent colour is for links, focus, the active item, the 8px label square and a chart line. **The
27
- accent is never a button fill and never body text.**
28
- 3. **Actions are rectangles, chips are pills.** Buttons: radius 8px, 40px tall, sentence case, 14px/600. Chips,
29
- toggles, badges: fully rounded. Cards: radius 12px (16px for a large panel). No other radii.
26
+ 2. **White canvas, one action colour, one accent.** The background is white (`--gw-bg`). The thing you click
27
+ (`.gw-btn`) is near-black ink, or the brand's accent when the site chose the `accent` action profile
28
+ (`data-gw-action="accent"` on `<html>`, section 7): one colour for every primary button of the site, never chosen
29
+ per button. Why the choice (0.3.0): the site and the product speak one language, and GemPages' product clicks in
30
+ blue while GemBoss's clicks in ink. Otherwise the accent is for links, focus, the active item, the 8px label
31
+ square and a chart line. **The accent is never body text, never words in a heading, never a section band, and
32
+ never a fill you set yourself.**
33
+ 3. **Actions are rectangles, chips are pills.** Buttons: 40px tall, sentence case, 14px/600, the action radius of the
34
+ site's profile (8px; 12px in `soft`). Chips, toggles, badges: fully rounded. Cards: the card radius (12px; 20px in
35
+ `soft`), large panels the panel radius (16px; 24px). No other radii: the site picks its profile once
36
+ (`data-gw-radius`), components follow.
30
37
  4. **A card sits.** A card (`.gw-card`, a plan) is a white box with a 1px `--gw-line` border and the resting shadow
31
38
  `--gw-shadow`, the same as the GemBoss dashboard. It never lifts on hover. To separate anything else, use space or a
32
39
  hairline before a shadow. The only shadows are `--gw-shadow` (rest), `--gw-shadow-sm` and `--gw-shadow-raised`
@@ -66,6 +73,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
66
73
  it works; a hard-coded colour breaks it.
67
74
  - **Accent as text** (links, the active menu item, step numbers) is `--gw-accent-strong`; the plain accent is for
68
75
  the label square, the focus ring, check marks and borders. Night bands switch to the dark palette by themselves.
76
+ - **Brand profiles** (section 7): `data-gw-action` and `data-gw-radius` on `<html>`, `.gw-header--float` on the
77
+ header. Without them a page is GemBoss: ink buttons, tight corners, the flat header.
69
78
  - **Controls** are 40px tall on desktop and at least 44px on touch screens; `.gw-btn--sm` is 36px with a 46px hit area on desktop and 44px tall on touch screens.
70
79
  - **A button that waits** (a form sent, an audit started) gets `aria-busy="true"` from the page's script until the
71
80
  answer comes: the kit keeps its label and width, draws the ring and ignores a second press. Nothing else: no
@@ -85,16 +94,21 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
85
94
 
86
95
  ## 4. Don't (each of these has happened, and each one made a page look cheap or broke it)
87
96
 
88
- - Don't fill a button with the accent colour, a gradient or a wash hue.
97
+ - Don't fill a button yourself: not with the accent (that is the site's action profile, set once on `<html>`), a
98
+ gradient or a wash hue. No raised or inset shadow on a button.
99
+ - Don't colour the words of a heading with the accent or a gradient, and don't fill a section band with the accent.
89
100
  - Don't use uppercase labels with letter spacing ("FEATURES").
90
101
  - Don't add shadows beyond the three tokens, and don't lift cards on hover.
91
102
  - Don't round a button into a pill, and don't square a chip.
92
103
  - Don't use cream, beige or grey page backgrounds; white or `--gw-bg-soft` only.
93
- - Don't use colours, sizes, spacings or radii outside the tokens and scales.
94
- - Don't put a floating, blurred, rounded "pill" header on the page; the header is a flat bar with one hairline.
104
+ - Don't use colours, sizes, spacings or radii outside the tokens and scales, and don't redefine a kit token to get
105
+ another look: choose a profile.
106
+ - Don't build your own floating, blurred or "pill" header. The header is the kit's flat bar with one hairline, or
107
+ its inset card `.gw-header--float` when the brand chose it.
95
108
  - Don't tuck the hero under the header with a negative margin.
96
109
  - Don't use emoji as icons; use inline stroke SVG (1.7–2px stroke, `currentColor`, `aria-hidden="true"`).
97
- - Don't use gradient washes behind body text, glassmorphism on chrome, or neon glows.
110
+ - Don't use gradient washes behind body text, glassmorphism on chrome, or neon glows (the floating header's blur is
111
+ the kit's, not a style to copy).
98
112
  - Don't put two primary buttons side by side.
99
113
  - Don't write "Click here", "Learn more" as the only words of a link; say where it goes.
100
114
  - Don't use the em dash (—) in visible text. Use a comma, a colon or a full stop.
@@ -139,14 +153,24 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
139
153
 
140
154
  ## 7. Theming for another brand
141
155
 
142
- A brand other than GemBoss changes **only**: the accent family, the wash hues, the logo in the header and footer, and
143
- the favicon. The accent family is eight variables set in one `:root {}` block after the kit:
144
- `--gw-accent-light`, `--gw-accent-strong-light`, `--gw-accent-soft-light`, `--gw-on-accent-light` and the same four
145
- ending in `-dark`. Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses
146
- as soon as the theme toggle is pressed. Accent text (links, the active menu item) uses `--gw-accent-strong`, so a new
147
- accent-strong must reach 4.5:1 on both `--gw-bg` and `--gw-bg-soft`. A brand does not change the shapes, the scales,
148
- the ink action, the white canvas or the type. Recipe and checks: `docs/theming.md`; a working example:
149
- `demo/theme-example.html`.
156
+ A brand other than GemBoss chooses **only** these, and nothing else:
157
+
158
+ - **the accent family**: eight variables in one `:root {}` block after the kit, `--gw-accent-light`,
159
+ `--gw-accent-strong-light`, `--gw-accent-soft-light`, `--gw-on-accent-light` and the same four ending in `-dark`.
160
+ Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses as soon as the
161
+ theme toggle is pressed. Accent text uses `--gw-accent-strong`, so it must reach 4.5:1 on `--gw-bg` and
162
+ `--gw-bg-soft`;
163
+ - **the wash hues**, **the logo** in the header and footer, and **the favicon**;
164
+ - **three named profiles**, once for the site, never free values:
165
+ - action colour: `data-gw-action="accent"` on `<html>` fills the primary button with the accent (default `ink`).
166
+ Its words (`--gw-on-accent`) must reach 4.5:1 on `--gw-accent` and `--gw-accent-strong`, light and dark, or
167
+ `check` fails;
168
+ - radius: `data-gw-radius="soft"` on `<html>` (12 / 20 / 24) instead of `tight` (8 / 12 / 16, default);
169
+ - header: `<header class="gw-header gw-header--float">`, an inset card from 1080px, instead of the flat bar.
170
+
171
+ A brand does not change the neutrals, the type, the spacing, the shadows or the widths, and the profiles do not open
172
+ accent-coloured heading words, accent bands or uppercase labels. Recipe and checks: `docs/theming.md`; working
173
+ examples: `demo/theme-example.html` (accent only) and `demo/brand-profiles.html` (all three profiles).
150
174
 
151
175
  ## 8. Where things go in a consuming site
152
176
 
@@ -180,10 +204,11 @@ npx gemboss-website check # every .html and .css in the site against
180
204
  npx gemboss-website check --fix-list # the same, as a to-do list
181
205
  ```
182
206
 
183
- It reports colours, radii, spacing and type outside the system, shadows on chrome, accent-filled buttons, uppercase
184
- labels, em dashes in visible text, images without `alt`, overrides of `.gw-*` classes, a waiting button not marked
185
- with `aria-busy`, a segmented control without its radio-group markup, a drawn mock-up with numbers and no caption, and
186
- patterns that repeat across pages (likely gaps). It does not replace looking at the page at 320px and 1366px.
207
+ It reports colours, radii, spacing and type outside the system, shadows on chrome, buttons filled from site CSS,
208
+ uppercase labels, em dashes in visible text, images without `alt`, overrides of `.gw-*` classes, redefined kit tokens,
209
+ a waiting button not marked with `aria-busy`, a segmented control without its radio-group markup, a drawn mock-up
210
+ with numbers and no caption, an unknown brand profile or one set off `<html>`, `.gw-header--float` off the header,
211
+ words on the accent button below 4.5:1, and patterns that repeat across pages (likely gaps). It does not replace looking at the page at 320px and 1366px.
187
212
 
188
213
  ## 11. Upgrading the kit
189
214
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ Every release lists what changed for a site that uses the kit. A release that ad
4
4
  says so with `Closes gap #N` (the GitHub issue number), so `npx gemboss-website upgrade` can tell that site which
5
5
  stopgap (`kitgap-*`) it can now delete.
6
6
 
7
+ ## 0.3.0 (2026-10-25)
8
+
9
+ Brand profiles: three knobs so that another GemCommerce brand (GemPages first, Gemians later) can use the kit and
10
+ still look like itself. Each one is a named profile chosen once for the site, never a free value. Markup that worked
11
+ in 0.2.0 still works, and a page without the new attributes looks exactly as before (ink buttons, tight corners, the
12
+ flat header: the demo is pixel-identical to 0.2.0 at 390 and 1366px, light and dark).
13
+
14
+ Closes #61
15
+
16
+ - Action colour: `data-gw-action="accent"` on `<html>` fills the primary `.gw-btn` with the brand's accent
17
+ (`--gw-on-accent` words, `--gw-accent-strong` on hover and press, the `-dark` family in dark mode and night bands).
18
+ The default is `ink`. No button shadow. The build fails when the kit's own colours put the button's words below
19
+ 4.5:1, light or dark, and `check` does the same with a brand's colours (`action-contrast`).
20
+ - Radius profiles: `data-gw-radius="soft"` on `<html>` gives 12 / 20 / 24 (action, card, panel) and 24 for the mega
21
+ menu; the default `tight` keeps 8 / 12 / 16 and 20. Every component reads `--gw-radius-action|card|panel|popover`;
22
+ no component keeps a radius of its own.
23
+ - Floating header: `<header class="gw-header gw-header--float">` is, from 1080px, an inset card (the panel radius, the
24
+ surface at 85% over a 10px blur, one hairline, the raised shadow, 16px from the top). Same bar, mega menu and
25
+ script; the flat bar below 1080px, so the phone menu is unchanged; solid under `prefers-reduced-transparency` and
26
+ where the browser cannot blur.
27
+ - Still forbidden with every profile: accent or gradient words in a heading, accent section bands, uppercase labels.
28
+ `AGENTS.md` (rules 2 and 3, section 7), `docs/theming.md` (a brand chooses six things, nothing else) and
29
+ `docs/philosophy.md` say what changed and why: the site speaks the language of the brand's own product.
30
+ - `check`: `profile-unknown` (a name that does not exist, or a profile set off `<html>`), `header-float-markup` (the
31
+ float class off the header) and `action-contrast`, all errors; `token-redefined` and `radius-off-scale` point at
32
+ the profiles.
33
+ - `system.json` version 2: `profiles` (action, radius, header). `radius.action`, `radius.card` and `radius.panel`
34
+ moved to `profiles.radius.options.tight`; `radius.scale` gains 24. New tokens: `--gw-radius-popover`,
35
+ `--gw-radius-24`, `--gw-header-gap`, `--gw-header-surface`, `--gw-header-blur`.
36
+ - `demo/brand-profiles.html`: a GemPages-style brand with all three profiles and the accent `#3c67ff`; its
37
+ accent-strong `#2f52d6` is an example the GemPages team has still to confirm.
38
+
7
39
  ## 0.2.0 (2026-10-25)
8
40
 
9
41
  Four gaps reported while the GemBoss Design System was written. Markup that worked in 0.1.0 still works.
package/README.md CHANGED
@@ -12,11 +12,11 @@ it. Made so that a team working with Claude Code, even without a designer, ships
12
12
 
13
13
  | | |
14
14
  |---|---|
15
- | `website.css`, `website.min.css` | toàn bộ kit trong một tệp (khoảng 49 KB, 38 KB bản min): token sáng và tối, chữ, bố cục, nút (cả trạng thái nhấn và đang chờ), link, nhãn, chip, segmented control, badge, card, khung cửa sổ kèm chú thích "Illustrative data.", bảng, FAQ, chữ bài dài (`.gw-prose`), header + mega menu + menu điện thoại, footer, 12 section |
15
+ | `website.css`, `website.min.css` | toàn bộ kit trong một tệp (khoảng 52 KB, 40 KB bản min): token sáng và tối, chữ, bố cục, nút (cả trạng thái nhấn và đang chờ), link, nhãn, chip, segmented control, badge, card, khung cửa sổ kèm chú thích "Illustrative data.", bảng, FAQ, chữ bài dài (`.gw-prose`), header + mega menu + menu điện thoại, footer, 12 section, 3 hồ sơ thương hiệu |
16
16
  | `js/website.js` | mega menu, menu điện thoại, đổi giao diện sáng/tối, hiện dần khi cuộn, đánh dấu trang hiện tại, segmented control, chặn bấm lần hai khi nút đang chờ; không phụ thuộc thư viện nào, khoảng 9 KB |
17
17
  | `AGENTS.md` | luật mà Claude Code (và người) phải theo: 10 luật, cách dựng trang, điều cấm, chữ, khả năng truy cập, đổi thương hiệu, khi kit thiếu |
18
18
  | `templates/` | khung một trang và 12 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta, prose) |
19
- | `demo/` | mọi thành phần trên một trang; `theme-example.html` cho thấy một thương hiệu khác đổi màu nhấn |
19
+ | `demo/` | mọi thành phần trên một trang; `theme-example.html` cho thấy một thương hiệu khác đổi màu nhấn; `brand-profiles.html` là một thương hiệu kiểu GemPages bật cả 3 hồ sơ |
20
20
  | `docs/` | danh mục class (`components.md`), đổi thương hiệu (`theming.md`), triết lý (`philosophy.md`), hợp đồng markup |
21
21
  | `system.json` | mọi giá trị của hệ thống (màu, khoảng cách, chữ, bo góc, bóng, khung): nguồn duy nhất |
22
22
  | CLI `gemboss-website` | `init`, `check`, `gap`, `upgrade` |
@@ -40,9 +40,9 @@ nó đọc `AGENTS.md` của kit trước, dựng từ mẫu, và chạy `check`
40
40
  **Site HTML tĩnh hoặc không có bước build:** dùng CDN của npm, ghim đúng phiên bản.
41
41
 
42
42
  ```html
43
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.2.0/website.min.css">
43
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.3.0/website.min.css">
44
44
  <link rel="stylesheet" href="/site.css"> <!-- CSS riêng của site, luôn SAU kit -->
45
- <script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.2.0/js/website.js" defer></script>
45
+ <script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.3.0/js/website.js" defer></script>
46
46
  ```
47
47
 
48
48
  **Next.js (App Router):** nạp CSS trong layout gốc, nạp script bằng `next/script`.
@@ -74,16 +74,24 @@ section trong `templates/sections/` theo thứ tự trang cần, thay mọi ch
74
74
 
75
75
  ## Thương hiệu khác GemBoss
76
76
 
77
- Một thương hiệu chỉ đổi màu nhấn (8 biến cho sáng và tối), màu wash, logo và favicon, trong một khối `:root {}` đặt
78
- sau kit. Hình dạng, thang, nút màu mực, nền trắng và kiểu chữ giữ nguyên. Công thức và các phép kiểm tương phản:
79
- `docs/theming.md`; ví dụ chạy được: `demo/theme-example.html`.
77
+ Một thương hiệu chỉ chọn sáu thứ: màu nhấn (8 biến cho sáng và tối, trong một khối `:root {}` đặt sau kit), màu
78
+ wash, logo (kèm favicon), và ba hồ sơ có tên, chọn một lần cho cả site:
79
+
80
+ - màu nút chính: `data-gw-action="accent"` trên `<html>` cho nút chính dùng màu nhấn (mặc định `ink`, nút màu mực);
81
+ chữ trên nút phải đạt 4,5:1 cả sáng lẫn tối, không thì `check` báo lỗi;
82
+ - bo góc: `data-gw-radius="soft"` trên `<html>` (12 / 20 / 24) thay cho `tight` (8 / 12 / 16, mặc định);
83
+ - header: `<header class="gw-header gw-header--float">`, một thẻ nổi từ 1080px; điện thoại giữ thanh phẳng.
84
+
85
+ Nền trắng, màu trung tính, chữ, khoảng cách, bóng giữ nguyên; chữ màu nhấn trong tiêu đề, dải nền màu nhấn, nhãn
86
+ viết hoa vẫn cấm. Công thức và các phép kiểm tương phản: `docs/theming.md`; ví dụ chạy được:
87
+ `demo/theme-example.html` (chỉ màu nhấn) và `demo/brand-profiles.html` (cả ba hồ sơ).
80
88
 
81
89
  ## Lệnh
82
90
 
83
91
  | lệnh | làm gì |
84
92
  |---|---|
85
93
  | `npx gemboss-website init` | nối luật của kit vào Claude Code của dự án (CLAUDE.md + skill) |
86
- | `npx gemboss-website check` | kiểm mọi `.html` và `.css` của site: màu, khoảng cách, chữ, bo góc ngoài thang, bóng, nút tô màu nhấn, nhãn viết hoa giãn chữ, đè class `gw-`, dấu gạch dài, ảnh thiếu `alt`, link mơ hồ… và gợi ý chỗ kit có thể đang thiếu |
94
+ | `npx gemboss-website check` | kiểm mọi `.html` và `.css` của site: màu, khoảng cách, chữ, bo góc ngoài thang, bóng, nút tự tô màu, nhãn viết hoa giãn chữ, đè class `gw-`, đổi token của kit, hồ sơ thương hiệu sai tên hoặc sai chỗ, chữ trên nút màu nhấn dưới 4,5:1, dấu gạch dài, ảnh thiếu `alt`, link mơ hồ… và gợi ý chỗ kit có thể đang thiếu |
87
95
  | `npx gemboss-website gap` | báo một chỗ kit còn thiếu: mở GitHub Issue ở repo của kit (nếu `gh` đã đăng nhập và có quyền), không thì ghi `kit-gaps/<tên>.md` để gửi |
88
96
  | `npx gemboss-website upgrade` | so bản đang dùng với bản mới nhất, in thay đổi, gap đã có trong kit, và lệnh nâng cấp |
89
97
 
package/bin/lib/check.mjs CHANGED
@@ -3,8 +3,19 @@
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { basename, resolve } from "node:path";
5
5
  import { PKG, SYSTEM, UsageError, blank, c, isKitPath, lineIndex, parseArgs, rel, walkFiles } from "./util.mjs";
6
+ import { MIN_CONTRAST, actionPairs, ratio } from "./contrast.mjs";
6
7
 
7
8
  const RADII = new Set(SYSTEM.radius.scale);
9
+ // A brand's named profiles (system.json `profiles`): one attribute on <html> each, and the floating header's class.
10
+ const PROFILE = Object.fromEntries(["action", "radius"].map((k) => {
11
+ const p = SYSTEM.profiles[k];
12
+ return [p.attribute, { kind: k, names: Object.keys(p.options).filter((n) => !n.startsWith("$")) }];
13
+ }));
14
+ const FLOAT_CLASS = SYSTEM.profiles.header.options.float.class;
15
+ const ACCENT_ACTION = SYSTEM.profiles.action.options.accent;
16
+ const profileList = () => Object.entries(PROFILE).map(([attr, p]) => `${attr}="${p.names.join("|")}"`).join(" and ");
17
+ const radiusText = () => Object.entries(SYSTEM.profiles.radius.options).filter(([n]) => !n.startsWith("$"))
18
+ .map(([n, o]) => `${n} ${o.action}/${o.card}/${o.panel}`).join(", ");
8
19
  const TYPE = new Set(SYSTEM.type.scale);
9
20
  // The page gutter (layout.gutter) is a system value too: horizontal padding and the negative bleed that undoes it.
10
21
  const SPACE = new Set([...SYSTEM.space.scale, SYSTEM.layout.gutter]);
@@ -20,13 +31,13 @@ export const RULES = {
20
31
  },
21
32
  "token-redefined": {
22
33
  level: "error", title: "Kit token redefined",
23
- why: "Only the accent family and the wash hues are themable. Changing another --gw-* token changes every component on the site.",
24
- fix: "Remove it. If the brand really needs a different value, report a gap: npx gemboss-website gap --kind token.",
34
+ why: "A brand chooses its accent family and wash hues (variables), its logo, and three named profiles; nothing else. Changing another --gw-* token changes every component on the site.",
35
+ fix: `Remove it. The action colour and the corners are profiles, not tokens: ${profileList()} on <html>; the floating header is the class ${FLOAT_CLASS} (docs/theming.md). If the brand needs something else, report a gap: npx gemboss-website gap --kind token.`,
25
36
  },
26
37
  "radius-off-scale": {
27
38
  level: "error", title: "Radius not on the scale",
28
- why: "Actions are 8px rectangles, cards 12px, panels 16px, chips 999px. Other radii make a page look improvised.",
29
- fix: `Use one of ${list(RADII)} (px), or a kit class that already has the shape.`,
39
+ why: `Actions, cards and panels take the radius of the site's profile (${radiusText()}), chips 999px. Other radii make a page look improvised.`,
40
+ fix: `Use var(--gw-radius-action), var(--gw-radius-card) or var(--gw-radius-panel) (they follow the profile), one of ${list(RADII)} (px), or a kit class that already has the shape.`,
30
41
  },
31
42
  "type-off-scale": {
32
43
  level: "error", title: "Font size not on the type scale",
@@ -55,8 +66,8 @@ export const RULES = {
55
66
  },
56
67
  "accent-button": {
57
68
  level: "error", title: "Button filled with the accent, a wash or a gradient",
58
- why: "The thing you click is ink. The accent marks links, focus and the active item; it is never a button fill.",
59
- fix: "Use .gw-btn (ink) or .gw-btn--secondary as they are. Do not restyle buttons.",
69
+ why: "The thing you click has one colour on the whole site, the action profile: ink, or the brand's accent when <html> has data-gw-action=\"accent\". A fill set in site CSS breaks that, and a gradient or a wash hue is never one.",
70
+ fix: "Use .gw-btn and .gw-btn--secondary as they are. For an accent button, put data-gw-action=\"accent\" on <html> (docs/theming.md).",
60
71
  },
61
72
  "gw-override": {
62
73
  level: "error", title: "Site CSS overrides a kit class",
@@ -103,6 +114,21 @@ export const RULES = {
103
114
  why: "website.js and screen readers find the choice through the markup: a radiogroup with a name and data-gw-segmented, two to four <button role=\"radio\"> options, exactly one aria-checked=\"true\". Without it the arrows do nothing and the choice is not announced.",
104
115
  fix: "Copy the markup from docs/components.md (Segmented control) or templates/sections/pricing.html.",
105
116
  },
117
+ "profile-unknown": {
118
+ level: "error", title: "Brand profile that does not exist, or not on <html>",
119
+ why: "A brand chooses its action colour and its corners once for the site, from a fixed list, with one attribute on <html>. An unknown name falls back to the default without a word, and on any other element the profile reaches only part of the page.",
120
+ fix: `Use ${profileList()} on <html>, and nowhere else (docs/theming.md).`,
121
+ },
122
+ "header-float-markup": {
123
+ level: "error", title: "Floating header class not on the header",
124
+ why: `.${FLOAT_CLASS} reshapes the <header class="gw-header"> itself (an inset card from 1080px). On another element it does nothing, or draws a card inside the bar.`,
125
+ fix: `Put both classes on the header: <header class="gw-header ${FLOAT_CLASS}" data-gw-header>.`,
126
+ },
127
+ "action-contrast": {
128
+ level: "error", title: "Words on the accent button below 4.5:1",
129
+ why: `With data-gw-action="accent" the primary button is filled with the brand's accent. Its words (--gw-on-accent) must reach ${MIN_CONTRAST}:1 on --gw-accent and on --gw-accent-strong (hover and pressed), in light and in dark, or the main action of every page is hard to read.`,
130
+ fix: "Darken --gw-accent-light and --gw-accent-strong-light (or change --gw-on-accent-light), lighten the -dark ones (dark words on them), until every pair passes; write them as #rrggbb. Or keep the ink button: remove data-gw-action.",
131
+ },
106
132
  "uppercase-text": {
107
133
  level: "warning", title: "Uppercase text",
108
134
  why: "Headings, buttons, labels and menu items are sentence case.",
@@ -382,6 +408,39 @@ function checkFontSize(d, value, where, add) {
382
408
  }
383
409
  }
384
410
 
411
+ /**
412
+ * The brand's accent primitives (--gw-accent-light, --gw-on-accent-dark…) as one page sees them: the plain :root
413
+ * blocks of the site's CSS files, then the page's own <style> blocks, over the kit's values. { name: { value, at } }
414
+ */
415
+ function brandAccent(cssRules, page) {
416
+ const vals = {};
417
+ const take = ({ file, rules }) => {
418
+ for (const r of rules) {
419
+ if (r.atBlock || r.at.length || !r.selector || !isRootish(r.selector) || /data-theme/i.test(r.selector)) continue;
420
+ for (const d of r.decls) {
421
+ if (/^--gw-(?:on-)?accent(?:-strong|-soft)?-(?:light|dark)$/.test(d.prop)) vals[d.prop] = { value: d.value.replace(/!important\s*$/i, "").trim(), at: `${file}:${d.line}` };
422
+ }
423
+ }
424
+ };
425
+ for (const c of cssRules.filter((x) => !x.inline)) take(c);
426
+ for (const c of cssRules.filter((x) => x.inline && x.file === page)) take(c);
427
+ return vals;
428
+ }
429
+
430
+ /** Pages with data-gw-action="accent": the words of the primary button on its fills, light and dark (action-contrast). */
431
+ function checkActionContrast(out, add) {
432
+ for (const pg of out.pageProfiles.filter((p) => p.action === "accent")) {
433
+ const brand = brandAccent(out.cssRules, pg.file);
434
+ const colours = (theme, role) => (brand[`--gw-${role}-${theme}`] || { value: SYSTEM.color[theme][role] }).value;
435
+ const src = (theme, role) => { const b = brand[`--gw-${role}-${theme}`]; return b ? ` (${b.at})` : " (kit default)"; };
436
+ for (const p of actionPairs(ACCENT_ACTION, colours)) {
437
+ if (p.ok) continue;
438
+ const pair = `--gw-${p.textRole}-${p.theme} ${p.text}${src(p.theme, p.textRole)} on --gw-${p.fillRole}-${p.theme} ${p.fill}${src(p.theme, p.fillRole)}`;
439
+ add("action-contrast", { file: pg.file, line: pg.line, excerpt: `${p.theme}, ${p.state === "rest" ? "at rest" : "hover"}: ${pair} ${p.ratio === null ? "cannot be read as a colour" : `is ${ratio(p.ratio)}, needs ${MIN_CONTRAST}:1`}` });
440
+ }
441
+ }
442
+ }
443
+
385
444
  // --------------------------------------------------------------------------------------------- HTML scanning
386
445
 
387
446
  const TAG_RE = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
@@ -469,6 +528,13 @@ function analyzeHtml(file, text, out) {
469
528
  if (tag === "img" && !("alt" in a)) add("img-alt", where());
470
529
  if (tag === "h1" && ++h1 === 2) add("multiple-h1", where());
471
530
  if (tag === "html" && !(a.lang || "").trim()) add("missing-lang", where());
531
+ for (const [attr, p] of Object.entries(PROFILE)) {
532
+ if (!(attr in a)) continue;
533
+ const v = (a[attr] || "").trim();
534
+ if (tag !== "html") add("profile-unknown", where(`${attr}="${v}" on <${tag}>: only on <html>`));
535
+ else if (!p.names.includes(v)) add("profile-unknown", where(`${attr}="${v}": no such ${p.kind} profile (${p.names.join(", ")})`));
536
+ else if (p.kind === "action") out.pageProfiles.push({ file: relFile, line, action: v });
537
+ }
472
538
  if ((tag === "div" || tag === "span") && "onclick" in a) add("clickable-div", where());
473
539
  for (const k of ["alt", "title", "aria-label", "placeholder"]) {
474
540
  if (a[k] && hasEmDash(a[k])) add("em-dash", where(`${k}="${a[k]}"`));
@@ -481,6 +547,7 @@ function analyzeHtml(file, text, out) {
481
547
  classes.add(k);
482
548
  if (k.startsWith("kitgap-") && !out.kitgapUse.has(gapBlock(k))) out.kitgapUse.set(gapBlock(k), { file: relFile, line });
483
549
  }
550
+ if (cls.includes(FLOAT_CLASS) && !cls.includes("gw-header")) add("header-float-markup", where());
484
551
  const inner = () => { const end = elementEnd(scanText, m.index, tag); return end < 0 ? "" : scanText.slice(m.index + m[0].length, end); };
485
552
  if (cls.includes("gw-btn")) {
486
553
  const busy = (a["aria-busy"] || "").trim() === "true";
@@ -569,7 +636,7 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
569
636
  const r = RULES[rule];
570
637
  findings.push({ rule, level: r.level, file: w.file, line: w.line, excerpt: w.excerpt, title: r.title, why: r.why, fix: r.fix });
571
638
  };
572
- const out = { add, cssRules: [], pageClasses: [], kitgapUse: new Map() };
639
+ const out = { add, cssRules: [], pageClasses: [], kitgapUse: new Map(), pageProfiles: [] };
573
640
  const counts = { html: 0, css: 0, skipped: [] };
574
641
  for (const f of files) {
575
642
  let text;
@@ -587,6 +654,8 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
587
654
  }
588
655
  }
589
656
 
657
+ checkActionContrast(out, add);
658
+
590
659
  // Classes styled in site CSS: first definition of each.
591
660
  const styled = new Map();
592
661
  for (const { file, rules, text } of [...out.cssRules].sort((a, b) => (a.inline ? 1 : 0) - (b.inline ? 1 : 0))) {
@@ -0,0 +1,67 @@
1
+ // WCAG 2 contrast for the kit's action profiles (system.json `profiles.action`). One implementation for the build
2
+ // (scripts/build.mjs checks the kit's own colours) and for `gemboss-website check` (a brand's accent family).
3
+ // No dependencies, Node 18 or later.
4
+
5
+ export const MIN_CONTRAST = 4.5;
6
+
7
+ /** "#rgb", "#rrggbb" (alpha ignored only when fully opaque), "rgb(r g b)" / "rgb(r, g, b)" -> [r, g, b], else null. */
8
+ export function parseColor(value) {
9
+ const v = String(value || "").trim().toLowerCase();
10
+ let m = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
11
+ if (m) {
12
+ let h = m[1];
13
+ if (h.length <= 4) h = [...h].map((c) => c + c).join("");
14
+ if (h.length === 8 && h.slice(6) !== "ff") return null;
15
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
16
+ }
17
+ m = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\s*(?:[,/]\s*(1|1\.0+|100%)\s*)?\)$/.exec(v);
18
+ if (m) {
19
+ const rgb = m.slice(1, 4).map(Number);
20
+ return rgb.every((n) => n <= 255) ? rgb : null;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ const channel = (c) => { const s = c / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
26
+ const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
27
+
28
+ /** Contrast ratio of two colours (any order), or null when one cannot be read. */
29
+ export function contrast(a, b) {
30
+ const x = parseColor(a), y = parseColor(b);
31
+ if (!x || !y) return null;
32
+ const [hi, lo] = [luminance(x), luminance(y)].sort((p, q) => q - p);
33
+ return (hi + 0.05) / (lo + 0.05);
34
+ }
35
+
36
+ export const ratio = (n) => `${(Math.floor(n * 100) / 100).toFixed(2)}:1`;
37
+
38
+ /**
39
+ * Every pair a primary button paints in one action profile: its label (`on-action`) on the fill (`action`) and on the
40
+ * hover and pressed fill (`action-hover`), in light and in dark (a night band uses the dark palette).
41
+ * `colours(theme, role)` returns the colour of a role ("accent", "on-accent", "action"…) in "light" or "dark".
42
+ * Returns [{ theme, state, text, fill, textRole, fillRole, ratio, ok }]; `ratio` is null when a colour is unreadable.
43
+ */
44
+ export function actionPairs(profile, colours) {
45
+ const out = [];
46
+ for (const theme of ["light", "dark"]) {
47
+ for (const [state, fillRole] of [["rest", profile.action], ["hover", profile["action-hover"]]]) {
48
+ const textRole = profile["on-action"];
49
+ const text = colours(theme, textRole), fill = colours(theme, fillRole);
50
+ const r = contrast(text, fill);
51
+ out.push({ theme, state, text, fill, textRole, fillRole, ratio: r, ok: r !== null && r >= MIN_CONTRAST });
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** The kit's own colours for a role, from system.json `color.light` / `color.dark`. */
58
+ export const kitColours = (system) => (theme, role) => system.color[theme][role];
59
+
60
+ /** Every action profile of system.json against the kit's own colours: [{ profile, ...pair }]. Used by the build. */
61
+ export function kitActionContrast(system) {
62
+ const out = [];
63
+ for (const [name, profile] of Object.entries(system.profiles.action.options).filter(([k]) => !k.startsWith("$"))) {
64
+ for (const p of actionPairs(profile, kitColours(system))) out.push({ profile: name, ...p });
65
+ }
66
+ return out;
67
+ }
@@ -1,6 +1,8 @@
1
1
  /* components: buttons, links, chips, badges, cards, windows, icons, lists, tables, FAQ. */
2
2
 
3
- /* actions are rectangles: 40px, radius 8, 14px/600, sentence case, ink fill (never the accent) */
3
+ /* actions are rectangles: 40px, the action radius of the site's profile (8, 12 in soft), 14px/600, sentence case.
4
+ The fill is the action colour: ink, or the brand's accent under data-gw-action="accent" (tokens.css). Never a
5
+ gradient, a wash hue or a shadow. */
4
6
  .gw-btn {
5
7
  position: relative;
6
8
  display: inline-flex;
@@ -64,7 +66,7 @@
64
66
  @media (prefers-reduced-motion: reduce) {
65
67
  .gw-btn[aria-busy="true"]::before {
66
68
  border: 0;
67
- border-radius: 0;
69
+ border-radius: var(--gw-radius-0);
68
70
  background:
69
71
  radial-gradient(circle at 3px 50%, currentColor 1.5px, transparent 2px),
70
72
  radial-gradient(circle at 8px 50%, currentColor 1.5px, transparent 2px),
@@ -110,8 +112,8 @@
110
112
  }
111
113
 
112
114
  /* segmented control: one choice out of two to four (Monthly / Yearly), a radio group of buttons. The dashboard's look
113
- (@gemboss/ui .ui-seg): one hairline group, radius 8, a hairline between options, the chosen one on the accent tint,
114
- so it follows a brand's accent. The weight never changes with the choice: the labels do not move. */
115
+ (@gemboss/ui .ui-seg): one hairline group, the action radius, a hairline between options, the chosen one on the
116
+ accent tint, so it follows a brand's accent. The weight never changes with the choice: the labels do not move. */
115
117
  .gw-segmented {
116
118
  display: inline-flex;
117
119
  max-width: 100%;
@@ -170,8 +172,8 @@
170
172
  .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); }
171
173
  .gw-badge--warning::before { background: var(--gw-warning); }
172
174
 
173
- /* cards sit: white, 1px hairline, radius 12, the dashboard's resting shadow (Chris 24/10/2026: one language with the
174
- app); no hover lift. A soft card is a tint and carries no shadow. */
175
+ /* cards sit: white, 1px hairline, the card radius of the site's profile (12, 20 in soft), the dashboard's resting
176
+ shadow (Chris 24/10/2026: one language with the app); no hover lift. A soft card is a tint and carries no shadow. */
175
177
  .gw-card {
176
178
  padding: 24px;
177
179
  border: 1px solid var(--gw-line);
package/css/header.css CHANGED
@@ -1,5 +1,6 @@
1
1
  /* header: announcement, a flat 64px bar with one hairline, mega menu, mobile menu below 1080px.
2
- Not a floating pill: no radius, no blur, no shadow on the bar. Nothing tucks under it. */
2
+ Not a floating pill: no radius, no blur, no shadow on the bar. Nothing tucks under it. A brand may choose the one
3
+ other shape, .gw-header--float (an inset card from 1080px, at the end of this file). */
3
4
  .gw-announce {
4
5
  padding: 8px var(--gw-gutter);
5
6
  border-bottom: 1px solid var(--gw-line);
@@ -83,9 +84,9 @@
83
84
  text-underline-offset: 8px;
84
85
  }
85
86
 
86
- /* mega menu: a floating card centred under the bar, as on gemboss.ai (Chris 24/10/2026): one hairline, radius 20,
87
- the raised shadow (it is a popover), 32px inside. The bridge (::before) keeps the pointer inside while it crosses
88
- the 8px gap from the trigger. */
87
+ /* mega menu: a floating card centred under the bar, as on gemboss.ai (Chris 24/10/2026): one hairline, the popover
88
+ radius of the site's profile (20, 24 in soft), the raised shadow (it is a popover), 32px inside. The bridge
89
+ (::before) keeps the pointer inside while it crosses the 8px gap from the trigger. */
89
90
  .gw-mega {
90
91
  position: absolute;
91
92
  top: calc(100% + 8px);
@@ -95,7 +96,7 @@
95
96
  overflow-y: auto;
96
97
  transform: translateX(-50%);
97
98
  border: 1px solid var(--gw-line);
98
- border-radius: 20px;
99
+ border-radius: var(--gw-radius-popover);
99
100
  background: var(--gw-surface);
100
101
  box-shadow: var(--gw-shadow-raised);
101
102
  }
@@ -229,3 +230,34 @@ html.is-menu-open .gw-header { position: fixed; top: 0; left: 0; right: 0; }
229
230
  @media (max-width: 768px), (pointer: coarse) {
230
231
  .gw-theme-toggle, .gw-menu-toggle { width: var(--gw-tap-min); height: var(--gw-tap-min); }
231
232
  }
233
+
234
+ /* floating header (0.3.0), a brand's header profile: <header class="gw-header gw-header--float">. The same bar, mega
235
+ menu and script, drawn from 1080px as an inset card: the panel radius of the site's profile (16, 24 in soft), the
236
+ surface at 85% over a 10px blur, one hairline, the raised shadow, a 16px gap above it and when it sticks. It is
237
+ wrap + 2 x 24px + its border wide, so the bar's content lines up with the page's from 1170px. Below 1080px it is
238
+ the flat bar, so the phone menu is unchanged. Solid for a visitor who asks for less transparency, and where the
239
+ browser cannot blur. No other floating or blurred header: it is a profile, not a style to copy. */
240
+ @media (min-width: 1080px) {
241
+ .gw-header--float {
242
+ top: var(--gw-header-gap);
243
+ width: min(100% - 32px, var(--gw-wrap) + 50px);
244
+ margin: var(--gw-header-gap) auto 0;
245
+ border: 1px solid var(--gw-line);
246
+ border-radius: var(--gw-radius-panel);
247
+ background: color-mix(in srgb, var(--gw-surface) var(--gw-header-surface), transparent);
248
+ -webkit-backdrop-filter: blur(var(--gw-header-blur));
249
+ backdrop-filter: blur(var(--gw-header-blur));
250
+ box-shadow: var(--gw-shadow-raised);
251
+ }
252
+ .gw-header--float.is-scrolled { border-color: var(--gw-line-strong); }
253
+ .gw-header--float > .gw-header__bar { width: auto; padding-inline: 24px; }
254
+ .gw-header--float .gw-mega { max-height: calc(100vh - var(--gw-header-height) - var(--gw-header-gap) - 24px); }
255
+ /* an anchor lands below the card, not under it */
256
+ :root:has(.gw-header--float) [id] { scroll-margin-top: calc(var(--gw-header-height) + var(--gw-header-gap) + 18px); }
257
+ }
258
+ @media (min-width: 1080px) and (prefers-reduced-transparency: reduce) {
259
+ .gw-header--float { background: var(--gw-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
260
+ }
261
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
262
+ .gw-header--float { background: var(--gw-surface); }
263
+ }
package/css/prose.css CHANGED
@@ -59,7 +59,8 @@
59
59
  :where(.gw-prose) > hr { height: 0; border: 0; border-top: 1px solid var(--gw-line); }
60
60
  :where(.gw-prose) :is(sup, sub) { line-height: 0; }
61
61
 
62
- /* code: inline, a tint chip that wraps on a phone; a block scrolls inside its own box */
62
+ /* code: inline, a tint chip that wraps on a phone (radius 6 in every profile: it follows the text, not the shapes);
63
+ a block scrolls inside its own box */
63
64
  :where(.gw-prose) :is(pre, code, kbd, samp) { font-family: var(--gw-font-mono); }
64
65
  :where(.gw-prose) code {
65
66
  padding: 2px 6px;
@@ -87,7 +88,7 @@
87
88
  line-height: 1.6;
88
89
  tab-size: 2;
89
90
  }
90
- :where(.gw-prose) pre code { padding: 0; border-radius: 0; background: none; font-size: inherit; overflow-wrap: normal; }
91
+ :where(.gw-prose) pre code { padding: 0; border-radius: var(--gw-radius-0); background: none; font-size: inherit; overflow-wrap: normal; }
91
92
 
92
93
  /* images and captions (an image inside a .gw-window keeps the window's square edges) */
93
94
  :where(.gw-prose) > :is(img, video),
package/css/tokens.css CHANGED
@@ -46,11 +46,13 @@
46
46
  --gw-radius-12: 12px;
47
47
  --gw-radius-16: 16px;
48
48
  --gw-radius-20: 20px;
49
+ --gw-radius-24: 24px;
49
50
  --gw-radius-999: 999px;
50
51
  --gw-radius-action: 8px;
51
52
  --gw-radius-chip: 999px;
52
53
  --gw-radius-card: 12px;
53
54
  --gw-radius-panel: 16px;
55
+ --gw-radius-popover: 20px;
54
56
  --gw-shadow: 0 1px 2px rgba(31,30,29,.04), 0 2px 6px rgba(31,30,29,.06);
55
57
  --gw-shadow-sm: 0 1px 2px rgba(31,30,29,.04);
56
58
  --gw-shadow-raised: 0 4px 16px rgba(31,30,29,.10);
@@ -58,6 +60,9 @@
58
60
  --gw-wrap-narrow: 780px;
59
61
  --gw-gutter: 22px;
60
62
  --gw-header-height: 64px;
63
+ --gw-header-gap: 16px;
64
+ --gw-header-surface: 85%;
65
+ --gw-header-blur: 10px;
61
66
  --gw-button-height: 40px;
62
67
  --gw-tap-min: 44px;
63
68
  --gw-ease: cubic-bezier(.22,1,.36,1);
@@ -198,3 +203,17 @@
198
203
  --gw-success-soft: #132a22;
199
204
  --gw-warning: #e0b341;
200
205
  }
206
+ /* action profile "accent": data-gw-action="accent" on <html>. In night bands too, where the dark palette applies. */
207
+ :root[data-gw-action="accent"],
208
+ :root[data-gw-action="accent"] :is(.gw-band--night, .gw-showcase) {
209
+ --gw-action: var(--gw-accent);
210
+ --gw-action-hover: var(--gw-accent-strong);
211
+ --gw-on-action: var(--gw-on-accent);
212
+ }
213
+ /* radius profile "soft": data-gw-radius="soft" on <html> */
214
+ :root[data-gw-radius="soft"] {
215
+ --gw-radius-action: 12px;
216
+ --gw-radius-card: 20px;
217
+ --gw-radius-panel: 24px;
218
+ --gw-radius-popover: 24px;
219
+ }