@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 CHANGED
@@ -10,23 +10,31 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
10
10
  | file | what it is |
11
11
  |---|---|
12
12
  | `website.css` | the whole kit, one file (tokens, base, layout, components, sections) |
13
- | `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons; no dependencies |
13
+ | `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons; no dependencies; `window.gwWebsite.init(root)` |
14
+ | `js/website.mjs` | the same script as an ES module for apps: `import { init, destroy } from '@gemboss/website-ui/website.mjs'` |
14
15
  | `system.json` | every value of the system: colours, spacing, type, radii, shadows, widths |
15
16
  | `templates/page.html` | the page skeleton: start every new page from it |
16
17
  | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose… |
17
18
  | `demo/index.html` | every component on one page: open it to see what exists before you build anything |
19
+ | `demo/brand-profiles.html` | a GemPages-style brand: its accent, the accent button, soft corners, the floating header |
18
20
  | `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 |
21
+ | `docs/theming.md` | what a brand other than GemBoss chooses (accent, wash, logo, three named profiles), and what it must not change |
20
22
 
21
23
  ## 1. The ten rules
22
24
 
23
25
  1. **Compose, do not invent.** A page is the page skeleton plus kit sections plus your words. Look in `demo/` and
24
26
  `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.
27
+ 2. **White canvas, one action colour, one accent.** The background is white (`--gw-bg`). The thing you click
28
+ (`.gw-btn`) is near-black ink, or the brand's accent when the site chose the `accent` action profile
29
+ (`data-gw-action="accent"` on `<html>`, section 7): one colour for every primary button of the site, never chosen
30
+ per button. Why the choice (0.3.0): the site and the product speak one language, and GemPages' product clicks in
31
+ blue while GemBoss's clicks in ink. Otherwise the accent is for links, focus, the active item, the 8px label
32
+ square and a chart line. **The accent is never body text, never words in a heading, never a section band, and
33
+ never a fill you set yourself.**
34
+ 3. **Actions are rectangles, chips are pills.** Buttons: 40px tall, sentence case, 14px/600, the action radius of the
35
+ site's profile (8px; 12px in `soft`). Chips, toggles, badges: fully rounded. Cards: the card radius (12px; 20px in
36
+ `soft`), large panels the panel radius (16px; 24px). No other radii: the site picks its profile once
37
+ (`data-gw-radius`), components follow.
30
38
  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
39
  `--gw-shadow`, the same as the GemBoss dashboard. It never lifts on hover. To separate anything else, use space or a
32
40
  hairline before a shadow. The only shadows are `--gw-shadow` (rest), `--gw-shadow-sm` and `--gw-shadow-raised`
@@ -66,6 +74,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
66
74
  it works; a hard-coded colour breaks it.
67
75
  - **Accent as text** (links, the active menu item, step numbers) is `--gw-accent-strong`; the plain accent is for
68
76
  the label square, the focus ring, check marks and borders. Night bands switch to the dark palette by themselves.
77
+ - **Brand profiles** (section 7): `data-gw-action` and `data-gw-radius` on `<html>`, `.gw-header--float` on the
78
+ header. Without them a page is GemBoss: ink buttons, tight corners, the flat header.
69
79
  - **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
80
  - **A button that waits** (a form sent, an audit started) gets `aria-busy="true"` from the page's script until the
71
81
  answer comes: the kit keeps its label and width, draws the ring and ignores a second press. Nothing else: no
@@ -85,16 +95,21 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
85
95
 
86
96
  ## 4. Don't (each of these has happened, and each one made a page look cheap or broke it)
87
97
 
88
- - Don't fill a button with the accent colour, a gradient or a wash hue.
98
+ - Don't fill a button yourself: not with the accent (that is the site's action profile, set once on `<html>`), a
99
+ gradient or a wash hue. No raised or inset shadow on a button.
100
+ - 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
101
  - Don't use uppercase labels with letter spacing ("FEATURES").
90
102
  - Don't add shadows beyond the three tokens, and don't lift cards on hover.
91
103
  - Don't round a button into a pill, and don't square a chip.
92
104
  - 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.
105
+ - Don't use colours, sizes, spacings or radii outside the tokens and scales, and don't redefine a kit token to get
106
+ another look: choose a profile.
107
+ - Don't build your own floating, blurred or "pill" header. The header is the kit's flat bar with one hairline, or
108
+ its inset card `.gw-header--float` when the brand chose it.
95
109
  - Don't tuck the hero under the header with a negative margin.
96
110
  - 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.
111
+ - Don't use gradient washes behind body text, glassmorphism on chrome, or neon glows (the floating header's blur is
112
+ the kit's, not a style to copy).
98
113
  - Don't put two primary buttons side by side.
99
114
  - Don't write "Click here", "Learn more" as the only words of a link; say where it goes.
100
115
  - Don't use the em dash (—) in visible text. Use a comma, a colon or a full stop.
@@ -105,6 +120,9 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
105
120
  - Don't ship a page you have not seen at 320px.
106
121
  - Don't write CSS that overrides a `.gw-*` class to change how it looks. If the kit's version is wrong for you,
107
122
  that is a gap (section 9).
123
+ - Don't wire kit components with your own JS (no click handler on a trigger, no class toggled by hand). After you
124
+ insert kit markup (a route change, a block rendered later), call `gwWebsite.init(root)` on it, or `init(root)` from
125
+ `website.mjs`.
108
126
 
109
127
  ## 5. Accessibility
110
128
 
@@ -139,14 +157,24 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
139
157
 
140
158
  ## 7. Theming for another brand
141
159
 
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`.
160
+ A brand other than GemBoss chooses **only** these, and nothing else:
161
+
162
+ - **the accent family**: eight variables in one `:root {}` block after the kit, `--gw-accent-light`,
163
+ `--gw-accent-strong-light`, `--gw-accent-soft-light`, `--gw-on-accent-light` and the same four ending in `-dark`.
164
+ Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses as soon as the
165
+ theme toggle is pressed. Accent text uses `--gw-accent-strong`, so it must reach 4.5:1 on `--gw-bg` and
166
+ `--gw-bg-soft`;
167
+ - **the wash hues**, **the logo** in the header and footer, and **the favicon**;
168
+ - **three named profiles**, once for the site, never free values:
169
+ - action colour: `data-gw-action="accent"` on `<html>` fills the primary button with the accent (default `ink`).
170
+ Its words (`--gw-on-accent`) must reach 4.5:1 on `--gw-accent` and `--gw-accent-strong`, light and dark, or
171
+ `check` fails;
172
+ - radius: `data-gw-radius="soft"` on `<html>` (12 / 20 / 24) instead of `tight` (8 / 12 / 16, default);
173
+ - header: `<header class="gw-header gw-header--float">`, an inset card from 1080px, instead of the flat bar.
174
+
175
+ A brand does not change the neutrals, the type, the spacing, the shadows or the widths, and the profiles do not open
176
+ accent-coloured heading words, accent bands or uppercase labels. Recipe and checks: `docs/theming.md`; working
177
+ examples: `demo/theme-example.html` (accent only) and `demo/brand-profiles.html` (all three profiles).
150
178
 
151
179
  ## 8. Where things go in a consuming site
152
180
 
@@ -180,10 +208,11 @@ npx gemboss-website check # every .html and .css in the site against
180
208
  npx gemboss-website check --fix-list # the same, as a to-do list
181
209
  ```
182
210
 
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.
211
+ It reports colours, radii, spacing and type outside the system, shadows on chrome, buttons filled from site CSS,
212
+ uppercase labels, em dashes in visible text, images without `alt`, overrides of `.gw-*` classes, redefined kit tokens,
213
+ a waiting button not marked with `aria-busy`, a segmented control without its radio-group markup, a drawn mock-up
214
+ with numbers and no caption, an unknown brand profile or one set off `<html>`, `.gw-header--float` off the header,
215
+ 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
216
 
188
217
  ## 11. Upgrading the kit
189
218
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,69 @@ 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.4.0 (2026-10-25)
8
+
9
+ The website script, standardised like the CSS: it now wires markup that arrives after the page loaded (a Next.js
10
+ route change, a GemPages block rendered later, a CMS preview), and comes as an ES module too. A page that loads
11
+ `js/website.js` with `defer` and changes nothing works exactly as in 0.3.0 (the demo is pixel-identical at 390 and
12
+ 1366px, light and dark).
13
+
14
+ Closes #64
15
+
16
+ - `gwWebsite.init(root = document)`: wires the kit's markup under `root`, `root` included. Idempotent: each element
17
+ is wired once (and marked `data-gw-ready`), so call it after every navigation or insert and only the new elements
18
+ are wired; on a fragment it wires that fragment only. The page's own listeners (Escape, the outside click, the
19
+ busy-button guard, the system theme, the scroll) are installed once. Each call lets go of elements removed from
20
+ the page meanwhile and moves the script's `aria-current` mark to the new path (a mark in the markup stays).
21
+ - `gwWebsite.destroy(root = document)`: removes what `init` wired under `root`, its listeners and its marks, for
22
+ frameworks that unmount. An open mega or mobile menu closes first, so the page is never left locked.
23
+ - ESM entry `@gemboss/website-ui/website.mjs` (`js/website.mjs`): `export { init, destroy, version }`, no side
24
+ effects on import (safe to import on a server), no auto-init. `js/website.js` keeps its path, its export
25
+ `./website.js` and its behaviour: it starts by itself on `DOMContentLoaded` and exposes `window.gwWebsite`.
26
+ - One source: `scripts/build.mjs` writes `js/website.js` from `js/website.mjs` (the code in an IIFE, without the
27
+ comments), and `--check` fails when they differ or when the version is not `package.json`'s.
28
+ - One copy per page: the first copy that runs owns `window.gwWebsite`; another one (the classic script next to a
29
+ bundle) hands its calls to it, so nothing is wired twice.
30
+ - Events, all bubbling, listed in `docs/markup-contract.md`: `gw:mega` on the trigger (`{ id, open }`) and
31
+ `gw:theme` on `<html>` (`{ theme: 'light' | 'dark' }`), next to `gw:segmented`.
32
+ - Budget: `js/website.js` is 11.3 KB unminified, 3.8 KB gzipped (0.3.0: 9.2 KB, 3.3 KB). The test now holds it under
33
+ 12 KB unminified and 4.5 KB gzipped (was 10 KB unminified): the comments live in `website.mjs`, the classic file
34
+ carries none.
35
+ - `AGENTS.md`, the skill and `docs/components.md`: never wire a kit part with your own JS; call `init(root)` after
36
+ inserting kit markup. README: the ESM import and the Next.js recipe (a client effect on each route change).
37
+
38
+ ## 0.3.0 (2026-10-25)
39
+
40
+ Brand profiles: three knobs so that another GemCommerce brand (GemPages first, Gemians later) can use the kit and
41
+ still look like itself. Each one is a named profile chosen once for the site, never a free value. Markup that worked
42
+ in 0.2.0 still works, and a page without the new attributes looks exactly as before (ink buttons, tight corners, the
43
+ flat header: the demo is pixel-identical to 0.2.0 at 390 and 1366px, light and dark).
44
+
45
+ Closes #61
46
+
47
+ - Action colour: `data-gw-action="accent"` on `<html>` fills the primary `.gw-btn` with the brand's accent
48
+ (`--gw-on-accent` words, `--gw-accent-strong` on hover and press, the `-dark` family in dark mode and night bands).
49
+ The default is `ink`. No button shadow. The build fails when the kit's own colours put the button's words below
50
+ 4.5:1, light or dark, and `check` does the same with a brand's colours (`action-contrast`).
51
+ - Radius profiles: `data-gw-radius="soft"` on `<html>` gives 12 / 20 / 24 (action, card, panel) and 24 for the mega
52
+ menu; the default `tight` keeps 8 / 12 / 16 and 20. Every component reads `--gw-radius-action|card|panel|popover`;
53
+ no component keeps a radius of its own.
54
+ - Floating header: `<header class="gw-header gw-header--float">` is, from 1080px, an inset card (the panel radius, the
55
+ surface at 85% over a 10px blur, one hairline, the raised shadow, 16px from the top). Same bar, mega menu and
56
+ script; the flat bar below 1080px, so the phone menu is unchanged; solid under `prefers-reduced-transparency` and
57
+ where the browser cannot blur.
58
+ - Still forbidden with every profile: accent or gradient words in a heading, accent section bands, uppercase labels.
59
+ `AGENTS.md` (rules 2 and 3, section 7), `docs/theming.md` (a brand chooses six things, nothing else) and
60
+ `docs/philosophy.md` say what changed and why: the site speaks the language of the brand's own product.
61
+ - `check`: `profile-unknown` (a name that does not exist, or a profile set off `<html>`), `header-float-markup` (the
62
+ float class off the header) and `action-contrast`, all errors; `token-redefined` and `radius-off-scale` point at
63
+ the profiles.
64
+ - `system.json` version 2: `profiles` (action, radius, header). `radius.action`, `radius.card` and `radius.panel`
65
+ moved to `profiles.radius.options.tight`; `radius.scale` gains 24. New tokens: `--gw-radius-popover`,
66
+ `--gw-radius-24`, `--gw-header-gap`, `--gw-header-surface`, `--gw-header-blur`.
67
+ - `demo/brand-profiles.html`: a GemPages-style brand with all three profiles and the accent `#3c67ff`; its
68
+ accent-strong `#2f52d6` is an example the GemPages team has still to confirm.
69
+
7
70
  ## 0.2.0 (2026-10-25)
8
71
 
9
72
  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 |
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 |
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
+ | `js/website.js`, `js/website.mjs` | 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 4 KB khi nén gzip. `website.js` là script thường (tự chạy khi trang tải xong), `website.mjs` là bản ES module cho app; cả hai có `init(root)` để gắn lại phần markup chèn sau (`docs/markup-contract.md`, mục "Script") |
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,33 +40,55 @@ 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.4.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.4.0/js/website.js" defer></script>
46
46
  ```
47
47
 
48
- **Next.js (App Router):** nạp CSS trong layout gốc, nạp script bằng `next/script`.
48
+ Chèn markup của kit sau khi trang đã tải (block của builder, bản xem trước CMS): gọi `gwWebsite.init(phầnMới)` ngay
49
+ sau đó. Đừng tự viết JS cho mega menu, nút đổi giao diện hay segmented control.
50
+
51
+ **Next.js (App Router):** nạp CSS trong layout gốc; script là bản ES module, gọi `init()` trong một effect mỗi lần
52
+ đổi trang (chuyển trang phía client không tải lại trang, nên phần markup mới phải được gắn lại).
53
+
54
+ ```js
55
+ import { init, destroy } from "@gemboss/website-ui/website.mjs";
56
+ ```
57
+
58
+ ```js
59
+ // app/kit-script.jsx
60
+ "use client";
61
+ import { useEffect } from "react";
62
+ import { usePathname } from "next/navigation";
63
+ import { init } from "@gemboss/website-ui/website.mjs";
64
+
65
+ export default function KitScript() {
66
+ const pathname = usePathname();
67
+ useEffect(() => { init(); }, [pathname]);
68
+ return null;
69
+ }
70
+ ```
49
71
 
50
72
  ```js
51
73
  // app/layout.js
52
74
  import "@gemboss/website-ui/website.css";
53
75
  import "./site.css";
54
- import Script from "next/script";
76
+ import KitScript from "./kit-script";
55
77
 
56
78
  export default function RootLayout({ children }) {
57
79
  return (
58
80
  <html lang="vi">
59
81
  <body className="gw-page">
60
82
  {children}
61
- <Script src="/kit/website.js" strategy="afterInteractive" />
83
+ <KitScript />
62
84
  </body>
63
85
  </html>
64
86
  );
65
87
  }
66
88
  ```
67
89
 
68
- Chép `node_modules/@gemboss/website-ui/js/website.js` vào `public/kit/` (hoặc phục vụ nó bằng một route). Font Inter
69
- nạp từ Google Fonts như trong `templates/page.html`.
90
+ `init()` gọi lại bao nhiêu lần cũng được: phần đã gắn thì bỏ qua, chỉ gắn phần mới. Font Inter nạp từ Google Fonts
91
+ như trong `templates/page.html`.
70
92
 
71
93
  **Bắt đầu một trang:** chép `templates/page.html`, điền header và footer một lần (chung cho mọi trang), ghép các
72
94
  section trong `templates/sections/` theo thứ tự trang cần, thay mọi chỗ `[trong ngoặc]`, xoá phần không dùng. Mở
@@ -74,16 +96,24 @@ section trong `templates/sections/` theo thứ tự trang cần, thay mọi ch
74
96
 
75
97
  ## Thương hiệu khác GemBoss
76
98
 
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`.
99
+ 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
100
+ wash, logo (kèm favicon), và ba hồ sơ có tên, chọn một lần cho cả site:
101
+
102
+ - 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);
103
+ 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;
104
+ - bo góc: `data-gw-radius="soft"` trên `<html>` (12 / 20 / 24) thay cho `tight` (8 / 12 / 16, mặc định);
105
+ - header: `<header class="gw-header gw-header--float">`, một thẻ nổi từ 1080px; điện thoại giữ thanh phẳng.
106
+
107
+ 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
108
+ 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:
109
+ `demo/theme-example.html` (chỉ màu nhấn) và `demo/brand-profiles.html` (cả ba hồ sơ).
80
110
 
81
111
  ## Lệnh
82
112
 
83
113
  | lệnh | làm gì |
84
114
  |---|---|
85
115
  | `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 |
116
+ | `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
117
  | `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
118
  | `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
119
 
@@ -109,7 +139,7 @@ gemboss-www; mọi thay đổi đi qua PR, giá trị của hệ thống chỉ s
109
139
 
110
140
  ```sh
111
141
  cd packages/website-ui
112
- node scripts/build.mjs # sinh css/tokens.css từ system.json, ghép website.css và website.min.css
142
+ node scripts/build.mjs # sinh css/tokens.css từ system.json, ghép website.css và website.min.css, sinh js/website.js từ js/website.mjs
113
143
  node test/run.mjs # tự kiểm CLI
114
144
  node scripts/gate-demo.mjs # cổng thị giác và khoảng thở của gemboss.ai trên demo (cần .ci của repo)
115
145
  ```
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);