@gemboss/website-ui 0.1.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
@@ -10,23 +10,30 @@ 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; no dependencies |
13
+ | `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons; no dependencies |
14
14
  | `system.json` | every value of the system: colours, spacing, type, radii, shadows, widths |
15
15
  | `templates/page.html` | the page skeleton: start every new page from it |
16
- | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA… |
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`
@@ -37,7 +44,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
37
44
  7. **One idea per section, one primary action per view.** A section has a short label, a headline, at most two
38
45
  lines under it, then the content. One `.gw-btn` per section; the second action is `.gw-btn--secondary` or a link.
39
46
  8. **Real words, real facts.** No lorem ipsum, no invented numbers, customers, logos, reviews or quotes. Missing
40
- facts become a visible placeholder like `[PRICE]` and a question to the person (section 6).
47
+ facts become a visible placeholder like `[PRICE]` and a question to the person (section 6). A drawn mock-up may
48
+ show made-up numbers only with the caption `<p class="gw-window__caption">Illustrative data.</p>` in its window.
41
49
  9. **It must work at 320px.** Check every page at 320, 390, 768 and 1366px wide. Nothing scrolls sideways, no text
42
50
  is clipped, every tap target is at least 44px.
43
51
  10. **Run the check before you say "done".** `npx gemboss-website check` (section 10).
@@ -48,7 +56,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
48
56
  header, the `<main>` and the footer. Fill the header and footer once (they are the same on every page).
49
57
  2. Pick sections from `templates/sections/` in the order the page needs. A typical page: hero → proof (logos or
50
58
  stats) → 2 to 4 feature sections → one showcase or split → FAQ → final CTA. Not every page needs every section;
51
- fewer, clearer sections beat more.
59
+ fewer, clearer sections beat more. A page that is read rather than scanned (a blog post, docs, terms, privacy) is
60
+ the prose section: the article's own HTML inside `.gw-prose`, no classes and no site CSS for its type.
52
61
  3. Write the copy (section 6), then delete what you did not use. Do not leave empty slots or sample text.
53
62
  4. Images: real product screens or photos the brand owns. Give every `<img>` a real `alt` (or `alt=""` when it is
54
63
  decoration), `width`, `height`, and `loading="lazy"` below the first screen.
@@ -64,7 +73,14 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
64
73
  it works; a hard-coded colour breaks it.
65
74
  - **Accent as text** (links, the active menu item, step numbers) is `--gw-accent-strong`; the plain accent is for
66
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.
67
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.
79
+ - **A button that waits** (a form sent, an audit started) gets `aria-busy="true"` from the page's script until the
80
+ answer comes: the kit keeps its label and width, draws the ring and ignores a second press. Nothing else: no
81
+ `disabled`, no loading class, no spinner of your own, no "Loading…" label.
82
+ - **One choice out of two to four** (Monthly / Yearly) is `.gw-segmented`, a radio group; what each option shows
83
+ carries `data-gw-show="group:value"`. Markup: `docs/components.md`, `templates/sections/pricing.html`.
68
84
  - **Type**: Inter. Headings weight 650, tracking −0.022em; display h1/h2 weight 600, tracking −0.03em. Body 18px,
69
85
  line height 1.6. The house headline shape: a muted first sentence, an ink second one
70
86
  (`<h2><span class="gw-dim">Two builders.</span> One engine underneath.</h2>`).
@@ -78,20 +94,27 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
78
94
 
79
95
  ## 4. Don't (each of these has happened, and each one made a page look cheap or broke it)
80
96
 
81
- - 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.
82
100
  - Don't use uppercase labels with letter spacing ("FEATURES").
83
101
  - Don't add shadows beyond the three tokens, and don't lift cards on hover.
84
102
  - Don't round a button into a pill, and don't square a chip.
85
103
  - Don't use cream, beige or grey page backgrounds; white or `--gw-bg-soft` only.
86
- - Don't use colours, sizes, spacings or radii outside the tokens and scales.
87
- - 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.
88
108
  - Don't tuck the hero under the header with a negative margin.
89
109
  - Don't use emoji as icons; use inline stroke SVG (1.7–2px stroke, `currentColor`, `aria-hidden="true"`).
90
- - 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).
91
112
  - Don't put two primary buttons side by side.
92
113
  - Don't write "Click here", "Learn more" as the only words of a link; say where it goes.
93
114
  - Don't use the em dash (—) in visible text. Use a comma, a colon or a full stop.
94
115
  - Don't invent numbers, customer names, logos, testimonials, ratings, awards or certifications.
116
+ - Don't write fake numbers on a mock-up without the caption "Illustrative data." under it, and don't reword it.
117
+ - Don't style an article's headings, lists or code in site CSS; wrap the article in `.gw-prose`.
95
118
  - Don't animate layout properties (width, height, top, margin), and don't autoplay motion that cannot be stopped.
96
119
  - Don't ship a page you have not seen at 320px.
97
120
  - Don't write CSS that overrides a `.gw-*` class to change how it looks. If the kit's version is wrong for you,
@@ -105,6 +128,10 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
105
128
  - Tap targets at least 44×44px, with at least 8px between neighbours.
106
129
  - One `<h1>` per page, headings in order, landmarks (`header`, `nav`, `main`, `footer`), and the skip link kept.
107
130
  - Icon-only buttons get `aria-label`. Decorative SVG gets `aria-hidden="true"`.
131
+ - Loading is `aria-busy="true"` on the button, with its label kept; when the wait ends, say what happened in words
132
+ (a `.gw-note` or a `role="status"` line). Under `prefers-reduced-motion` the kit's ring stands still.
133
+ - A single choice is a radio group (`.gw-segmented`): one Tab stop, the arrows move and choose. Never a row of
134
+ `aria-pressed` buttons for it.
108
135
 
109
136
  ## 6. Words
110
137
 
@@ -115,6 +142,9 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
115
142
  - Product names, plan names and prices are written exactly as the brand writes them.
116
143
  - Domains in the text are the brand's own; never a made-up example brand that looks real.
117
144
  - Do not name or quote internal tools or internal data on a public page.
145
+ - Made-up numbers in a drawn mock-up: `.gw-window__caption` says exactly "Illustrative data." (vi: "Dữ liệu minh
146
+ hoạ.", zh: "示意数据。"), plus one sentence only when a demo brand needs naming ("Vital Glow is a GemBoss demo
147
+ brand."). Real numbers in a window: the caption names their source.
118
148
  - Image sources: record where each image comes from (the brand's own screen, a licensed photo). No photos of real
119
149
  people or other brands' logos without permission.
120
150
  - Other languages: rewrite each page for its reader (transcreation), do not translate sentence by sentence. Facts
@@ -123,14 +153,24 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
123
153
 
124
154
  ## 7. Theming for another brand
125
155
 
126
- A brand other than GemBoss changes **only**: the accent family, the wash hues, the logo in the header and footer, and
127
- the favicon. The accent family is eight variables set in one `:root {}` block after the kit:
128
- `--gw-accent-light`, `--gw-accent-strong-light`, `--gw-accent-soft-light`, `--gw-on-accent-light` and the same four
129
- ending in `-dark`. Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses
130
- as soon as the theme toggle is pressed. Accent text (links, the active menu item) uses `--gw-accent-strong`, so a new
131
- accent-strong must reach 4.5:1 on both `--gw-bg` and `--gw-bg-soft`. A brand does not change the shapes, the scales,
132
- the ink action, the white canvas or the type. Recipe and checks: `docs/theming.md`; a working example:
133
- `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).
134
174
 
135
175
  ## 8. Where things go in a consuming site
136
176
 
@@ -164,9 +204,11 @@ npx gemboss-website check # every .html and .css in the site against
164
204
  npx gemboss-website check --fix-list # the same, as a to-do list
165
205
  ```
166
206
 
167
- It reports colours, radii, spacing and type outside the system, shadows on chrome, accent-filled buttons, uppercase
168
- labels, em dashes in visible text, images without `alt`, overrides of `.gw-*` classes, and patterns that repeat
169
- 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.
170
212
 
171
213
  ## 11. Upgrading the kit
172
214
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ 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
+
39
+ ## 0.2.0 (2026-10-25)
40
+
41
+ Four gaps reported while the GemBoss Design System was written. Markup that worked in 0.1.0 still works.
42
+
43
+ - Buttons: a pressed state (`:active`: the ink one step darker, nothing moves) and a loading state
44
+ (`aria-busy="true"` on the `<button>`: the label keeps the width and the accessible name, a 16px ring in the
45
+ button's own ink, a still ellipsis under reduced motion). `website.js` ignores presses on a busy button, so a form
46
+ is not sent twice. Replace a `kitgap-` spinner or loading class with `aria-busy`.
47
+ Closes gap #25 (button pressed and loading states)
48
+ - Segmented control `.gw-segmented` (+ `--block`): one choice out of two to four, a radio group (one Tab stop, the
49
+ arrow keys move and choose) with the dashboard's look, the chosen option on the accent tint. Content for one
50
+ option carries `data-gw-show="group:value"`; `website.js` shows it and fires `gw:segmented`.
51
+ `templates/sections/pricing.html` now has the monthly / yearly control. A `kitgap-segmented` group of
52
+ `aria-pressed` buttons becomes `role="radiogroup"` with `role="radio"` options (docs/components.md).
53
+ Closes gap #27 (segmented control, pricing monthly / yearly)
54
+ - `.gw-window__caption`: what the numbers in a window are, 12px muted at the end of the window. Under a drawn
55
+ mock-up it says "Illustrative data." (vi: "Dữ liệu minh hoạ.", zh: "示意数据。"); under real numbers, their source.
56
+ The hero, split and showcase templates carry it.
57
+ Closes gap #32 (illustrative data caption for mock-ups)
58
+ - `.gw-prose` (+ `--sm` for docs and legal): long-form reading. The article's own HTML needs no classes: headings,
59
+ paragraphs, lists, quotes, inline and block code, tables that scroll inside their own box, images and captions,
60
+ all on the kit's scales. New `templates/sections/prose.html`.
61
+ Closes gap #46 (long-form prose styles)
62
+ - `check`: `busy-button` and `segmented-markup` (errors), `illustrative-caption` (a warning: a drawn window with
63
+ numbers and no caption).
64
+ - Tokens: `--gw-font-mono` and `--gw-spin-ms` (`type.mono` and `motion.spin_ms` in `system.json`).
65
+ - `website.js` 0.2.0, about 9 KB. `AGENTS.md` and the skill say when to use the four new parts.
66
+
7
67
  ## 0.1.0 (2026-10-24)
8
68
 
9
69
  The foundation.
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 39 KB, 31 KB bản min): token sáng và tối, chữ, bố cục, nút, link, nhãn, chip, badge, card, khung cửa sổ, bảng, FAQ, header + mega menu + menu điện thoại, footer, 11 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; không phụ thuộc thư viện nào, khoảng 7 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` | 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
- | `templates/` | khung một trang và 11 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta) |
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 |
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; `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.1.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.1.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
 
@@ -114,5 +122,6 @@ node test/run.mjs # tự kiểm CLI
114
122
  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
123
  ```
116
124
 
117
- Phát hành: tăng `version`, thêm mục CHANGELOG (kèm `Closes gap #N`), rồi gắn tag `website-ui-vX.Y.Z` (Trusted
118
- Publishing), hoặc `npm publish` từ thư mục này.
125
+ Phát hành: tăng `version`, thêm mục CHANGELOG (kèm `Closes gap #N`), rồi gắn tag `website-ui-vX.Y.Z`. GitHub Actions
126
+ đưa bản đó lên npm ở dạng CHỜ DUYỆT (`npm stage publish`, Trusted Publishing, không có token nào); bản chỉ ra công
127
+ khai khi một maintainer duyệt bằng passkey trên npmjs.com (hoặc `npm stage approve <id>`).
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",
@@ -93,11 +104,41 @@ export const RULES = {
93
104
  why: "A div or span with onclick cannot be reached by keyboard and is not announced as a control.",
94
105
  fix: "Use <a href> to go somewhere and <button type=\"button\"> to do something.",
95
106
  },
107
+ "busy-button": {
108
+ level: "error", title: "Loading button not marked with aria-busy",
109
+ why: "The kit draws a waiting button from aria-busy=\"true\": the label keeps the width and the accessible name, the ring turns (still under reduced motion) and website.js ignores a second press. A loading class or a hand-made spinner does none of that, and disabled throws the keyboard focus back to the top of the page.",
110
+ fix: "While it waits, set aria-busy=\"true\" on the <button class=\"gw-btn\"> and remove it when the answer comes. No loading class, no spinner element, no disabled.",
111
+ },
112
+ "segmented-markup": {
113
+ level: "error", title: "Segmented control without its radio-group markup",
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.",
115
+ fix: "Copy the markup from docs/components.md (Segmented control) or templates/sections/pricing.html.",
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
+ },
96
132
  "uppercase-text": {
97
133
  level: "warning", title: "Uppercase text",
98
134
  why: "Headings, buttons, labels and menu items are sentence case.",
99
135
  fix: "Write it in sentence case instead of capitals or text-transform: uppercase.",
100
136
  },
137
+ "illustrative-caption": {
138
+ level: "warning", title: "Numbers in a drawn mock-up with no caption",
139
+ why: "A drawn mock-up's numbers are made up. Without a caption a visitor reads them as a claim, and a claim found to be made up makes every other number on the page doubtful.",
140
+ fix: "Add <p class=\"gw-window__caption\">Illustrative data.</p> as the last child of the .gw-window (vi: Dữ liệu minh hoạ. zh: 示意数据。), or the source when the numbers are real.",
141
+ },
101
142
  "possible-gap": {
102
143
  level: "note", title: "Possible kit gap: a site class used on several pages",
103
144
  why: "A site class styled in site CSS and used on 3 or more pages is often a reusable part the kit does not have yet.",
@@ -367,6 +408,39 @@ function checkFontSize(d, value, where, add) {
367
408
  }
368
409
  }
369
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
+
370
444
  // --------------------------------------------------------------------------------------------- HTML scanning
371
445
 
372
446
  const TAG_RE = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
@@ -382,6 +456,19 @@ const VAGUE = new Set(["click here", "here", "learn more", "read more", "more",
382
456
  const LABELISH = /label|eyebrow|kicker|overline|badge|chip|btn|button|pill/i;
383
457
  const SKIP_CLASS = /^(?:gw-|is-|has-|js-|kitgap-)/;
384
458
 
459
+ /** The end offset of the element whose start tag is at `start`, counting nested tags of the same name; -1 if unclosed. */
460
+ function elementEnd(text, start, tag) {
461
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
462
+ re.lastIndex = start;
463
+ let depth = 0;
464
+ for (let m; (m = re.exec(text));) {
465
+ if (m[1]) { if (--depth <= 0) return m.index + m[0].length; } else if (!m[0].endsWith("/>")) depth++;
466
+ }
467
+ return -1;
468
+ }
469
+ const LOADING_CLASS = /^(?:is-)?(?:loading|busy)$|--(?:loading|busy)$/;
470
+ const SPINNER_CLASS = /\bclass\s*=\s*["'][^"']*(?:spinner|loader|loading)/i;
471
+
385
472
  function parseAttrs(s) {
386
473
  const a = {};
387
474
  for (const m of s.matchAll(ATTR_RE)) a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
@@ -441,6 +528,13 @@ function analyzeHtml(file, text, out) {
441
528
  if (tag === "img" && !("alt" in a)) add("img-alt", where());
442
529
  if (tag === "h1" && ++h1 === 2) add("multiple-h1", where());
443
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
+ }
444
538
  if ((tag === "div" || tag === "span") && "onclick" in a) add("clickable-div", where());
445
539
  for (const k of ["alt", "title", "aria-label", "placeholder"]) {
446
540
  if (a[k] && hasEmDash(a[k])) add("em-dash", where(`${k}="${a[k]}"`));
@@ -453,6 +547,32 @@ function analyzeHtml(file, text, out) {
453
547
  classes.add(k);
454
548
  if (k.startsWith("kitgap-") && !out.kitgapUse.has(gapBlock(k))) out.kitgapUse.set(gapBlock(k), { file: relFile, line });
455
549
  }
550
+ if (cls.includes(FLOAT_CLASS) && !cls.includes("gw-header")) add("header-float-markup", where());
551
+ const inner = () => { const end = elementEnd(scanText, m.index, tag); return end < 0 ? "" : scanText.slice(m.index + m[0].length, end); };
552
+ if (cls.includes("gw-btn")) {
553
+ const busy = (a["aria-busy"] || "").trim() === "true";
554
+ if (busy ? "disabled" in a : cls.some((k) => LOADING_CLASS.test(k)) || SPINNER_CLASS.test(inner())) add("busy-button", where());
555
+ }
556
+ if (cls.includes("gw-segmented")) {
557
+ const opts = [...inner().matchAll(/<button\b([^>]*)>/gi)].map((x) => parseAttrs(x[1]));
558
+ const missing = [
559
+ a.role !== "radiogroup" && 'role="radiogroup"',
560
+ !(a["aria-label"] || a["aria-labelledby"]) && "aria-label",
561
+ !(a["data-gw-segmented"] || "").trim() && "data-gw-segmented=\"name\"",
562
+ (opts.length < 2 || opts.length > 4) && "two to four <button> options",
563
+ opts.some((o) => o.role !== "radio" || !("aria-checked" in o)) && 'role="radio" and aria-checked on each option',
564
+ opts.filter((o) => o["aria-checked"] === "true").length !== 1 && 'exactly one aria-checked="true"',
565
+ ].filter(Boolean);
566
+ if (missing.length) add("segmented-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
567
+ }
568
+ if (cls.includes("gw-window")) {
569
+ const end = elementEnd(scanText, m.index, tag);
570
+ const body = end < 0 ? "" : scanText.slice(m.index, end);
571
+ const words = end < 0 ? "" : decode(visible.slice(m.index, end)).replace(/\s+/g, " ").trim();
572
+ if (/\d/.test(words) && !/<(?:img|picture|video|iframe|canvas)\b/i.test(body) && !/\bgw-window__caption\b/.test(body)) {
573
+ add("illustrative-caption", where(`${m[0].replace(/\s+/g, " ").slice(0, 60)} ${words.slice(0, 70)}`));
574
+ }
575
+ }
456
576
  if ("style" in a && a.style.trim()) {
457
577
  const selector = tag + (a.id ? `#${a.id}` : "") + cls.map((k) => "." + k).join("");
458
578
  checkRule({ selector, decls: parseInline(decode(a.style), line), line }, { file: relFile, svg: within(svg, m.index), inline: true }, add);
@@ -516,7 +636,7 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
516
636
  const r = RULES[rule];
517
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 });
518
638
  };
519
- const out = { add, cssRules: [], pageClasses: [], kitgapUse: new Map() };
639
+ const out = { add, cssRules: [], pageClasses: [], kitgapUse: new Map(), pageProfiles: [] };
520
640
  const counts = { html: 0, css: 0, skipped: [] };
521
641
  for (const f of files) {
522
642
  let text;
@@ -534,6 +654,8 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
534
654
  }
535
655
  }
536
656
 
657
+ checkActionContrast(out, add);
658
+
537
659
  // Classes styled in site CSS: first definition of each.
538
660
  const styled = new Map();
539
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
+ }
package/css/base.css CHANGED
@@ -61,8 +61,8 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
61
61
  background: var(--gw-accent);
62
62
  }
63
63
 
64
- /* links inside running text get a visible link style; everything else opts in with .gw-link */
65
- :where(p, dd, td, figcaption, .gw-list--check li) a:not([class]),
64
+ /* links inside running text (and anywhere in .gw-prose) get a visible link style; everything else opts in with .gw-link */
65
+ :where(p, dd, td, figcaption, .gw-list--check li, .gw-prose) a:not([class]),
66
66
  .gw-link {
67
67
  color: var(--gw-accent-strong);
68
68
  font-weight: 500;
@@ -71,7 +71,7 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
71
71
  text-underline-offset: 4px;
72
72
  text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
73
73
  }
74
- :where(p, dd, td, figcaption, .gw-list--check li) a:not([class]):hover,
74
+ :where(p, dd, td, figcaption, .gw-list--check li, .gw-prose) a:not([class]):hover,
75
75
  .gw-link:hover { text-decoration-color: currentColor; }
76
76
 
77
77
  :focus-visible { outline: 2px solid var(--gw-accent); outline-offset: 2px; }