@gemboss/website-ui 0.3.0 → 0.5.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,12 +10,14 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
10
10
  | file | what it is |
11
11
  |---|---|
12
12
  | `website.css` | the whole kit, one file (tokens, base, layout, components, sections) |
13
- | `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons; no dependencies |
13
+ | `js/website.js` | header, mega menu, mobile menu, theme toggle, scroll reveal, segmented control, busy buttons, an article's contents and its tables' scroll box; no dependencies; `window.gwWebsite.init(root)` |
14
+ | `js/website.mjs` | the same script as an ES module for apps: `import { init, destroy } from '@gemboss/website-ui/website.mjs'` |
14
15
  | `system.json` | every value of the system: colours, spacing, type, radii, shadows, widths |
15
16
  | `templates/page.html` | the page skeleton: start every new page from it |
16
- | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose… |
17
+ | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose, article, post list… |
17
18
  | `demo/index.html` | every component on one page: open it to see what exists before you build anything |
18
19
  | `demo/brand-profiles.html` | a GemPages-style brand: its accent, the accent button, soft corners, the floating header |
20
+ | `demo/article.html`, `demo/blog.html` | a whole blog post (breadcrumbs, meta, contents, prose, callouts, FAQ) and the blog index (post list, pagination) |
19
21
  | `docs/components.md` | every class, what it is for, its variants |
20
22
  | `docs/theming.md` | what a brand other than GemBoss chooses (accent, wash, logo, three named profiles), and what it must not change |
21
23
 
@@ -58,6 +60,10 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
58
60
  stats) → 2 to 4 feature sections → one showcase or split → FAQ → final CTA. Not every page needs every section;
59
61
  fewer, clearer sections beat more. A page that is read rather than scanned (a blog post, docs, terms, privacy) is
60
62
  the prose section: the article's own HTML inside `.gw-prose`, no classes and no site CSS for its type.
63
+ **An article page (a blog post, a guide) is `templates/sections/article.html`**: breadcrumbs, the meta row
64
+ (author, category, date, read time), a contents list (`.gw-toc`) built from the article's h2s, the prose, callouts
65
+ where they help, the FAQ last. Never a callout told by its colour alone: it always carries its label in words
66
+ (Note, Tip, Warning). The blog index and "more from the blog" are `templates/sections/post-list.html`.
61
67
  3. Write the copy (section 6), then delete what you did not use. Do not leave empty slots or sample text.
62
68
  4. Images: real product screens or photos the brand owns. Give every `<img>` a real `alt` (or `alt=""` when it is
63
69
  decoration), `width`, `height`, and `loading="lazy"` below the first screen.
@@ -67,8 +73,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
67
73
 
68
74
  - **Colour roles** (CSS variables, light and dark): `--gw-bg`, `--gw-bg-soft` (the neutral raised fill),
69
75
  `--gw-surface`, `--gw-ink`, `--gw-ink-2`, `--gw-muted`, `--gw-line`, `--gw-line-strong`, `--gw-action`,
70
- `--gw-on-action`, `--gw-accent`, `--gw-accent-strong`, `--gw-accent-soft`, `--gw-success`, `--gw-warning`,
71
- `--gw-night` (the dark band). Wash hues `--gw-wash-pink|violet|sky|mint` exist for the hero wash and illustrations only.
76
+ `--gw-on-action`, `--gw-accent`, `--gw-accent-strong`, `--gw-accent-soft`, `--gw-success`, `--gw-warning` (and
77
+ their tints `--gw-success-soft`, `--gw-warning-soft`), `--gw-night` (the dark band). Wash hues `--gw-wash-pink|violet|sky|mint` exist for the hero wash and illustrations only.
72
78
  - **Dark mode** follows the visitor's system and the theme toggle (`data-theme` on `<html>`). Use the variables and
73
79
  it works; a hard-coded colour breaks it.
74
80
  - **Accent as text** (links, the active menu item, step numbers) is `--gw-accent-strong`; the plain accent is for
@@ -115,10 +121,16 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
115
121
  - Don't invent numbers, customer names, logos, testimonials, ratings, awards or certifications.
116
122
  - Don't write fake numbers on a mock-up without the caption "Illustrative data." under it, and don't reword it.
117
123
  - Don't style an article's headings, lists or code in site CSS; wrap the article in `.gw-prose`.
124
+ - Don't make a callout whose kind only its colour tells: `.gw-callout` starts with its `.gw-callout__label` in words.
125
+ - Don't type separators (›, /, >) between breadcrumbs, and don't build a contents list whose links point at ids the
126
+ page does not have: build it from the article's own h2s.
118
127
  - Don't animate layout properties (width, height, top, margin), and don't autoplay motion that cannot be stopped.
119
128
  - Don't ship a page you have not seen at 320px.
120
129
  - Don't write CSS that overrides a `.gw-*` class to change how it looks. If the kit's version is wrong for you,
121
130
  that is a gap (section 9).
131
+ - Don't wire kit components with your own JS (no click handler on a trigger, no class toggled by hand). After you
132
+ insert kit markup (a route change, a block rendered later), call `gwWebsite.init(root)` on it, or `init(root)` from
133
+ `website.mjs`.
122
134
 
123
135
  ## 5. Accessibility
124
136
 
@@ -132,6 +144,8 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
132
144
  (a `.gw-note` or a `role="status"` line). Under `prefers-reduced-motion` the kit's ring stands still.
133
145
  - A single choice is a radio group (`.gw-segmented`): one Tab stop, the arrows move and choose. Never a row of
134
146
  `aria-pressed` buttons for it.
147
+ - A wide table gets a scroll box a keyboard can reach: the `.gw-table` wrapper, or, for a bare table in `.gw-prose`,
148
+ the box `website.js` adds. Give the table a `<caption>`: it names the box.
135
149
 
136
150
  ## 6. Words
137
151
 
@@ -208,7 +222,8 @@ It reports colours, radii, spacing and type outside the system, shadows on chrom
208
222
  uppercase labels, em dashes in visible text, images without `alt`, overrides of `.gw-*` classes, redefined kit tokens,
209
223
  a waiting button not marked with `aria-busy`, a segmented control without its radio-group markup, a drawn mock-up
210
224
  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.
225
+ words on the accent button below 4.5:1, breadcrumbs without their markup, a contents link to an id that is not on
226
+ the page, a callout without its label, and patterns that repeat across pages (likely gaps). It does not replace looking at the page at 320px and 1366px.
212
227
 
213
228
  ## 11. Upgrading the kit
214
229
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,99 @@ 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.5.0 (2026-10-25)
8
+
9
+ The blog release: the parts around a long article and the blog index, so a blog (gempages.net's 1,290 posts first,
10
+ its Shopify article template the proposed pilot) is built from the kit instead of site code. Markup that worked in
11
+ 0.4.0 still works, and a page without the new parts looks exactly as before: the demo index (without its new link),
12
+ `brand-profiles.html` and `theme-example.html` are pixel-identical to 0.4.0 at 390 and 1366px, light and dark, with
13
+ and without reduced motion, the index's bare prose table now in its script-made scroll box included.
14
+
15
+ - Breadcrumbs `.gw-breadcrumbs`: a `<nav>` with a name around an `<ol>`, the last item `aria-current="page"`. The
16
+ separators are a chevron the CSS draws, never a character in the text; the current item stays on one line and is
17
+ cut with an ellipsis on a phone. A BreadcrumbList JSON-LD example is in `docs/components.md` (the kit writes none).
18
+ - Article meta row `.gw-article-meta` (+ `__avatar`): author, category, `<time datetime>`, read time; 14px muted on
19
+ 24px lines, a dot between items that never starts a wrapped line.
20
+ - Article layout `.gw-article` (+ `__head`, `__hero`) and contents `.gw-toc` (`data-gw-toc`): one column on phones
21
+ with the contents folded above the prose (a `<details>`); from 1080px (the kit's desktop width) the reading column (68ch) and the contents
22
+ beside it, sticky under the header (the floating header included). The links work without the script.
23
+ `website.js` marks the section in view with `aria-current="true"` (an accent bar; the weight never changes) using
24
+ an IntersectionObserver that sees a heading cross the line 30% down the window, so a jump across sections counts
25
+ too; it opens the list from 1080px, folds it again below, and a link folds it on a phone before the page moves.
26
+ Each change of section fires `gw:toc` on the nav (`{ id }`, `''` above the first heading). A contents link glides
27
+ to its section, except under `prefers-reduced-motion`.
28
+ Closes gap #54 (blog article chrome: breadcrumbs, meta row, sticky contents, callout, post list)
29
+ - Callout `.gw-callout` (+ `--note`, `--tip`, `--warning`, `__label`), `role="note"`: the kind in words (the label),
30
+ a 16px icon before it, on the kit's tints: the accent's, success's and the new `--gw-warning-soft`. Inside
31
+ `.gw-prose` it takes 32px of air like a figure.
32
+ - Card picture `.gw-card__media` (+ `--wide` 1.91:1 for 1200 x 630 share images, `--square`; 16:9 by default): the
33
+ card's first child, edge to edge across its top, its corners the card's radius inside the hairline, the soft
34
+ ground while it loads.
35
+ Closes gap #33 (card with a thumbnail)
36
+ - Post list `.gw-post-list` (+ `--compact`) of `.gw-card.gw-post` (`__title`): a picture, the category as a
37
+ `.gw-label`, the title whose link covers the card (one target, named by the title alone), an excerpt and a meta
38
+ row; one, two (768px) and three (1080px) columns. Pagination `.gw-pagination` (+ `__status`): previous and next as
39
+ links, markup only.
40
+ - Prose tables: `website.js` puts each `<table>` straight inside a `.gw-prose` in a scroll box of its own,
41
+ `div.gw-prose__table`, a region named after the table's caption (else its first header cell, else its first
42
+ cell) with `tabindex="0"`, and marks the table `.is-wrapped`. The table keeps `display: table`, so its rows and
43
+ cells stay a table to assistive tech. Without the script, or before it runs, the table scrolls by itself as in
44
+ 0.2.0 (`:not(.is-wrapped)`); the box looks the same. `destroy` takes the box away again. A site's own wrapper for
45
+ bare tables can go. Issue #63 itself stays open until the box is heard with VoiceOver on Safari, as it asks (the
46
+ table semantics are checked in Chromium's accessibility tree only).
47
+ Closes gap #63 (prose tables that scroll without losing table semantics)
48
+ - `init(root)` and `destroy(root)` cover the two new parts like the others: wired once (`data-gw-ready="toc"` and
49
+ `"table"`), taken back by `destroy` (observer disconnected, mark removed, box removed), wired again by a later
50
+ `init`.
51
+ - Templates: `templates/sections/article.html` (breadcrumbs, title, meta, hero, contents, prose with callouts and a
52
+ table, FAQ last) and `templates/sections/post-list.html` (cards, pagination, the compact list). Demo:
53
+ `demo/article.html` (a whole post, then a post list) and `demo/blog.html` (the blog index), linked from
54
+ `demo/index.html`, with their drawn pictures in `demo/assets/`.
55
+ - `check`: `toc-target` (a contents link to an id that is not on the page) and `breadcrumbs-markup` (no `<nav>`,
56
+ no name, no `<ol>`, `aria-current="page"` missing from the last item or set on another, a typed separator) are
57
+ errors; `callout-label` (a callout without its label, told by colour alone) is a warning.
58
+ - Token: `--gw-warning-soft` (`#fbf1dc`, dark `#2b2410`), the warning's tint, next to `--gw-success-soft`.
59
+ - Budget: `js/website.js` is 13.8 KB unminified, 4.5 KB gzipped (0.4.0: 11.3 KB, 3.8 KB); the test now holds it
60
+ under 15 KB and 5 KB. `website.min.css` is 50.5 KB, 9.1 KB gzipped (0.4.0: 40.6 KB, 7.7 KB), most of it the
61
+ icons' masks, which gzip well.
62
+ - Tests: `test/run.mjs` covers the new script parts, rules and CSS in its fake DOM; `test/browser.mjs` drives the
63
+ demo in Chromium (wheel and jump scrolling, Tab and Enter through the contents, the fold below 1080px, the table's
64
+ box and accessibility tree at 390px, init twice, destroy then init, the soft and accent profiles). CI runs it in
65
+ the kit's job.
66
+ - `AGENTS.md`, the skill and the docs: an article page is `templates/sections/article.html` (breadcrumbs, meta,
67
+ contents from the h2s, prose, callouts); never a callout told by its colour alone.
68
+
69
+ ## 0.4.0 (2026-10-25)
70
+
71
+ The website script, standardised like the CSS: it now wires markup that arrives after the page loaded (a Next.js
72
+ route change, a GemPages block rendered later, a CMS preview), and comes as an ES module too. A page that loads
73
+ `js/website.js` with `defer` and changes nothing works exactly as in 0.3.0 (the demo is pixel-identical at 390 and
74
+ 1366px, light and dark).
75
+
76
+ Closes #64
77
+
78
+ - `gwWebsite.init(root = document)`: wires the kit's markup under `root`, `root` included. Idempotent: each element
79
+ is wired once (and marked `data-gw-ready`), so call it after every navigation or insert and only the new elements
80
+ are wired; on a fragment it wires that fragment only. The page's own listeners (Escape, the outside click, the
81
+ busy-button guard, the system theme, the scroll) are installed once. Each call lets go of elements removed from
82
+ the page meanwhile and moves the script's `aria-current` mark to the new path (a mark in the markup stays).
83
+ - `gwWebsite.destroy(root = document)`: removes what `init` wired under `root`, its listeners and its marks, for
84
+ frameworks that unmount. An open mega or mobile menu closes first, so the page is never left locked.
85
+ - ESM entry `@gemboss/website-ui/website.mjs` (`js/website.mjs`): `export { init, destroy, version }`, no side
86
+ effects on import (safe to import on a server), no auto-init. `js/website.js` keeps its path, its export
87
+ `./website.js` and its behaviour: it starts by itself on `DOMContentLoaded` and exposes `window.gwWebsite`.
88
+ - One source: `scripts/build.mjs` writes `js/website.js` from `js/website.mjs` (the code in an IIFE, without the
89
+ comments), and `--check` fails when they differ or when the version is not `package.json`'s.
90
+ - One copy per page: the first copy that runs owns `window.gwWebsite`; another one (the classic script next to a
91
+ bundle) hands its calls to it, so nothing is wired twice.
92
+ - Events, all bubbling, listed in `docs/markup-contract.md`: `gw:mega` on the trigger (`{ id, open }`) and
93
+ `gw:theme` on `<html>` (`{ theme: 'light' | 'dark' }`), next to `gw:segmented`.
94
+ - Budget: `js/website.js` is 11.3 KB unminified, 3.8 KB gzipped (0.3.0: 9.2 KB, 3.3 KB). The test now holds it under
95
+ 12 KB unminified and 4.5 KB gzipped (was 10 KB unminified): the comments live in `website.mjs`, the classic file
96
+ carries none.
97
+ - `AGENTS.md`, the skill and `docs/components.md`: never wire a kit part with your own JS; call `init(root)` after
98
+ inserting kit markup. README: the ESM import and the Next.js recipe (a client effect on each route change).
99
+
7
100
  ## 0.3.0 (2026-10-25)
8
101
 
9
102
  Brand profiles: three knobs so that another GemCommerce brand (GemPages first, Gemians later) can use the kit and
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 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 |
15
+ | `website.css`, `website.min.css` | toàn bộ kit trong một tệp (khoảng 68 KB, 51 KB bản min, 9 KB khi nén gzip): 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 (kèm ảnh trên đầu thẻ), khung cửa sổ kèm chú thích "Illustrative data.", bảng, FAQ, chữ bài dài (`.gw-prose`), bộ khung bài blog (breadcrumb, dòng tác giả và ngày, mục lục dính, hộp lưu ý, danh sách bài, phân trang), header + mega menu + menu điện thoại, footer, 14 section, 3 hồ sơ thương hiệu |
16
+ | `js/website.js`, `js/website.mjs` | mega menu, menu điện thoại, đổi giao diện sáng/tối, hiện dần khi cuộn, đánh dấu trang hiện tại, segmented control, chặn bấm lần hai khi nút đang chờ, đánh dấu mục đang đọc trong mục lục bài viết, khung cuộn cho bảng trong bài; không phụ thuộc thư viện nào, khoảng 4,5 KB khi nén gzip. `website.js` là script thường (tự chạy khi trang tải xong), `website.mjs` là bản ES module cho app; cả hai có `init(root)` để gắn lại phần markup chèn sau (`docs/markup-contract.md`, mục "Script") |
17
17
  | `AGENTS.md` | luật mà Claude Code (và người) phải theo: 10 luật, cách dựng trang, điều cấm, chữ, khả năng truy cập, đổi thương hiệu, khi kit thiếu |
18
- | `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ơ |
18
+ | `templates/` | khung một trang và 14 section (hero, logos, stats, features, split, showcase, steps, quote, pricing, faq, cta, prose, article, post-list) |
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ơ; `article.html` là một bài blog trọn vẹn, `blog.html` là trang danh sách bài |
20
20
  | `docs/` | danh mục class (`components.md`), đổi thương hiệu (`theming.md`), triết lý (`philosophy.md`), hợp đồng markup |
21
21
  | `system.json` | mọi giá trị của hệ thống (màu, khoảng cách, chữ, bo góc, bóng, khung): nguồn duy nhất |
22
22
  | CLI `gemboss-website` | `init`, `check`, `gap`, `upgrade` |
@@ -40,33 +40,55 @@ nó đọc `AGENTS.md` của kit trước, dựng từ mẫu, và chạy `check`
40
40
  **Site HTML tĩnh hoặc không có bước build:** dùng CDN của npm, ghim đúng phiên bản.
41
41
 
42
42
  ```html
43
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.3.0/website.min.css">
43
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.5.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.3.0/js/website.js" defer></script>
45
+ <script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.5.0/js/website.js" defer></script>
46
46
  ```
47
47
 
48
- **Next.js (App Router):** nạp CSS trong layout gốc, nạp script bằng `next/script`.
48
+ Chèn markup của kit sau khi trang đã tải (block của builder, bản xem trước CMS): gọi `gwWebsite.init(phầnMới)` ngay
49
+ sau đó. Đừng tự viết JS cho mega menu, nút đổi giao diện hay segmented control.
50
+
51
+ **Next.js (App Router):** nạp CSS trong layout gốc; script là bản ES module, gọi `init()` trong một effect mỗi lần
52
+ đổi trang (chuyển trang phía client không tải lại trang, nên phần markup mới phải được gắn lại).
53
+
54
+ ```js
55
+ import { init, destroy } from "@gemboss/website-ui/website.mjs";
56
+ ```
57
+
58
+ ```js
59
+ // app/kit-script.jsx
60
+ "use client";
61
+ import { useEffect } from "react";
62
+ import { usePathname } from "next/navigation";
63
+ import { init } from "@gemboss/website-ui/website.mjs";
64
+
65
+ export default function KitScript() {
66
+ const pathname = usePathname();
67
+ useEffect(() => { init(); }, [pathname]);
68
+ return null;
69
+ }
70
+ ```
49
71
 
50
72
  ```js
51
73
  // app/layout.js
52
74
  import "@gemboss/website-ui/website.css";
53
75
  import "./site.css";
54
- import Script from "next/script";
76
+ import KitScript from "./kit-script";
55
77
 
56
78
  export default function RootLayout({ children }) {
57
79
  return (
58
80
  <html lang="vi">
59
81
  <body className="gw-page">
60
82
  {children}
61
- <Script src="/kit/website.js" strategy="afterInteractive" />
83
+ <KitScript />
62
84
  </body>
63
85
  </html>
64
86
  );
65
87
  }
66
88
  ```
67
89
 
68
- Chép `node_modules/@gemboss/website-ui/js/website.js` vào `public/kit/` (hoặc phục vụ nó bằng một route). Font Inter
69
- nạp từ Google Fonts như trong `templates/page.html`.
90
+ `init()` gọi lại bao nhiêu lần cũng được: phần đã gắn thì bỏ qua, chỉ gắn phần mới. Font Inter nạp từ Google Fonts
91
+ như trong `templates/page.html`.
70
92
 
71
93
  **Bắt đầu một trang:** chép `templates/page.html`, điền header và footer một lần (chung cho mọi trang), ghép các
72
94
  section trong `templates/sections/` theo thứ tự trang cần, thay mọi chỗ `[trong ngoặc]`, xoá phần không dùng. Mở
@@ -117,8 +139,9 @@ gemboss-www; mọi thay đổi đi qua PR, giá trị của hệ thống chỉ s
117
139
 
118
140
  ```sh
119
141
  cd packages/website-ui
120
- node scripts/build.mjs # sinh css/tokens.css từ system.json, ghép website.css và website.min.css
121
- node test/run.mjs # tự kiểm CLI
142
+ node scripts/build.mjs # sinh css/tokens.css từ system.json, ghép website.css và website.min.css, sinh js/website.js từ js/website.mjs
143
+ node test/run.mjs # tự kiểm CLI, CSS và website.js (DOM giả)
144
+ node test/browser.mjs # bài viết demo trong Chromium thật: cuộn, Tab, mục lục gập, bảng (cần .ci của repo)
122
145
  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)
123
146
  ```
124
147
 
package/bin/lib/check.mjs CHANGED
@@ -129,6 +129,16 @@ export const RULES = {
129
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
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
131
  },
132
+ "toc-target": {
133
+ level: "error", title: "Contents link to a heading that is not on the page",
134
+ why: "A .gw-toc link whose #id names no element on the page jumps nowhere, and website.js cannot mark its section. It happens when a heading is renamed or removed and the list is not.",
135
+ fix: "Give the heading the id the link names (<h2 id=\"…\">), or point the link at the heading's id. Build the list from the article's own h2s (docs/components.md, Contents).",
136
+ },
137
+ "breadcrumbs-markup": {
138
+ level: "error", title: "Breadcrumbs without their markup",
139
+ why: "A screen reader announces the trail from its markup: a <nav> with a name around an <ol>, the last item marked aria-current=\"page\". The kit draws the separators; a › or / typed between the links is read out as a word.",
140
+ fix: "Copy the markup from docs/components.md (Breadcrumbs) or templates/sections/article.html: <nav class=\"gw-breadcrumbs\" aria-label=\"Breadcrumb\"><ol><li><a href=\"/blog\">Blog</a></li><li><a href=\"…\" aria-current=\"page\">This page</a></li></ol></nav>.",
141
+ },
132
142
  "uppercase-text": {
133
143
  level: "warning", title: "Uppercase text",
134
144
  why: "Headings, buttons, labels and menu items are sentence case.",
@@ -139,6 +149,11 @@ export const RULES = {
139
149
  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
150
  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
151
  },
152
+ "callout-label": {
153
+ level: "warning", title: "Callout that tells its kind by colour only",
154
+ why: "A note, a tip and a warning differ by their tint, which a colour-blind reader, a screen reader and a printed page do not get. The kind has to be in words.",
155
+ fix: "Start the callout with <p class=\"gw-callout__label\">Note</p> (Tip, Warning, or the same word in the page's language).",
156
+ },
142
157
  "possible-gap": {
143
158
  level: "note", title: "Possible kit gap: a site class used on several pages",
144
159
  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.",
@@ -519,6 +534,7 @@ function analyzeHtml(file, text, out) {
519
534
  }
520
535
 
521
536
  const classes = new Set();
537
+ const ids = new Set(), tocs = [];
522
538
  let h1 = 0;
523
539
  for (const m of scanText.matchAll(TAG_RE)) {
524
540
  const tag = m[1].toLowerCase();
@@ -536,6 +552,7 @@ function analyzeHtml(file, text, out) {
536
552
  else if (p.kind === "action") out.pageProfiles.push({ file: relFile, line, action: v });
537
553
  }
538
554
  if ((tag === "div" || tag === "span") && "onclick" in a) add("clickable-div", where());
555
+ if (a.id) ids.add(a.id);
539
556
  for (const k of ["alt", "title", "aria-label", "placeholder"]) {
540
557
  if (a[k] && hasEmDash(a[k])) add("em-dash", where(`${k}="${a[k]}"`));
541
558
  }
@@ -565,6 +582,28 @@ function analyzeHtml(file, text, out) {
565
582
  ].filter(Boolean);
566
583
  if (missing.length) add("segmented-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
567
584
  }
585
+ if (cls.includes("gw-toc")) {
586
+ const end = elementEnd(scanText, m.index, tag);
587
+ if (end > 0) tocs.push({ start: m.index, end });
588
+ }
589
+ if (cls.includes("gw-breadcrumbs")) {
590
+ const body = inner();
591
+ const items = [...body.matchAll(/<li\b[^>]*>[\s\S]*?<\/li\s*>/gi)].map((x) => x[0]);
592
+ const current = (li) => /\baria-current\s*=\s*["']?page\b/i.test(li);
593
+ const words = decode(body.replace(/<[^>]*>/g, " "));
594
+ const missing = [
595
+ tag !== "nav" && "a <nav> (the class is on <" + tag + ">)",
596
+ tag === "nav" && !(a["aria-label"] || a["aria-labelledby"]) && "aria-label on the <nav>",
597
+ !/<ol\b/i.test(body) && "an <ol> of <li> items",
598
+ items.length && !current(items[items.length - 1]) && 'aria-current="page" on the last item',
599
+ items.slice(0, -1).some(current) && 'aria-current="page" on the last item only',
600
+ /(?:^|\s)(?:›|»|>|\/|\||·|•|→)(?=\s|$)/.test(words) && "no separator characters in the text (the kit draws them)",
601
+ ].filter(Boolean);
602
+ if (missing.length) add("breadcrumbs-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: ${missing.join(", ")}`));
603
+ }
604
+ if (cls.includes("gw-callout") && !/\bclass\s*=\s*["'][^"']*\bgw-callout__label\b/i.test(inner())) {
605
+ add("callout-label", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} ${decode(visible.slice(m.index, elementEnd(scanText, m.index, tag))).replace(/\s+/g, " ").trim().slice(0, 60)}`));
606
+ }
568
607
  if (cls.includes("gw-window")) {
569
608
  const end = elementEnd(scanText, m.index, tag);
570
609
  const body = end < 0 ? "" : scanText.slice(m.index, end);
@@ -594,6 +633,14 @@ function analyzeHtml(file, text, out) {
594
633
  if (shouty(txt)) add("uppercase-text", where(`${m[0].slice(0, 60)}${txt.trim()}`));
595
634
  }
596
635
  }
636
+ // Contents links point at ids of this page (an id may come after the list: checked once the page is read).
637
+ for (const t of tocs) {
638
+ for (const x of scanText.slice(t.start, t.end).matchAll(/<a\b[^>]*?\bhref\s*=\s*["']#([^"']*)["']/gi)) {
639
+ let id = x[1];
640
+ try { id = decodeURIComponent(id); } catch { /* as written */ }
641
+ if (!ids.has(id)) add("toc-target", { file: relFile, line: lineAt(t.start + x.index), excerpt: `href="#${x[1]}": no element on the page has id="${id}"` });
642
+ }
643
+ }
597
644
  out.pageClasses.push({ file: relFile, classes });
598
645
  }
599
646
 
@@ -0,0 +1,237 @@
1
+ /* article (0.5.0): the parts around a long read and the blog index. Breadcrumbs, the meta row, the article layout with
2
+ its contents list (.gw-toc), callouts, the post list and prev / next pagination. The article's own HTML stays in
3
+ .gw-prose (prose.css); .gw-card__media is in components.css. */
4
+
5
+ /* breadcrumbs: a nav with an <ol>, the last item aria-current="page". The separator is drawn here (a chevron mask,
6
+ no character), so screen readers read the trail and not the punctuation. The current item stays on one line and
7
+ is cut with an ellipsis on a phone: the h1 right under it says it in full. */
8
+ .gw-breadcrumbs { color: var(--gw-muted); font-size: 13px; line-height: 1.5; }
9
+ .gw-breadcrumbs > ol { display: flex; flex-wrap: wrap; align-items: center; gap: 0 8px; margin: 0; padding: 0; list-style: none; }
10
+ .gw-breadcrumbs li { display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; }
11
+ .gw-breadcrumbs li + li::before {
12
+ content: "";
13
+ flex: none;
14
+ width: 12px;
15
+ height: 12px;
16
+ background: currentColor;
17
+ opacity: 0.6;
18
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 6l6 6-6 6' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
19
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 6l6 6-6 6' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
20
+ }
21
+ .gw-breadcrumbs a { display: inline-flex; align-items: center; min-height: 24px; color: var(--gw-muted); }
22
+ .gw-breadcrumbs a:hover { color: var(--gw-ink); text-decoration: underline; text-underline-offset: 4px; }
23
+ .gw-breadcrumbs [aria-current="page"] {
24
+ display: block;
25
+ min-width: 0;
26
+ overflow: hidden;
27
+ color: var(--gw-ink);
28
+ font-weight: 500;
29
+ line-height: 24px;
30
+ text-overflow: ellipsis;
31
+ white-space: nowrap;
32
+ }
33
+
34
+ /* meta row: author (an optional 24px avatar), category, published or updated <time datetime>, read time, each item a
35
+ <span> (or a <time>, an <a>) straight in the row. Small muted text on 24px lines that wraps. A dot sits before every
36
+ item; the row is pulled 16px to the left and clipped there, so the dot before the first item of each line is hidden
37
+ and a wrapped line never starts with a separator. */
38
+ .gw-article-meta {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: center;
42
+ row-gap: 4px;
43
+ margin-inline-start: -16px;
44
+ clip-path: inset(-8px -8px -8px 12px);
45
+ color: var(--gw-muted);
46
+ font-size: 14px;
47
+ line-height: 24px;
48
+ }
49
+ .gw-article-meta > *::before {
50
+ content: "";
51
+ display: inline-block;
52
+ width: 4px;
53
+ height: 4px;
54
+ margin-inline: 6px;
55
+ border-radius: var(--gw-radius-chip);
56
+ background: var(--gw-line-strong);
57
+ vertical-align: middle;
58
+ }
59
+ .gw-article-meta a {
60
+ display: inline-block;
61
+ color: var(--gw-accent-strong);
62
+ font-weight: 500;
63
+ text-decoration: underline;
64
+ text-decoration-thickness: 1px;
65
+ text-underline-offset: 4px;
66
+ text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
67
+ }
68
+ .gw-article-meta a:hover { text-decoration-color: currentColor; }
69
+ .gw-article-meta__avatar { display: inline-block; width: 24px; height: 24px; margin-inline-end: 8px; border-radius: var(--gw-radius-chip); object-fit: cover; vertical-align: top; }
70
+
71
+ /* the article page (templates/sections/article.html): <article class="gw-wrap gw-article"> holding __head
72
+ (breadcrumbs, h1, meta), an optional __hero image, the .gw-toc and the .gw-prose, in that order. On phones one
73
+ column, the contents folded above the prose. From 1080px (the kit's desktop width, where the header shows its nav)
74
+ two columns: the reading column (68ch, the prose measure) and the contents beside it, sticky under the header. */
75
+ .gw-article { padding-block: 32px 64px; }
76
+ .gw-article__head > * + * { margin-top: 16px; }
77
+ .gw-article__head h1 { font-size: var(--gw-display-h2); }
78
+ .gw-article__hero { margin-top: 32px; }
79
+ .gw-article__hero img { width: 100%; border-radius: var(--gw-radius-card); background: var(--gw-bg-soft); }
80
+ .gw-article__hero figcaption { margin-top: 12px; color: var(--gw-muted); font-size: 14px; line-height: 1.5; }
81
+ .gw-article > .gw-toc { margin-top: 32px; }
82
+ .gw-article > .gw-prose { margin-top: 40px; }
83
+ @media (min-width: 768px) {
84
+ .gw-article { padding-block: 48px 96px; }
85
+ .gw-article__hero { margin-top: 40px; }
86
+ }
87
+ @media (min-width: 1080px) {
88
+ .gw-article {
89
+ display: grid;
90
+ grid-template-columns: minmax(0, 68ch) minmax(200px, 240px);
91
+ grid-template-rows: auto auto 1fr;
92
+ justify-content: space-between;
93
+ column-gap: 64px;
94
+ }
95
+ .gw-article > * { grid-column: 1; }
96
+ .gw-article > .gw-article__head { grid-row: 1; }
97
+ .gw-article > .gw-article__hero { grid-row: 2; }
98
+ .gw-article > .gw-prose { grid-row: 3; }
99
+ .gw-article > .gw-toc { grid-column: 2; grid-row: 2 / 4; margin-top: 40px; }
100
+ }
101
+
102
+ /* contents: <nav class="gw-toc" aria-label="On this page" data-gw-toc> holding a <details>, its <summary> and an <ol>
103
+ of links to the article's h2s (a nested <ol> for h3s). Plain links: they work without the script. website.js marks
104
+ the section in view (aria-current="true", the accent bar) and opens the <details> from 1080px, where the list sits
105
+ beside the article and sticks under the header; below 1080px it is folded above the article and a link closes it.
106
+ The weight never changes with the mark, so the list does not move. */
107
+ .gw-toc { font-size: 14px; line-height: 1.45; }
108
+ .gw-toc > details { border: 1px solid var(--gw-line); border-radius: var(--gw-radius-card); background: var(--gw-surface); }
109
+ .gw-toc summary {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ gap: 12px;
114
+ min-height: var(--gw-tap-min);
115
+ padding: 8px 16px;
116
+ color: var(--gw-ink);
117
+ font-weight: 600;
118
+ list-style: none;
119
+ cursor: pointer;
120
+ }
121
+ .gw-toc summary::-webkit-details-marker { display: none; }
122
+ .gw-toc summary::after {
123
+ content: "";
124
+ flex: none;
125
+ width: 12px;
126
+ height: 12px;
127
+ background: currentColor;
128
+ opacity: 0.6;
129
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
130
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
131
+ transition: transform 0.18s var(--gw-ease);
132
+ }
133
+ .gw-toc details[open] > summary::after { transform: rotate(180deg); }
134
+ .gw-toc ol { margin: 0; padding: 0; list-style: none; }
135
+ /* the rail is a background, so the list can scroll inside itself without clipping the bar of the marked link */
136
+ .gw-toc details > ol { margin: 0 16px 16px; background: linear-gradient(var(--gw-line), var(--gw-line)) 0 0 / 1px 100% no-repeat; }
137
+ .gw-toc a {
138
+ display: block;
139
+ padding: 6px 8px 6px 16px;
140
+ border-left: 2px solid transparent;
141
+ color: var(--gw-muted);
142
+ }
143
+ .gw-toc ol ol a { padding-left: 32px; }
144
+ .gw-toc a:hover { color: var(--gw-ink); }
145
+ .gw-toc a:focus-visible { outline-offset: -2px; }
146
+ .gw-toc a[aria-current="true"] { border-left-color: var(--gw-accent); color: var(--gw-ink); }
147
+ /* from 1080px: sticky beside the article; a long list scrolls inside itself, under the summary. Under the floating
148
+ header it sticks below the card. */
149
+ @media (min-width: 1080px) {
150
+ .gw-toc { position: sticky; top: calc(var(--gw-header-height) + 32px); align-self: start; }
151
+ .gw-toc > details { border: 0; border-radius: var(--gw-radius-0); background: transparent; }
152
+ .gw-toc summary { min-height: 32px; padding: 0; color: var(--gw-muted); font-size: 13px; }
153
+ .gw-toc details > ol {
154
+ max-height: calc(100vh - var(--gw-header-height) - 120px);
155
+ margin: 8px 0 0;
156
+ overflow-y: auto;
157
+ overscroll-behavior: contain;
158
+ }
159
+ :root:has(.gw-header--float) .gw-toc { top: calc(var(--gw-header-height) + var(--gw-header-gap) + 32px); }
160
+ :root:has(.gw-header--float) .gw-toc details > ol { max-height: calc(100vh - var(--gw-header-height) - var(--gw-header-gap) - 120px); }
161
+ }
162
+ /* a contents link glides to its section, except for a visitor who asked for less motion (base.css stops it too) */
163
+ @media (prefers-reduced-motion: no-preference) {
164
+ :root:has(.gw-toc) { scroll-behavior: smooth; }
165
+ }
166
+
167
+ /* callout: a note, a tip or a warning inside an article. The kind is said in words by __label (Note, Tip, Warning,
168
+ or the reader's language), with a 16px icon before it; the tint only repeats it. Tints are the kit's: the accent's,
169
+ success's and warning's -soft grounds; the icon takes the solid hue. role="note" on the box. */
170
+ .gw-callout {
171
+ padding: 16px 20px;
172
+ border-radius: var(--gw-radius-card);
173
+ background: var(--gw-accent-soft);
174
+ color: var(--gw-ink);
175
+ font-size: 16px;
176
+ line-height: 1.6;
177
+ }
178
+ .gw-callout > * + * { margin-top: 8px; }
179
+ .gw-callout--tip { background: var(--gw-success-soft); }
180
+ .gw-callout--warning { background: var(--gw-warning-soft); }
181
+ .gw-callout__label { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; line-height: 1.5; }
182
+ .gw-callout__label::before {
183
+ content: "";
184
+ flex: none;
185
+ width: 16px;
186
+ height: 16px;
187
+ background: var(--gw-accent);
188
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 11v5M12 7.5v.5'/%3E%3C/svg%3E") center / 16px no-repeat;
189
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 11v5M12 7.5v.5'/%3E%3C/svg%3E") center / 16px no-repeat;
190
+ }
191
+ .gw-callout--tip > .gw-callout__label::before {
192
+ background: var(--gw-success);
193
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.7.5 1.1 1.3 1.1 2.2h5c0-.9.4-1.7 1.1-2.2A6 6 0 0 0 12 3z'/%3E%3C/svg%3E");
194
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.7.5 1.1 1.3 1.1 2.2h5c0-.9.4-1.7 1.1-2.2A6 6 0 0 0 12 3z'/%3E%3C/svg%3E");
195
+ }
196
+ .gw-callout--warning > .gw-callout__label::before {
197
+ background: var(--gw-warning);
198
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5 2.8 19.5h18.4z'/%3E%3Cpath d='M12 10v4M12 16.8v.2'/%3E%3C/svg%3E");
199
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5 2.8 19.5h18.4z'/%3E%3Cpath d='M12 10v4M12 16.8v.2'/%3E%3C/svg%3E");
200
+ }
201
+
202
+ /* post list: the blog index and "more from the blog". A <ul role="list"> of post cards: .gw-card with a
203
+ .gw-card__media first, a .gw-label for the category, the title (its link covers the card, so the card is one target
204
+ and the link's name is the title alone), one or two lines of excerpt and a .gw-article-meta row at the foot. One
205
+ column on phones, two from 768px, three from 1080px. --compact: one column of rows, no card and no picture. */
206
+ .gw-post-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; margin: 0; padding: 0; list-style: none; }
207
+ .gw-post-list > li { display: flex; min-width: 0; }
208
+ .gw-post { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; }
209
+ .gw-post:hover { border-color: var(--gw-line-strong); }
210
+ .gw-post > .gw-card__media + * { margin-top: 20px; }
211
+ .gw-post > .gw-label + .gw-post__title { margin-top: 8px; }
212
+ .gw-post__title { font-size: 20px; line-height: 1.3; }
213
+ .gw-post__title a { color: var(--gw-ink); }
214
+ .gw-post__title a::after { content: ""; position: absolute; inset: 0; z-index: 1; border-radius: var(--gw-radius-card); }
215
+ .gw-post:hover .gw-post__title a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }
216
+ .gw-post__title a:focus-visible { outline: 0; }
217
+ .gw-post__title a:focus-visible::after { outline: 2px solid var(--gw-accent); outline-offset: 2px; }
218
+ .gw-post__title + p { margin-top: 8px; }
219
+ .gw-post > .gw-article-meta { margin-top: auto; padding-top: 16px; }
220
+ @media (min-width: 768px) {
221
+ .gw-post-list { gap: 24px; }
222
+ .gw-post-list:not(.gw-post-list--compact) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
223
+ }
224
+ @media (min-width: 1080px) {
225
+ .gw-post-list:not(.gw-post-list--compact) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
226
+ }
227
+ .gw-post-list--compact { gap: 0; border-top: 1px solid var(--gw-line); }
228
+ .gw-post-list--compact .gw-post { padding: 20px 0; border: 0; border-bottom: 1px solid var(--gw-line); border-radius: var(--gw-radius-0); background: transparent; box-shadow: none; }
229
+ .gw-post-list--compact .gw-post__title a::after { border-radius: var(--gw-radius-0); }
230
+ .gw-post-list--compact .gw-card__media { display: none; }
231
+ .gw-post-list--compact .gw-post > .gw-article-meta { padding-top: 8px; }
232
+
233
+ /* pagination: previous and next as links, a muted "Page 2 of 12" between them. Markup only, no script. */
234
+ .gw-pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
235
+ * + .gw-pagination { margin-top: 40px; }
236
+ .gw-pagination__status { color: var(--gw-muted); font-size: 14px; }
237
+ .gw-pagination > [rel="next"] { margin-inline-start: auto; }
@@ -185,6 +185,21 @@
185
185
  .gw-card--soft { background: var(--gw-bg-soft); box-shadow: none; }
186
186
  .gw-card--link { display: block; color: inherit; }
187
187
  .gw-card--link:hover { border-color: var(--gw-line-strong); }
188
+ /* a card's picture (0.5.0, gap #33): the first child of the card, edge to edge across its top, its corners the card's
189
+ radius inside the hairline. An aspect-ratio box (16:9; --wide 1.91:1, the share-image shape, so a post's own
190
+ 1200 x 630 image fits uncut; --square 1:1) that the image covers, on the soft ground while it loads. */
191
+ .gw-card__media {
192
+ overflow: hidden;
193
+ aspect-ratio: 16 / 9;
194
+ margin: -24px -24px 0;
195
+ border-top-left-radius: calc(var(--gw-radius-card) - 1px);
196
+ border-top-right-radius: calc(var(--gw-radius-card) - 1px);
197
+ background: var(--gw-bg-soft);
198
+ }
199
+ .gw-card__media--wide { aspect-ratio: 1200 / 630; }
200
+ .gw-card__media--square { aspect-ratio: 1 / 1; }
201
+ .gw-card__media :is(img, video) { width: 100%; height: 100%; object-fit: cover; }
202
+ @media (min-width: 768px) { .gw-card__media { margin: -32px -32px 0; } }
188
203
  :where(.gw-card, .gw-feature, .gw-plan) > :is(h3, h4) + p { margin-top: 8px; }
189
204
  :where(.gw-card, .gw-feature) h3 { font-size: 20px; }
190
205
  :where(.gw-card, .gw-feature) p { font-size: 16px; color: var(--gw-muted); }