@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 +23 -6
- package/CHANGELOG.md +28 -0
- package/README.md +8 -7
- package/bin/lib/check.mjs +53 -0
- package/css/base.css +3 -3
- package/css/components.css +88 -0
- package/css/layout.css +1 -1
- package/css/prose.css +119 -0
- package/css/tokens.css +2 -0
- package/demo/index.html +83 -9
- package/docs/components.md +113 -5
- package/docs/markup-contract.md +36 -3
- package/docs/theming.md +3 -2
- package/js/website.js +39 -2
- package/package.json +1 -1
- package/skill/SKILL.md +2 -1
- package/system.json +3 -1
- package/templates/sections/hero.html +3 -1
- package/templates/sections/pricing.html +15 -3
- package/templates/sections/prose.html +36 -0
- package/templates/sections/showcase.html +3 -2
- package/templates/sections/split.html +3 -1
- package/website.css +215 -5
- package/website.min.css +1 -1
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,
|
|
169
|
-
|
|
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
|
|
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
|
|
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à
|
|
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.
|
|
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.
|
|
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
|
|
118
|
-
|
|
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; }
|
package/css/components.css
CHANGED
|
@@ -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;
|