@gemboss/website-ui 0.1.0 → 0.2.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,10 +10,10 @@ 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
18
  | `docs/components.md` | every class, what it is for, its variants |
19
19
  | `docs/theming.md` | how a brand other than GemBoss sets its accent and logo, and what it must not change |
@@ -37,7 +37,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
37
37
  7. **One idea per section, one primary action per view.** A section has a short label, a headline, at most two
38
38
  lines under it, then the content. One `.gw-btn` per section; the second action is `.gw-btn--secondary` or a link.
39
39
  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).
40
+ facts become a visible placeholder like `[PRICE]` and a question to the person (section 6). A drawn mock-up may
41
+ show made-up numbers only with the caption `<p class="gw-window__caption">Illustrative data.</p>` in its window.
41
42
  9. **It must work at 320px.** Check every page at 320, 390, 768 and 1366px wide. Nothing scrolls sideways, no text
42
43
  is clipped, every tap target is at least 44px.
43
44
  10. **Run the check before you say "done".** `npx gemboss-website check` (section 10).
@@ -48,7 +49,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
48
49
  header, the `<main>` and the footer. Fill the header and footer once (they are the same on every page).
49
50
  2. Pick sections from `templates/sections/` in the order the page needs. A typical page: hero → proof (logos or
50
51
  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.
52
+ fewer, clearer sections beat more. A page that is read rather than scanned (a blog post, docs, terms, privacy) is
53
+ the prose section: the article's own HTML inside `.gw-prose`, no classes and no site CSS for its type.
52
54
  3. Write the copy (section 6), then delete what you did not use. Do not leave empty slots or sample text.
53
55
  4. Images: real product screens or photos the brand owns. Give every `<img>` a real `alt` (or `alt=""` when it is
54
56
  decoration), `width`, `height`, and `loading="lazy"` below the first screen.
@@ -65,6 +67,11 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
65
67
  - **Accent as text** (links, the active menu item, step numbers) is `--gw-accent-strong`; the plain accent is for
66
68
  the label square, the focus ring, check marks and borders. Night bands switch to the dark palette by themselves.
67
69
  - **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
+ - **A button that waits** (a form sent, an audit started) gets `aria-busy="true"` from the page's script until the
71
+ answer comes: the kit keeps its label and width, draws the ring and ignores a second press. Nothing else: no
72
+ `disabled`, no loading class, no spinner of your own, no "Loading…" label.
73
+ - **One choice out of two to four** (Monthly / Yearly) is `.gw-segmented`, a radio group; what each option shows
74
+ carries `data-gw-show="group:value"`. Markup: `docs/components.md`, `templates/sections/pricing.html`.
68
75
  - **Type**: Inter. Headings weight 650, tracking −0.022em; display h1/h2 weight 600, tracking −0.03em. Body 18px,
69
76
  line height 1.6. The house headline shape: a muted first sentence, an ink second one
70
77
  (`<h2><span class="gw-dim">Two builders.</span> One engine underneath.</h2>`).
@@ -92,6 +99,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
92
99
  - Don't write "Click here", "Learn more" as the only words of a link; say where it goes.
93
100
  - Don't use the em dash (—) in visible text. Use a comma, a colon or a full stop.
94
101
  - Don't invent numbers, customer names, logos, testimonials, ratings, awards or certifications.
102
+ - Don't write fake numbers on a mock-up without the caption "Illustrative data." under it, and don't reword it.
103
+ - Don't style an article's headings, lists or code in site CSS; wrap the article in `.gw-prose`.
95
104
  - Don't animate layout properties (width, height, top, margin), and don't autoplay motion that cannot be stopped.
96
105
  - Don't ship a page you have not seen at 320px.
97
106
  - 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 +114,10 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
105
114
  - Tap targets at least 44×44px, with at least 8px between neighbours.
106
115
  - One `<h1>` per page, headings in order, landmarks (`header`, `nav`, `main`, `footer`), and the skip link kept.
107
116
  - Icon-only buttons get `aria-label`. Decorative SVG gets `aria-hidden="true"`.
117
+ - Loading is `aria-busy="true"` on the button, with its label kept; when the wait ends, say what happened in words
118
+ (a `.gw-note` or a `role="status"` line). Under `prefers-reduced-motion` the kit's ring stands still.
119
+ - A single choice is a radio group (`.gw-segmented`): one Tab stop, the arrows move and choose. Never a row of
120
+ `aria-pressed` buttons for it.
108
121
 
109
122
  ## 6. Words
110
123
 
@@ -115,6 +128,9 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
115
128
  - Product names, plan names and prices are written exactly as the brand writes them.
116
129
  - Domains in the text are the brand's own; never a made-up example brand that looks real.
117
130
  - Do not name or quote internal tools or internal data on a public page.
131
+ - Made-up numbers in a drawn mock-up: `.gw-window__caption` says exactly "Illustrative data." (vi: "Dữ liệu minh
132
+ hoạ.", zh: "示意数据。"), plus one sentence only when a demo brand needs naming ("Vital Glow is a GemBoss demo
133
+ brand."). Real numbers in a window: the caption names their source.
118
134
  - Image sources: record where each image comes from (the brand's own screen, a licensed photo). No photos of real
119
135
  people or other brands' logos without permission.
120
136
  - Other languages: rewrite each page for its reader (transcreation), do not translate sentence by sentence. Facts
@@ -165,8 +181,9 @@ npx gemboss-website check --fix-list # the same, as a to-do list
165
181
  ```
166
182
 
167
183
  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.
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.
170
187
 
171
188
  ## 11. Upgrading the kit
172
189
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ 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.2.0 (2026-10-25)
8
+
9
+ Four gaps reported while the GemBoss Design System was written. Markup that worked in 0.1.0 still works.
10
+
11
+ - Buttons: a pressed state (`:active`: the ink one step darker, nothing moves) and a loading state
12
+ (`aria-busy="true"` on the `<button>`: the label keeps the width and the accessible name, a 16px ring in the
13
+ button's own ink, a still ellipsis under reduced motion). `website.js` ignores presses on a busy button, so a form
14
+ is not sent twice. Replace a `kitgap-` spinner or loading class with `aria-busy`.
15
+ Closes gap #25 (button pressed and loading states)
16
+ - Segmented control `.gw-segmented` (+ `--block`): one choice out of two to four, a radio group (one Tab stop, the
17
+ arrow keys move and choose) with the dashboard's look, the chosen option on the accent tint. Content for one
18
+ option carries `data-gw-show="group:value"`; `website.js` shows it and fires `gw:segmented`.
19
+ `templates/sections/pricing.html` now has the monthly / yearly control. A `kitgap-segmented` group of
20
+ `aria-pressed` buttons becomes `role="radiogroup"` with `role="radio"` options (docs/components.md).
21
+ Closes gap #27 (segmented control, pricing monthly / yearly)
22
+ - `.gw-window__caption`: what the numbers in a window are, 12px muted at the end of the window. Under a drawn
23
+ mock-up it says "Illustrative data." (vi: "Dữ liệu minh hoạ.", zh: "示意数据。"); under real numbers, their source.
24
+ The hero, split and showcase templates carry it.
25
+ Closes gap #32 (illustrative data caption for mock-ups)
26
+ - `.gw-prose` (+ `--sm` for docs and legal): long-form reading. The article's own HTML needs no classes: headings,
27
+ paragraphs, lists, quotes, inline and block code, tables that scroll inside their own box, images and captions,
28
+ all on the kit's scales. New `templates/sections/prose.html`.
29
+ Closes gap #46 (long-form prose styles)
30
+ - `check`: `busy-button` and `segmented-markup` (errors), `illustrative-caption` (a warning: a drawn window with
31
+ numbers and no caption).
32
+ - Tokens: `--gw-font-mono` and `--gw-spin-ms` (`type.mono` and `motion.spin_ms` in `system.json`).
33
+ - `website.js` 0.2.0, about 9 KB. `AGENTS.md` and the skill say when to use the four new parts.
34
+
7
35
  ## 0.1.0 (2026-10-24)
8
36
 
9
37
  The foundation.
package/README.md CHANGED
@@ -12,10 +12,10 @@ 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 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 |
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) |
18
+ | `templates/` | khung một trang và 12 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta, prose) |
19
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 |
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 |
@@ -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.2.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.2.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`.
@@ -114,5 +114,6 @@ node test/run.mjs # tự kiểm CLI
114
114
  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
115
  ```
116
116
 
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.
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`. GitHub Actions
118
+ đư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
119
+ 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
@@ -93,11 +93,26 @@ export const RULES = {
93
93
  why: "A div or span with onclick cannot be reached by keyboard and is not announced as a control.",
94
94
  fix: "Use <a href> to go somewhere and <button type=\"button\"> to do something.",
95
95
  },
96
+ "busy-button": {
97
+ level: "error", title: "Loading button not marked with aria-busy",
98
+ 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.",
99
+ 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.",
100
+ },
101
+ "segmented-markup": {
102
+ level: "error", title: "Segmented control without its radio-group markup",
103
+ 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
+ fix: "Copy the markup from docs/components.md (Segmented control) or templates/sections/pricing.html.",
105
+ },
96
106
  "uppercase-text": {
97
107
  level: "warning", title: "Uppercase text",
98
108
  why: "Headings, buttons, labels and menu items are sentence case.",
99
109
  fix: "Write it in sentence case instead of capitals or text-transform: uppercase.",
100
110
  },
111
+ "illustrative-caption": {
112
+ level: "warning", title: "Numbers in a drawn mock-up with no caption",
113
+ 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.",
114
+ 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.",
115
+ },
101
116
  "possible-gap": {
102
117
  level: "note", title: "Possible kit gap: a site class used on several pages",
103
118
  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.",
@@ -382,6 +397,19 @@ const VAGUE = new Set(["click here", "here", "learn more", "read more", "more",
382
397
  const LABELISH = /label|eyebrow|kicker|overline|badge|chip|btn|button|pill/i;
383
398
  const SKIP_CLASS = /^(?:gw-|is-|has-|js-|kitgap-)/;
384
399
 
400
+ /** The end offset of the element whose start tag is at `start`, counting nested tags of the same name; -1 if unclosed. */
401
+ function elementEnd(text, start, tag) {
402
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
403
+ re.lastIndex = start;
404
+ let depth = 0;
405
+ for (let m; (m = re.exec(text));) {
406
+ if (m[1]) { if (--depth <= 0) return m.index + m[0].length; } else if (!m[0].endsWith("/>")) depth++;
407
+ }
408
+ return -1;
409
+ }
410
+ const LOADING_CLASS = /^(?:is-)?(?:loading|busy)$|--(?:loading|busy)$/;
411
+ const SPINNER_CLASS = /\bclass\s*=\s*["'][^"']*(?:spinner|loader|loading)/i;
412
+
385
413
  function parseAttrs(s) {
386
414
  const a = {};
387
415
  for (const m of s.matchAll(ATTR_RE)) a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
@@ -453,6 +481,31 @@ function analyzeHtml(file, text, out) {
453
481
  classes.add(k);
454
482
  if (k.startsWith("kitgap-") && !out.kitgapUse.has(gapBlock(k))) out.kitgapUse.set(gapBlock(k), { file: relFile, line });
455
483
  }
484
+ const inner = () => { const end = elementEnd(scanText, m.index, tag); return end < 0 ? "" : scanText.slice(m.index + m[0].length, end); };
485
+ if (cls.includes("gw-btn")) {
486
+ const busy = (a["aria-busy"] || "").trim() === "true";
487
+ if (busy ? "disabled" in a : cls.some((k) => LOADING_CLASS.test(k)) || SPINNER_CLASS.test(inner())) add("busy-button", where());
488
+ }
489
+ if (cls.includes("gw-segmented")) {
490
+ const opts = [...inner().matchAll(/<button\b([^>]*)>/gi)].map((x) => parseAttrs(x[1]));
491
+ const missing = [
492
+ a.role !== "radiogroup" && 'role="radiogroup"',
493
+ !(a["aria-label"] || a["aria-labelledby"]) && "aria-label",
494
+ !(a["data-gw-segmented"] || "").trim() && "data-gw-segmented=\"name\"",
495
+ (opts.length < 2 || opts.length > 4) && "two to four <button> options",
496
+ opts.some((o) => o.role !== "radio" || !("aria-checked" in o)) && 'role="radio" and aria-checked on each option',
497
+ opts.filter((o) => o["aria-checked"] === "true").length !== 1 && 'exactly one aria-checked="true"',
498
+ ].filter(Boolean);
499
+ if (missing.length) add("segmented-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
500
+ }
501
+ if (cls.includes("gw-window")) {
502
+ const end = elementEnd(scanText, m.index, tag);
503
+ const body = end < 0 ? "" : scanText.slice(m.index, end);
504
+ const words = end < 0 ? "" : decode(visible.slice(m.index, end)).replace(/\s+/g, " ").trim();
505
+ if (/\d/.test(words) && !/<(?:img|picture|video|iframe|canvas)\b/i.test(body) && !/\bgw-window__caption\b/.test(body)) {
506
+ add("illustrative-caption", where(`${m[0].replace(/\s+/g, " ").slice(0, 60)} ${words.slice(0, 70)}`));
507
+ }
508
+ }
456
509
  if ("style" in a && a.style.trim()) {
457
510
  const selector = tag + (a.id ? `#${a.id}` : "") + cls.map((k) => "." + k).join("");
458
511
  checkRule({ selector, decls: parseInline(decode(a.style), line), line }, { file: relFile, svg: within(svg, m.index), inline: true }, add);
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; }
@@ -35,6 +35,43 @@
35
35
  .gw-btn--lg { height: 48px; padding: 0 24px; font-size: 16px; }
36
36
  .gw-btn--block { display: flex; width: 100%; }
37
37
  .gw-btn:disabled, .gw-btn[aria-disabled="true"] { opacity: 0.48; cursor: not-allowed; pointer-events: none; }
38
+ /* pressed: the ink one step darker (the hover ink, as the dashboard's primary), nothing moves; the frameless variants
39
+ take one more step of tint */
40
+ .gw-btn:active { background: var(--gw-action-hover); }
41
+ .gw-btn--secondary:active { background: var(--gw-bg-soft); border-color: var(--gw-ink); }
42
+ .gw-btn--ghost:active { background: var(--gw-line); }
43
+ .gw-btn--on-night:active { background: var(--gw-on-night); opacity: 0.76; }
44
+ /* loading: aria-busy="true" on the <button>. The label stays (it keeps the width and the accessible name) but is not
45
+ painted; a 16px ring in the button's own ink turns in its place, the dashboard's spinner. ::before, because
46
+ .gw-btn--sm draws its hit area with ::after. website.js ignores presses on a busy button (no double submit). */
47
+ .gw-btn[aria-busy="true"] { cursor: progress; -webkit-text-fill-color: transparent; }
48
+ .gw-btn[aria-busy="true"] svg { opacity: 0; }
49
+ .gw-btn[aria-busy="true"]::before {
50
+ content: "";
51
+ position: absolute;
52
+ top: 50%;
53
+ left: 50%;
54
+ width: 16px;
55
+ height: 16px;
56
+ margin: -8px 0 0 -8px;
57
+ border: 2px solid color-mix(in srgb, currentColor 18%, transparent);
58
+ border-top-color: currentColor;
59
+ border-radius: var(--gw-radius-chip);
60
+ animation: gw-spin var(--gw-spin-ms) linear infinite;
61
+ }
62
+ @keyframes gw-spin { to { transform: rotate(360deg); } }
63
+ /* reduced motion: the ring stands still as an ellipsis, three dots in the button's ink */
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .gw-btn[aria-busy="true"]::before {
66
+ border: 0;
67
+ border-radius: 0;
68
+ background:
69
+ radial-gradient(circle at 3px 50%, currentColor 1.5px, transparent 2px),
70
+ radial-gradient(circle at 8px 50%, currentColor 1.5px, transparent 2px),
71
+ radial-gradient(circle at 13px 50%, currentColor 1.5px, transparent 2px);
72
+ animation: none;
73
+ }
74
+ }
38
75
 
39
76
  /* inline text link with an arrow that moves 2px on hover */
40
77
  .gw-link--arrow {
@@ -72,6 +109,45 @@
72
109
  color: var(--gw-accent-strong);
73
110
  }
74
111
 
112
+ /* 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
+ .gw-segmented {
116
+ display: inline-flex;
117
+ max-width: 100%;
118
+ border: 1px solid var(--gw-line-strong);
119
+ border-radius: var(--gw-radius-action);
120
+ background: var(--gw-surface);
121
+ overflow: hidden;
122
+ overflow: clip;
123
+ vertical-align: middle;
124
+ }
125
+ .gw-segmented--block { display: flex; width: 100%; }
126
+ .gw-segmented > button {
127
+ display: inline-flex;
128
+ flex: 0 1 auto;
129
+ align-items: center;
130
+ justify-content: center;
131
+ min-width: 0;
132
+ height: calc(var(--gw-button-height) - 2px);
133
+ padding: 0 16px;
134
+ border: 0;
135
+ background: transparent;
136
+ color: var(--gw-muted);
137
+ font-size: 14px;
138
+ font-weight: 500;
139
+ line-height: 1.4;
140
+ white-space: nowrap;
141
+ cursor: pointer;
142
+ }
143
+ .gw-segmented--block > button { flex: 1 1 0; }
144
+ .gw-segmented > button + button { border-left: 1px solid var(--gw-line-strong); }
145
+ .gw-segmented > button:hover { background: var(--gw-bg-soft); color: var(--gw-ink); }
146
+ .gw-segmented > button:active { background: var(--gw-line); }
147
+ /* inside the group: the group clips its corners, so the ring sits within the option */
148
+ .gw-segmented > button:focus-visible { outline-offset: -2px; }
149
+ .gw-segmented > button[aria-checked="true"] { background: var(--gw-accent-soft); color: var(--gw-accent-strong); }
150
+
75
151
  /* badges: small status, pill */
76
152
  .gw-badge {
77
153
  display: inline-flex;
@@ -145,6 +221,17 @@
145
221
  .gw-window__body { padding: 16px; }
146
222
  .gw-window__body:has(> img:only-child) { padding: 0; }
147
223
  .gw-window__body > img { width: 100%; }
224
+ /* what the numbers in the window are: "Illustrative data." under a drawn mock-up, or the source of real ones. The
225
+ last child of .gw-window, after __body (a <figcaption> when the window is a <figure>). */
226
+ .gw-window__caption {
227
+ margin: 0;
228
+ padding: 0 16px 12px;
229
+ color: var(--gw-muted);
230
+ font-size: 12px;
231
+ line-height: 1.5;
232
+ text-align: end;
233
+ }
234
+ .gw-window__body:has(> img:only-child) + .gw-window__caption { padding-top: 12px; }
148
235
 
149
236
  /* icon: 20px inline stroke SVG (1.7 to 2px stroke, currentColor) */
150
237
  .gw-icon { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 20px; height: 20px; }
@@ -233,4 +320,5 @@
233
320
  }
234
321
  .gw-btn--lg { min-height: 48px; }
235
322
  :is(a, button, label).gw-chip { min-height: var(--gw-tap-min); }
323
+ .gw-segmented > button { height: auto; min-height: var(--gw-tap-min); padding-block: 8px; white-space: normal; text-wrap: balance; }
236
324
  }
package/css/layout.css CHANGED
@@ -19,7 +19,7 @@
19
19
  @media (min-width: 768px) { .gw-section__head:not(:last-child) { margin-bottom: 48px; } }
20
20
 
21
21
  :where(.gw-section__head, .gw-hero > .gw-wrap, .gw-split > :not(.gw-window), .gw-cta, .gw-card, .gw-feature, .gw-plan) > * + * { margin-top: 16px; }
22
- :where(.gw-section__head, .gw-hero > .gw-wrap, .gw-split > :not(.gw-window), .gw-cta, .gw-card, .gw-plan) > .gw-actions { margin-top: 32px; }
22
+ :where(.gw-section__head, .gw-hero > .gw-wrap, .gw-split > :not(.gw-window), .gw-cta, .gw-card, .gw-plan) > :is(.gw-actions, .gw-segmented) { margin-top: 32px; }
23
23
  :where(.gw-section__head, .gw-hero > .gw-wrap, .gw-split > :not(.gw-window), .gw-cta, .gw-card, .gw-plan) > .gw-list--check { margin-top: 24px; }
24
24
 
25
25
  /* a row of actions: one .gw-btn, then a secondary or a link */
package/css/prose.css ADDED
@@ -0,0 +1,119 @@
1
+ /* prose: long-form reading (a blog post, a docs page, terms and privacy) inside one wrapper, .gw-prose. The plain HTML a
2
+ CMS or a markdown renderer writes is styled as it comes: headings, paragraphs, lists, quotes, code, tables, images
3
+ and captions, on the kit's scales. The rules sit at :where() weight and reach bare elements only (lists without a
4
+ class, the wrapper's own headings, quotes and tables), so a kit component placed inside the prose (.gw-table,
5
+ .gw-window, .gw-list--check, .gw-quote, .gw-btn) keeps its own look. --sm: a 16px body for docs and legal. */
6
+ .gw-prose {
7
+ max-width: 68ch;
8
+ min-width: 0;
9
+ color: var(--gw-ink);
10
+ font-size: var(--gw-body-size);
11
+ line-height: var(--gw-body-leading);
12
+ overflow-wrap: break-word;
13
+ }
14
+ .gw-prose--sm { font-size: 16px; }
15
+
16
+ /* the flow: each block 20px below the one before it (16px in --sm); more air around headings and large blocks */
17
+ :where(.gw-prose) > * + * { margin-top: 20px; }
18
+ :where(.gw-prose--sm) > * + * { margin-top: 16px; }
19
+ :where(.gw-prose) > * + :where(figure, img, video, pre, table, hr, .gw-table, .gw-window),
20
+ :where(.gw-prose) > :where(figure, img, video, pre, table, hr, .gw-table, .gw-window) + * { margin-top: 32px; }
21
+ :where(.gw-prose) > * + h2 { margin-top: 48px; }
22
+ :where(.gw-prose) > * + h3 { margin-top: 40px; }
23
+ :where(.gw-prose) > * + h4 { margin-top: 32px; }
24
+ :where(.gw-prose--sm) > * + h2 { margin-top: 40px; }
25
+ :where(.gw-prose--sm) > * + h3 { margin-top: 32px; }
26
+ :where(.gw-prose--sm) > * + h4 { margin-top: 24px; }
27
+ :where(.gw-prose) > h1 + * { margin-top: 24px; }
28
+ :where(.gw-prose) > :is(h2, h3, h4) + * { margin-top: 12px; }
29
+
30
+ /* headings: the article's own steps of the type scale, not the page's display sizes (an h2 in a post is not a
31
+ section headline); h1 is the article's title at the section-headline size */
32
+ :where(.gw-prose) > :is(h1, h2, h3, h4) {
33
+ color: var(--gw-ink);
34
+ font-weight: var(--gw-heading-weight);
35
+ letter-spacing: var(--gw-heading-tracking);
36
+ line-height: 1.25;
37
+ }
38
+ :where(.gw-prose) > h1 { font-size: var(--gw-display-h2); font-weight: var(--gw-display-weight); letter-spacing: var(--gw-display-tracking); line-height: var(--gw-heading-leading); }
39
+ :where(.gw-prose) > h2 { font-size: 30px; line-height: 1.2; }
40
+ :where(.gw-prose) > h3 { font-size: 24px; }
41
+ :where(.gw-prose) > h4 { font-size: 20px; }
42
+ :where(.gw-prose--sm) > h2 { font-size: 24px; }
43
+ :where(.gw-prose--sm) > h3 { font-size: 20px; }
44
+ :where(.gw-prose--sm) > h4 { font-size: 18px; }
45
+
46
+ /* lists without a class: plain bullets and numbers, the markers muted */
47
+ :where(.gw-prose :is(ul, ol):not([class])) { padding-left: 24px; }
48
+ :where(.gw-prose ul:not([class])) { list-style: disc; }
49
+ :where(.gw-prose ol:not([class])) { list-style: decimal; }
50
+ :where(.gw-prose :is(ul, ol):not([class]) > li + li),
51
+ :where(.gw-prose :is(ul, ol):not([class]) > li > * + *),
52
+ :where(.gw-prose :is(ul, ol):not([class]) > li > :is(ul, ol)) { margin-top: 8px; }
53
+ :where(.gw-prose :is(ul, ol):not([class]) > li)::marker { color: var(--gw-muted); font-variant-numeric: tabular-nums; }
54
+
55
+ /* a quotation: a rule on its left and muted words, as the dashboard's reader draws it */
56
+ :where(.gw-prose) > blockquote { padding-left: 20px; border-left: 3px solid var(--gw-line-strong); color: var(--gw-muted); }
57
+ :where(.gw-prose) > blockquote > * + * { margin-top: 12px; }
58
+
59
+ :where(.gw-prose) > hr { height: 0; border: 0; border-top: 1px solid var(--gw-line); }
60
+ :where(.gw-prose) :is(sup, sub) { line-height: 0; }
61
+
62
+ /* code: inline, a tint chip that wraps on a phone; a block scrolls inside its own box */
63
+ :where(.gw-prose) :is(pre, code, kbd, samp) { font-family: var(--gw-font-mono); }
64
+ :where(.gw-prose) code {
65
+ padding: 2px 6px;
66
+ border-radius: var(--gw-radius-6);
67
+ background: color-mix(in srgb, var(--gw-ink) 8%, transparent);
68
+ color: var(--gw-ink);
69
+ font-size: 16px;
70
+ overflow-wrap: anywhere;
71
+ -webkit-box-decoration-break: clone;
72
+ box-decoration-break: clone;
73
+ }
74
+ :where(.gw-prose--sm) code { font-size: 14px; }
75
+ :where(.gw-prose) :is(h1, h2, h3, h4) code { font-size: inherit; }
76
+ :where(.gw-prose) figcaption code { font-size: 13px; }
77
+ /* in a table a name stays whole: the table scrolls instead */
78
+ :where(.gw-prose) :is(th, td) code { font-size: 13px; overflow-wrap: normal; white-space: nowrap; }
79
+ :where(.gw-prose) pre {
80
+ overflow-x: auto;
81
+ padding: 16px 20px;
82
+ border: 1px solid var(--gw-line);
83
+ border-radius: var(--gw-radius-card);
84
+ background: var(--gw-bg-soft);
85
+ color: var(--gw-ink);
86
+ font-size: 14px;
87
+ line-height: 1.6;
88
+ tab-size: 2;
89
+ }
90
+ :where(.gw-prose) pre code { padding: 0; border-radius: 0; background: none; font-size: inherit; overflow-wrap: normal; }
91
+
92
+ /* images and captions (an image inside a .gw-window keeps the window's square edges) */
93
+ :where(.gw-prose) > :is(img, video),
94
+ :where(.gw-prose) > :is(p, figure) > :is(img, video) { border-radius: var(--gw-radius-card); }
95
+ :where(.gw-prose) figcaption { margin-top: 12px; color: var(--gw-muted); font-size: 14px; line-height: 1.5; }
96
+
97
+ /* a table straight from markdown scrolls inside its own box, never the page. When you write the markup, the .gw-table
98
+ wrapper (a region with a name and tabindex="0") is the better choice. */
99
+ :where(.gw-prose) > table {
100
+ display: block;
101
+ width: max-content;
102
+ max-width: 100%;
103
+ overflow-x: auto;
104
+ border: 1px solid var(--gw-line);
105
+ border-radius: var(--gw-radius-card);
106
+ background: var(--gw-surface);
107
+ border-collapse: collapse;
108
+ font-size: 14px;
109
+ line-height: 1.5;
110
+ -webkit-overflow-scrolling: touch;
111
+ }
112
+ :where(.gw-prose) > table :is(th, td) { padding: 12px 16px; border-bottom: 1px solid var(--gw-line); text-align: left; vertical-align: top; }
113
+ :where(.gw-prose) > table thead th { background: var(--gw-bg-soft); color: var(--gw-ink); font-size: 13px; font-weight: 600; white-space: nowrap; }
114
+ :where(.gw-prose) > table tbody th { font-weight: 500; }
115
+ :where(.gw-prose) > table td { color: var(--gw-ink-2); font-variant-numeric: tabular-nums; }
116
+ :where(.gw-prose) > table tbody tr:last-child > * { border-bottom: 0; }
117
+ :where(.gw-prose) > table caption { padding: 12px 16px; color: var(--gw-muted); font-size: 13px; text-align: left; }
118
+ /* on a phone a cell keeps room for a few words a line: the table scrolls further instead of squeezing its text */
119
+ @media (max-width: 767px) { :where(.gw-prose) > table td { min-width: 160px; } }
package/css/tokens.css CHANGED
@@ -2,6 +2,7 @@
2
2
  :root {
3
3
  color-scheme: light;
4
4
  --gw-font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
5
+ --gw-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
5
6
  --gw-text-11: 11px;
6
7
  --gw-text-12: 12px;
7
8
  --gw-text-13: 13px;
@@ -61,6 +62,7 @@
61
62
  --gw-tap-min: 44px;
62
63
  --gw-ease: cubic-bezier(.22,1,.36,1);
63
64
  --gw-reveal-ms: 700ms;
65
+ --gw-spin-ms: 700ms;
64
66
  --gw-wash-pink: #ff6f5e;
65
67
  --gw-wash-violet: #7c8af7;
66
68
  --gw-wash-sky: #43b0ff;