@gemboss/website-ui 0.12.0 → 0.14.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
@@ -17,6 +17,7 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
17
17
  | `templates/header.html` | the header of every site, with its two options (the drill-down mobile menu, the language switcher): fill it once, in the site's shared partial |
18
18
  | `templates/footer.html` | the footer of every site (the footer of gemboss.ai): fill it once, in the site's shared partial |
19
19
  | `templates/sections/*.html` | ready sections: hero, features, split, showcase, logos, stats, steps, pricing, FAQ, CTA, prose, article, post list, a short form, testimonials… |
20
+ | `templates/site/` | 0.13.0: what a new site owns from the start (the Next.js wrapper that serves its static pages, its page list, its public allow-list, `site.css`, `kit-gaps.css`): `npx gemboss-website init --site` writes it |
20
21
  | `demo/index.html` | every component on one page: open it to see what exists before you build anything |
21
22
  | `demo/brand-profiles.html` | a GemPages-style brand: its accent, the accent button, soft corners, the floating header |
22
23
  | `demo/article.html`, `demo/blog.html` | a whole blog post (breadcrumbs, meta, contents, prose, callouts, FAQ) and the blog index (post list, pagination) |
@@ -39,13 +40,14 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
39
40
  square and a chart line. **The accent is never body text, never words in a heading, never a section band, and
40
41
  never a fill you set yourself.**
41
42
  3. **Actions are rectangles, chips are pills.** Buttons: 40px tall, sentence case, 14px/600, the action radius of the
42
- site's profile (8px; 12px in `soft`). Chips, toggles, badges: fully rounded. Cards: the card radius (12px; 20px in
43
+ site's profile (8px; 12px in `soft`). Chips, toggles, badges: fully rounded; a status chip inside a drawn screen
44
+ (`.gw-status`, 0.14.0) takes the action radius, as the product draws it. Cards: the card radius (12px; 20px in
43
45
  `soft`), large panels the panel radius (16px; 24px). No other radii: the site picks its profile once
44
46
  (`data-gw-radius`), components follow.
45
47
  4. **A card sits.** A card (`.gw-card`, a plan) is a white box with a 1px `--gw-line` border and the resting shadow
46
48
  `--gw-shadow`, the same as the GemBoss dashboard. It never lifts on hover. To separate anything else, use space or a
47
49
  hairline before a shadow. The only shadows are `--gw-shadow` (rest), `--gw-shadow-sm` and `--gw-shadow-raised`
48
- (popovers: the mega menu, menus).
50
+ (popovers: the mega menu, menus; and a window, `.gw-window`, which floats over its section as on gemboss.ai).
49
51
  5. **Only the scales.** Spacing: 0 2 4 6 8 12 16 20 24 32 40 48 64 80 96 120 (px). Type: 11 12 13 14 16 18 20 22 24 30,
50
52
  plus the two display sizes for h1/h2. No half pixels, no "just 15px", no colour outside the tokens.
51
53
  6. **Sentence case everywhere.** Headings, buttons, labels, menu items. No uppercase with letter spacing.
@@ -60,6 +62,13 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
60
62
 
61
63
  ## 2. Build a page
62
64
 
65
+ **A new site** (0.13.0, the way gemboss.ai and gemians.com are built): `npx gemboss-website init --site` in an empty
66
+ folder writes the header and footer partials, `index.html` and `404.html`, `assets/site.css`, `assets/kit-gaps.css` and
67
+ the Next.js wrapper that serves the static pages, and adds the scripts to `package.json`. Then each page is a file with
68
+ `<!-- gw:header -->` and `<!-- gw:footer -->` markers, and `npx gemboss-website build` writes the partials into every
69
+ page (with `aria-current="page"` on its own links) and copies the kit into `assets/kit/`. Never hand-copy a header into
70
+ a page. A page of such a site starts from `index.html`, not from `templates/page.html`.
71
+
63
72
  1. Copy `templates/page.html` into the site. It links `website.css` and `website.js`, has the skip link, the
64
73
  header, the `<main>` and the footer. Fill the header and footer once (they are the same on every page).
65
74
  **The footer comes from `templates/footer.html`.** A brand fills its rows (the columns, the family rows, the
@@ -83,7 +92,7 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
83
92
  - **Colour roles** (CSS variables, light and dark): `--gw-bg`, `--gw-bg-soft` (the neutral raised fill),
84
93
  `--gw-surface`, `--gw-ink`, `--gw-ink-2`, `--gw-muted`, `--gw-line`, `--gw-line-strong`, `--gw-action`,
85
94
  `--gw-on-action`, `--gw-accent`, `--gw-accent-strong`, `--gw-accent-soft`, `--gw-success`, `--gw-warning` (and
86
- their tints `--gw-success-soft`, `--gw-warning-soft`), `--gw-danger` (an error, a destructive button; `--gw-danger-soft`,
95
+ their tints `--gw-success-soft`, `--gw-warning-soft`; success's text form `--gw-success-strong`, 0.14.0), `--gw-danger` (an error, a destructive button; `--gw-danger-soft`,
87
96
  `--gw-on-danger`), `--gw-line-control` (a form control's border, 3:1), `--gw-night` (the dark band). Wash hues `--gw-wash-pink|violet|sky|mint` exist for the hero wash and illustrations only.
88
97
  - **Dark mode** follows the visitor's system and the theme toggle (`data-theme` on `<html>`). Use the variables and
89
98
  it works; a hard-coded colour breaks it.
@@ -225,7 +234,9 @@ A brand other than GemBoss chooses **only** these, and nothing else:
225
234
  Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses as soon as the
226
235
  theme toggle is pressed. Accent text uses `--gw-accent-strong`, so it must reach 4.5:1 on `--gw-bg` and
227
236
  `--gw-bg-soft`;
228
- - **the wash hues**, **the logo** in the header and footer, and **the favicon**;
237
+ - **the wash hues**, **the logo** in the header and footer (a mark beside the name, or one drawing of both:
238
+ `.gw-brand__logo`, with a second file for dark grounds when the brand has one), and **the favicon**;
239
+ - **the announcement bar's and the Stage's colours** (`--gw-announce-*`, `--gw-stage-*`), only when it shows them;
229
240
  - **three named profiles**, once for the site, never free values:
230
241
  - action colour: `data-gw-action="accent"` on `<html>` fills the primary button with the accent (default `ink`).
231
242
  Its words (`--gw-on-accent`) must reach 4.5:1 on `--gw-accent` and `--gw-accent-strong`, light and dark, or
@@ -242,7 +253,9 @@ examples: `demo/theme-example.html` (accent only) and `demo/brand-profiles.html`
242
253
  - Link the kit, then the site's own CSS after it: `website.css` → `site.css`.
243
254
  - The site's own CSS is for content the kit cannot know (an illustration, a product mock-up) and uses only the
244
255
  tokens. Its classes start with the site's prefix (for example `gp-` on GemPages), never `gw-`.
245
- - Shared header and footer markup lives in one place in the site (a partial or component), not copied per page.
256
+ - Shared header and footer markup lives in one place in the site (a partial or component), not copied per page:
257
+ `partials/header.html` and `partials/footer.html`, written into the pages by `npx gemboss-website build` (CI runs
258
+ `build --check`, which fails when a page is out of date).
246
259
 
247
260
  ## 9. When the kit is missing something
248
261
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,71 @@ 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.14.0 (2026-10-27)
8
+
9
+ The blocks gemboss.ai shares between its pages, 1:1 in the kit: the FAQ, the check list, the window around a product
10
+ screen, and a new status chip (Chris 26/10/2026: step 2 of the pages' bodies onto the kit). gemboss.ai's pages now draw
11
+ these with the kit's classes and were measured against the pages before, pixel by pixel. Where gemboss.ai had a small
12
+ accessibility slip, the kit's version won (Chris 27/10/2026): those three spots are named below. A minor version: a site
13
+ on 0.13.0 sees these parts change look, none of the markup.
14
+
15
+ - **FAQ** (`.gw-faq`): an item 2px 24px inside; the question in the brand face alone (`--gw-font-action`) at the line
16
+ height around it; the + is the face's own glyph in the accent, 24px, turned 45 degrees into × when open (was drawn
17
+ lines); an answer's paragraph at most 33em (was 68ch) and rising 4px into place as the item opens (none under reduced
18
+ motion). On phones (up to 560px) the rows tighten to 4px above and below, never under 44px (gemboss.ai went to 37px:
19
+ the kit's 44px won).
20
+ - **Check list** (`.gw-list--check`): 14px at 1.5 lines, the words 28px in, the mark a 16px tile on the accent's tint with
21
+ the tick in the accent (was a bare tick). The tick follows the theme and the brand (gemboss.ai's was a fixed iris, and
22
+ ink on /partners, so it stayed light in dark mode: the kit's won). A plain link in an item keeps the link style
23
+ (/audit showed one as plain text: the kit's won).
24
+ - **Window** (`.gw-window`): it floats (`--gw-shadow-raised`, was the resting shadow), 13px at 20px lines. The bar is
25
+ 40px with 12px sides, three 8px dots 6px apart drawn as real circles, the words 16px after them, `<b>` in ink, one line
26
+ cut when too long; up to 560px the words wrap and the bar grows, its parts at the top.
27
+ - **Status chip** (`.gw-status`, new): gemboss.ai's "pills", a state inside a drawn screen as the GemBoss app draws it: a
28
+ rounded rectangle at the action radius, 28px, 12px/500; `--success` (live), `--accent` (a test), `--early` (neutral,
29
+ the dot in the accent), an optional `__dot`. A badge (`.gw-badge`) stays the pill that labels a thing on the page.
30
+ - **Token** `--gw-success-strong` (new, `system.json` `color.light/dark`): success's text form, `#0a6a4c` light (5.6:1 on
31
+ its tint) and `#34c38f` dark, the words of a live status chip.
32
+ - Docs: `AGENTS.md` lists the window among the raised shadows and the status chip among the shapes; `docs/components.md`
33
+ describes each part's measures.
34
+
35
+ ## 0.13.0 (2026-10-26)
36
+
37
+ What building a second site on the kit taught it. gemians.com was rebuilt on 0.12.0 from npm, kit sections only (Chris,
38
+ 26/10/2026: inherit from the GemBoss website kit, fix the kit where it gets in the way, then update Gemians and
39
+ GemBoss), and every place the kit fell short became one of these. A minor version: nothing a 0.12.0 site uses changes
40
+ its look, except a list right under a section's head (below), which now gets the head's spacing it was always meant to.
41
+
42
+ - **A new site in one command**: `npx gemboss-website init --site` writes what gemboss.ai and gemians.com both had to
43
+ write by hand: the header and footer partials (from the kit's templates), `index.html` and `404.html` with the
44
+ build's markers, `assets/site.css` (the brand's `:root` block, commented) and `assets/kit-gaps.css`, and the Next.js
45
+ wrapper that serves the static pages byte for byte (`app/[[...path]]/route.js`, `lib/pages.mjs`, `next.config.mjs`
46
+ with clean URLs, `stage-public.mjs` with its allow-list, `vercel.json`, `.gitignore`), from the new
47
+ `templates/site/`. It never overwrites a file and adds the scripts a site needs to `package.json`.
48
+ - **`npx gemboss-website build`** (new): copies the kit into `assets/kit/` as published, writes `partials/header.html`
49
+ and `partials/footer.html` into every page between `<!-- gw:header -->` and `<!-- gw:footer -->` (their leading
50
+ comments, the templates' notes, stay out), puts `aria-current="page"` on the header's links to that page, and the
51
+ kit's version on its two links (`?v=0.13.0`). `--check` writes nothing and fails when a page is out of date (CI).
52
+ One build for every site, so a fix reaches all of them with the kit, instead of a script per site that drifts.
53
+ - **A logo drawn as one file** (`.gw-brand__logo`, new): the mark and the wordmark together, 26px tall, in place of
54
+ `__mark` and `__name`, its `alt` the brand's name. A logo made for dark grounds (Gemians' white wordmark) comes with a
55
+ second file, `.gw-brand__logo--dark`, shown in dark only (the system's setting or the toggle). `check`:
56
+ `brand-markup`.
57
+ - **A photo beside words** (`.gw-figure`, new): a `<figure>` with its picture at the card radius and a 14px muted
58
+ caption 12px under it, in a split or under a hero's actions (48px below, as a window). Until now only the prose had
59
+ one; a split could hold a product window but not a photo of the team.
60
+ - **A list under a section's head gets the head's spacing**: `.gw-steps`, `.gw-stats`, `.gw-post-list` and
61
+ `.gw-testimonials` no longer reset their own margin, which beat 0.12.0's "what follows a head starts 40px below it
62
+ (48px from 768px)", so the steps sat right under the heading. The base still zeroes a list's margins, at no
63
+ specificity.
64
+ - **A brand's announcement bar and Stage**: `--gw-announce-*` and `--gw-stage-*` may be set in the brand's `:root` block
65
+ (`system.json` always said "a brand sets its own bar here", and `check` called it `token-redefined`). `check` computes
66
+ the bar's words and link on its ground and the Stage's words on its gradient (`brand-contrast`, 4.5:1).
67
+ - Docs: `docs/theming.md` asks 4.5:1 only of the accent's text form (`--gw-accent-strong-*`); the plain accent is never
68
+ text in the kit (icons, marks, the focus ring), so 3:1 is its bar (WCAG 1.4.11), and a gold brand keeps its gold. A
69
+ brand mark is 34×34 (the docs and `templates/page.html` still said 26). A contact page's form is its first screen
70
+ with the page's one `<h1>` (`templates/sections/form.html`). The demo's announcement pill says "New", in sentence case.
71
+
7
72
  ## 0.12.0 (2026-10-26)
8
73
 
9
74
  The grammar of every page, 1:1 with gemboss.ai: the announcement bar, the skip link, the button, the type (headings,
package/README.md CHANGED
@@ -19,7 +19,8 @@ it. Made so that a team working with Claude Code, even without a designer, ships
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; `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; `forms.html` là mọi trạng thái của ô nhập, newsletter ở footer, tường review và huy hiệu điểm (dữ liệu mẫu); `footer.html` là footer đầy đủ như gemboss.ai, kèm nút chuyển sang bản kiểu GemPages; `header.html` là header của gemboss.ai trên điện thoại (menu đi sâu từng mục, thanh nút dính đáy, nút đổi ngôn ngữ với ba ngôn ngữ), cũng có bản kiểu GemPages; `spotlight.html` là đốm sáng trên thẻ gói giá, thẻ link, thẻ nổi bật của mega menu và nút trong dải tối, cũng có bản kiểu GemPages |
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
- | CLI `gemboss-website` | `init`, `check`, `gap`, `upgrade` |
22
+ | CLI `gemboss-website` | `init` (và `init --site`), `build`, `check`, `gap`, `upgrade` |
23
+ | `templates/site/` | 0.13.0: bộ khung một site mới (Next.js phục vụ trang HTML tĩnh, danh sách trang, danh sách tệp công khai, `site.css`, `kit-gaps.css`), `init --site` chép ra |
23
24
 
24
25
  Cần Node 18 trở lên cho CLI. CSS và JS chạy trên mọi trình duyệt hiện đại, không cần bước build.
25
26
 
@@ -40,9 +41,9 @@ nó đọc `AGENTS.md` của kit trước, dựng từ mẫu, và chạy `check`
40
41
  **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
42
 
42
43
  ```html
43
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.12.0/website.min.css">
44
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.14.0/website.min.css">
44
45
  <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.12.0/js/website.js" defer></script>
46
+ <script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.14.0/js/website.js" defer></script>
46
47
  ```
47
48
 
48
49
  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
@@ -113,6 +114,8 @@ viết hoa vẫn cấm. Công thức và các phép kiểm tương phản: `docs
113
114
  | lệnh | làm gì |
114
115
  |---|---|
115
116
  | `npx gemboss-website init` | nối luật của kit vào Claude Code của dự án (CLAUDE.md + skill) |
117
+ | `npx gemboss-website init --site` | 0.13.0: dựng một site mới như gemboss.ai và gemians.com: partial header và footer, `index.html` và `404.html` có dấu `<!-- gw:header -->` / `<!-- gw:footer -->`, `site.css`, `kit-gaps.css`, phần Next.js phục vụ trang tĩnh; không ghi đè tệp có sẵn, thêm script còn thiếu vào `package.json` |
118
+ | `npx gemboss-website build` | 0.13.0: chép kit vào `assets/kit/`, ghi partial header và footer vào mọi trang (đánh dấu `aria-current="page"` cho link của chính trang đó); `--check` cho CI, báo đỏ khi trang chưa cập nhật |
116
119
  | `npx gemboss-website check` | kiểm mọi `.html` và `.css` của site: màu, khoảng cách, chữ, bo góc ngoài thang, bóng, nút tự tô màu, nhãn viết hoa giãn chữ, đè class `gw-`, đổi token của kit, hồ sơ thương hiệu sai tên hoặc sai chỗ, chữ trên nút màu nhấn dưới 4,5:1, dấu gạch dài, ảnh thiếu `alt`, link mơ hồ, sao thiếu giá trị bằng chữ, điểm hay review không có link nguồn, cột footer gập thiếu tiêu đề trong summary, wordmark không ẩn với trình đọc màn hình, mục của menu đi sâu thiếu nút Back hay tiêu đề, nút ngôn ngữ không ẩn sẵn hay bản dịch thiếu `hreflang`… và gợi ý chỗ kit có thể đang thiếu |
117
120
  | `npx gemboss-website gap` | báo một chỗ kit còn thiếu: mở GitHub Issue ở repo của kit (nếu `gh` đã đăng nhập và có quyền), không thì ghi `kit-gaps/<tên>.md` để gửi |
118
121
  | `npx gemboss-website upgrade` | so bản đang dùng với bản mới nhất, in thay đổi, gap đã có trong kit, và lệnh nâng cấp |
@@ -8,10 +8,21 @@ export const USAGE = `gemboss-website (${PKG.name} ${PKG.version})
8
8
  Usage: npx gemboss-website <command> [options] (run it in the site's root folder)
9
9
 
10
10
  Commands
11
- init [--force]
11
+ init [--force] [--site]
12
12
  Add or refresh the kit's block in CLAUDE.md (between <!-- ${PKG.name}:start/end --> markers;
13
13
  text outside them is never touched) and install the Claude Code skill
14
14
  .claude/skills/gemboss-website-ui/SKILL.md (replaced only if init wrote it, or with --force).
15
+ --site also start a whole site, as gemboss.ai and gemians.com are built: the header and
16
+ footer partials, index.html and 404.html with their markers, assets/site.css and
17
+ kit-gaps.css, and the Next.js wrapper that serves the static pages (app/, lib/pages.mjs,
18
+ next.config.mjs, stage-public.mjs, vercel.json, .gitignore). Never overwrites a file;
19
+ adds the missing scripts to package.json.
20
+
21
+ build [--check]
22
+ Copy the kit into assets/kit/ and write partials/header.html and partials/footer.html into every
23
+ page between its <!-- gw:header --> / <!-- gw:footer --> markers, with aria-current="page" on
24
+ the header's links to that page. Pages: lib/pages.mjs pages() and 404.html (else every .html in
25
+ the root). --check writes nothing and exits 1 when a file is out of date (for CI).
15
26
 
16
27
  check [paths...] [--json] [--fix-list] [--ignore <path>]... [--include-kit]
17
28
  Check every .html/.htm/.css (and <style>, style="") under the paths (default: here) against
@@ -59,6 +70,7 @@ async function main(argv) {
59
70
  console.log(PKG.version);
60
71
  return 0;
61
72
  case "init": return (await import("./lib/init.mjs")).cmdInit(rest);
73
+ case "build": return (await import("./lib/build.mjs")).cmdBuild(rest);
62
74
  case "check": return (await import("./lib/check.mjs")).cmdCheck(rest);
63
75
  case "gap": return (await import("./lib/gap.mjs")).cmdGap(rest);
64
76
  case "upgrade": return (await import("./lib/upgrade.mjs")).cmdUpgrade(rest);
@@ -0,0 +1,101 @@
1
+ // `gemboss-website build [--check]`: the parts every page of a static site shares, written into each page in place, so
2
+ // the HTML on disk is the HTML served (0.13.0, from gemboss-www and gemians-www):
3
+ // 1. the kit itself: website.min.css and js/website.js → assets/kit/website.css and assets/kit/website.js, as published;
4
+ // 2. partials/header.html and partials/footer.html → between <!-- gw:header --> … <!-- /gw:header --> (and gw:footer)
5
+ // in every page, with aria-current="page" on the header's links to that page;
6
+ // 3. the kit's version on its two links (?v=0.13.0), so a new kit is never served from an old cache.
7
+ // The pages are the site's lib/pages.mjs (`pages()`: Map of URL → file, the same list its Next route serves) plus
8
+ // 404.html; without that file, every .html in the site's root folder.
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { PKG, PKG_DIR, UsageError, c, parseArgs } from "./util.mjs";
13
+
14
+ export const KIT_DIR = "assets/kit";
15
+ const CURRENT = /\b(?:gw-nav__link|gw-nav__trigger|gw-mobile-menu__top|gw-mobile-menu__link|gw-mega__link)\b/;
16
+
17
+ /** file → clean URL, as Vercel's cleanUrls and the site's Next route: index.html → /, a/index.html → /a, a.html → /a. */
18
+ export function routeOf(file) {
19
+ const stem = file.replace(/\.html$/, "");
20
+ if (stem === "index") return "/";
21
+ return "/" + stem.replace(/\/index$/, "");
22
+ }
23
+
24
+ /** The header for one page: aria-current="page" on each of its links (nav, mobile menu, mega rows) to that URL. */
25
+ export function markCurrent(html, route) {
26
+ return html.replace(/<a\b([^>]*)>/g, (tag, attrs) => {
27
+ const cls = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs) || [])[1] || "";
28
+ const href = (/\bhref\s*=\s*"([^"]*)"/.exec(attrs) || [])[1];
29
+ if (!CURRENT.test(cls) || href !== route || /\baria-current\s*=/.test(attrs)) return tag;
30
+ return `<a${attrs} aria-current="page">`;
31
+ });
32
+ }
33
+
34
+ /** Put `body` between the page's <!-- gw:name --> markers; null when the page has none. */
35
+ export function fillRegion(html, name, body) {
36
+ const re = new RegExp(`(<!-- gw:${name} -->)[\\s\\S]*?(<!-- /gw:${name} -->)`);
37
+ return re.test(html) ? html.replace(re, (_, a, b) => `${a}\n${body}\n${b}`) : null;
38
+ }
39
+
40
+ async function sitePages(root) {
41
+ const lib = join(root, "lib", "pages.mjs");
42
+ let files;
43
+ if (existsSync(lib)) {
44
+ const mod = await import(pathToFileURL(lib).href);
45
+ if (typeof mod.pages !== "function") throw new UsageError("lib/pages.mjs must export pages() (a Map of URL → file)");
46
+ files = [...mod.pages().entries()].map(([route, file]) => ({ route, file }));
47
+ } else {
48
+ files = readdirSync(root).filter((f) => f.endsWith(".html") && f !== "404.html").map((file) => ({ route: routeOf(file), file }));
49
+ }
50
+ if (existsSync(join(root, "404.html"))) files.push({ route: "/404", file: "404.html" });
51
+ return files;
52
+ }
53
+
54
+ export async function cmdBuild(argv) {
55
+ const opts = parseArgs(argv, { check: "bool" });
56
+ if (opts._.length) throw new UsageError(`build takes no arguments (got ${opts._.join(" ")}); run it in the site's root folder`);
57
+ const root = process.cwd();
58
+ const stale = [], wrote = [];
59
+ const put = (file, text) => {
60
+ const abs = resolve(root, file);
61
+ if (existsSync(abs) && readFileSync(abs, "utf8") === text) return;
62
+ if (opts.check) { stale.push(file); return; }
63
+ mkdirSync(dirname(abs), { recursive: true });
64
+ writeFileSync(abs, text);
65
+ wrote.push(file);
66
+ };
67
+
68
+ put(`${KIT_DIR}/website.css`, `/* ${PKG.name} ${PKG.version}, copied by gemboss-website build: do not edit */\n` + readFileSync(join(PKG_DIR, "website.min.css"), "utf8"));
69
+ put(`${KIT_DIR}/website.js`, readFileSync(join(PKG_DIR, "js", "website.js"), "utf8"));
70
+
71
+ // a partial's leading comments are notes for its author (the kit templates' instructions), not for every page
72
+ const partial = (name) => { const p = join(root, "partials", `${name}.html`); return existsSync(p) ? readFileSync(p, "utf8").replace(/^(?:\s*<!--[\s\S]*?-->)+/, "").trim() : null; };
73
+ const header = partial("header"), footer = partial("footer");
74
+ const pages = await sitePages(root);
75
+ const problems = [];
76
+ for (const { route, file } of pages) {
77
+ const abs = join(root, file);
78
+ if (!existsSync(abs)) { problems.push(`${file}: listed by lib/pages.mjs, not found`); continue; }
79
+ let html = readFileSync(abs, "utf8");
80
+ for (const [name, body] of [["header", header && markCurrent(header, route)], ["footer", footer]]) {
81
+ if (!html.includes(`<!-- gw:${name} -->`)) continue;
82
+ if (body === null) { problems.push(`${file}: has <!-- gw:${name} --> but partials/${name}.html is missing`); continue; }
83
+ const next = fillRegion(html, name, body);
84
+ if (next === null) problems.push(`${file}: <!-- gw:${name} --> without its closing <!-- /gw:${name} -->`);
85
+ else html = next;
86
+ }
87
+ html = html.replace(/((?:href|src)\s*=\s*"\/assets\/kit\/website\.(?:css|js))(?:\?v=[^"]*)?"/g, `$1?v=${PKG.version}"`);
88
+ put(file, html);
89
+ }
90
+
91
+ for (const p of problems) console.error(` ${c.red(p)}`);
92
+ if (opts.check) {
93
+ for (const f of stale) console.error(` out of date: ${f}`);
94
+ const bad = stale.length + problems.length;
95
+ console.log(bad ? c.red(`build: RED, ${stale.length} file(s) out of date, ${problems.length} problem(s). Run: npx gemboss-website build`) : c.green(`build: green, ${pages.length} pages, kit ${PKG.version}`));
96
+ return bad ? 1 : 0;
97
+ }
98
+ for (const f of wrote) console.log(` wrote ${f}`);
99
+ console.log(`build: ${pages.length} pages, kit ${PKG.version}${wrote.length ? "" : ", nothing to write"}`);
100
+ return problems.length ? 1 : 0;
101
+ }
package/bin/lib/check.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { basename, resolve } from "node:path";
5
5
  import { PKG, SYSTEM, UsageError, blank, c, isKitPath, lineIndex, parseArgs, rel, walkFiles } from "./util.mjs";
6
- import { MIN_CONTRAST, actionPairs, ratio } from "./contrast.mjs";
6
+ import { MIN_CONTRAST, actionPairs, contrast, ratio } from "./contrast.mjs";
7
7
 
8
8
  const RADII = new Set(SYSTEM.radius.scale);
9
9
  // A brand's named profiles (system.json `profiles`): one attribute on <html> each, and the floating header's class.
@@ -31,7 +31,7 @@ export const RULES = {
31
31
  },
32
32
  "token-redefined": {
33
33
  level: "error", title: "Kit token redefined",
34
- why: "A brand chooses its accent family and wash hues (variables), its logo, and three named profiles; nothing else. Changing another --gw-* token changes every component on the site.",
34
+ why: "A brand chooses its accent family and wash hues (variables), the colours of its announcement bar and of its Stage (--gw-announce-*, --gw-stage-*), its logo, and three named profiles; nothing else. Changing another --gw-* token changes every component on the site.",
35
35
  fix: `Remove it. The action colour and the corners are profiles, not tokens: ${profileList()} on <html>; the floating header is the class ${FLOAT_CLASS} (docs/theming.md). If the brand needs something else, report a gap: npx gemboss-website gap --kind token.`,
36
36
  },
37
37
  "radius-off-scale": {
@@ -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
+ "brand-contrast": {
133
+ level: "error", title: "Words on the brand's announcement bar or Stage below 4.5:1",
134
+ why: `A brand that sets its own announcement bar (--gw-announce-*) or Stage (--gw-stage-*) picks colours the kit cannot check for it: the bar's words and its link on its ground, and the Stage's words on both ends of its gradient, must reach ${MIN_CONTRAST}:1, or the sentence over every page and the mega menu's pictures are hard to read.`,
135
+ fix: "Darken the ground (--gw-announce-bg, --gw-stage-top, --gw-stage-bottom) or lighten the words (--gw-announce-ink, --gw-announce-link, --gw-stage-ink) until every pair passes; write them as #rrggbb.",
136
+ },
137
+ "brand-markup": {
138
+ level: "error", title: "Brand logo without its markup",
139
+ why: "A logo drawn as one file (.gw-brand__logo, the mark and the wordmark together) stands in for the brand's name: its alt is that name, or the home link has no name for a screen reader. Its width and height keep the bar from jumping while it loads. A file for dark grounds (.gw-brand__logo--dark) is the same logo in another colour and needs the first one right before it.",
140
+ fix: "Write <a class=\"gw-brand\" href=\"/\"><img class=\"gw-brand__logo\" src=\"/logo.png\" alt=\"Brand\" width=\"150\" height=\"26\"><img class=\"gw-brand__logo gw-brand__logo--dark\" src=\"/logo-on-dark.png\" alt=\"Brand\" width=\"150\" height=\"26\"></a> (docs/theming.md, the logo).",
141
+ },
132
142
  "toc-target": {
133
143
  level: "error", title: "Contents link to a heading that is not on the page",
134
144
  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.",
@@ -339,7 +349,9 @@ const isRootish = (sel) => splitTop(sel).every((s) => s && ROOTISH.test(s));
339
349
  // its pictures' width and its columns (0.11.0: style="--gw-mega-w: 1120px; --gw-mega-pv: 380px; --gw-mega-cols: 2"); the colour of
340
350
  // a label's square (0.12.0: --gw-label-mark, on the label or around it)
341
351
  const INPUTS = new Set(["--gw-stars", "--gw-mega-w", "--gw-mega-pv", "--gw-mega-cols", "--gw-label-mark"]);
342
- const THEMABLE = /^--gw-(?:accent(?:-[\w-]+)?|on-accent(?:-(?:light|dark))?|wash(?:-[\w-]+)?)$/; // docs/theming.md: the eight accent variables (-light/-dark) + wash
352
+ // docs/theming.md: the eight accent variables (-light/-dark), the wash, and (0.13.0) the announcement bar's and the
353
+ // Stage's colours, which system.json always said a brand sets
354
+ const THEMABLE = /^--gw-(?:accent(?:-[\w-]+)?|on-accent(?:-(?:light|dark))?|wash(?:-[\w-]+)?|announce-[\w-]+|stage-[\w-]+)$/;
343
355
 
344
356
  const COLOR_PROPS = /^(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?(?:-color)?|outline(?:-color)?|fill|stroke|text-decoration(?:-color)?|caret-color|accent-color|column-rule(?:-color)?|text-emphasis(?:-color)?|-webkit-text-fill-color|-webkit-text-stroke(?:-color)?|scrollbar-color|stop-color|flood-color|lighting-color)$/;
345
357
  const NAMED = new Set(("white black red green blue yellow orange purple pink gray grey silver gold navy teal maroon olive lime aqua " +
@@ -480,6 +492,28 @@ function brandAccent(cssRules, page) {
480
492
  return vals;
481
493
  }
482
494
 
495
+ /** The brand's announcement bar and Stage, when its CSS sets them: their words on their grounds (brand-contrast). */
496
+ function checkBrandContrast(out, add) {
497
+ const vals = {};
498
+ for (const { file, rules } of out.cssRules.filter((x) => !x.inline)) {
499
+ for (const r of rules) {
500
+ if (r.atBlock || r.at.length || !r.selector || !isRootish(r.selector)) continue;
501
+ for (const d of r.decls) if (/^--gw-(?:announce|stage)-/.test(d.prop)) vals[d.prop] = { value: d.value.replace(/!important\s*$/i, "").trim(), at: `${file}:${d.line}` };
502
+ }
503
+ }
504
+ const pairs = [["announce", "ink", "bg"], ["announce", "link", "bg"], ["stage", "ink", "top"], ["stage", "ink", "bottom"]];
505
+ for (const [part, text, ground] of pairs) {
506
+ const t = vals[`--gw-${part}-${text}`], g = vals[`--gw-${part}-${ground}`];
507
+ if (!t && !g) continue; // the kit's own pair, checked by the kit
508
+ const kit = (k) => SYSTEM.color[part][k];
509
+ const tv = t ? t.value : kit(text), gv = g ? g.value : kit(ground);
510
+ const r = contrast(tv, gv);
511
+ if (r !== null && r >= MIN_CONTRAST) continue;
512
+ const at = (t || g).at, [file, line] = [at.slice(0, at.lastIndexOf(":")), Number(at.slice(at.lastIndexOf(":") + 1))];
513
+ add("brand-contrast", { file, line, excerpt: `--gw-${part}-${text} ${tv} on --gw-${part}-${ground} ${gv} ${r === null ? "cannot be read as a colour (write #rrggbb)" : `is ${ratio(r)}, needs ${MIN_CONTRAST}:1`}` });
514
+ }
515
+ }
516
+
483
517
  /** Pages with data-gw-action="accent": the words of the primary button on its fills, light and dark (action-contrast). */
484
518
  function checkActionContrast(out, add) {
485
519
  for (const pg of out.pageProfiles.filter((p) => p.action === "accent")) {
@@ -659,6 +693,15 @@ function analyzeHtml(file, text, out) {
659
693
  ].filter(Boolean);
660
694
  if (missing.length) add("stars-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
661
695
  }
696
+ if (tag === "img" && cls.includes("gw-brand__logo")) {
697
+ const before = scanText.slice(0, m.index).replace(/\s+$/, "");
698
+ const missing = [
699
+ !(a.alt || "").trim() && "alt with the brand's name (the logo stands in for it)",
700
+ !(a.width && a.height) && "width and height",
701
+ cls.includes("gw-brand__logo--dark") && !/<img\b[^>]*\bclass\s*=\s*["'][^"']*\bgw-brand__logo\b(?![\w-])[^"']*["'][^>]*>$/i.test(before) && "the light file right before it (<img class=\"gw-brand__logo\">)",
702
+ ].filter(Boolean);
703
+ if (missing.length) add("brand-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: ${missing.join(", ")}`));
704
+ }
662
705
  if (cls.includes("gw-footer__wordmark") && (a["aria-hidden"] || "").trim() !== "true") {
663
706
  add("footer-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: aria-hidden="true" (the wordmark is decoration)`));
664
707
  }
@@ -825,6 +868,7 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
825
868
  }
826
869
 
827
870
  checkActionContrast(out, add);
871
+ checkBrandContrast(out, add);
828
872
 
829
873
  // Classes styled in site CSS: first definition of each.
830
874
  const styled = new Map();
package/bin/lib/init.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // `gemboss-website init`: connect the kit's rules to the project's Claude Code (CLAUDE.md block + skill).
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { PKG_DIR, PKG_NAME, UsageError, parseArgs, rel } from "./util.mjs";
5
5
 
@@ -59,8 +59,87 @@ export function upsertBlock(text, block) {
59
59
  return { text: text + sep + b + eol, action: "added" };
60
60
  }
61
61
 
62
+ // ------------------------------------------------------------------------------------------- init --site
63
+
64
+ /** The files a new site owns from the start, from templates/site/ (gitignore → .gitignore: npm drops dotfiles). */
65
+ function siteTemplateFiles(dir = join(PKG_DIR, "templates", "site"), base = "") {
66
+ const out = [];
67
+ for (const name of readdirSync(dir)) {
68
+ const abs = join(dir, name), r = base ? `${base}/${name}` : name;
69
+ if (statSync(abs).isDirectory()) out.push(...siteTemplateFiles(abs, r));
70
+ else out.push({ from: abs, to: r === "gitignore" ? ".gitignore" : r });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * A page of the site from templates/page.html: the header and footer become the build's markers, the kit's paths the
77
+ * site's (assets/kit/), and kit-gaps.css follows site.css. `main` replaces the template's <main> when given.
78
+ */
79
+ export function sitePage(main) {
80
+ let html = readFileSync(join(PKG_DIR, "templates", "page.html"), "utf8");
81
+ const cut = (re, what) => { if (!re.test(html)) throw new Error(`templates/page.html: no ${what} to replace`); html = html.replace(re, ""); };
82
+ html = html.replace(/<!--\s*\n\s*Page skeleton:[\s\S]*?-->\n/, `<!-- A page of this site. Its header and footer are partials/header.html and partials/footer.html, written between the
83
+ markers by \`npx gemboss-website build\`. Compose <main> from node_modules/${PKG_NAME}/templates/sections/. -->\n`);
84
+ cut(/\n\s*<div class="gw-announce"[\s\S]*?<\/header>\n/, "header");
85
+ html = html.replace(/(<a class="gw-skip"[^\n]*\n)/, "$1 <!-- gw:header -->\n <!-- /gw:header -->\n\n");
86
+ cut(/\n\s*<!-- the short footer[^\n]*-->\s*<footer class="gw-footer">[\s\S]*?<\/footer>\n/, "footer");
87
+ html = html.replace(/(\s*<\/main>\n)/, "$1\n <!-- gw:footer -->\n <!-- /gw:footer -->\n");
88
+ html = html.split("[/kit]").join("/assets/kit").replace("[/assets]/site.css", "/assets/site.css")
89
+ .replace(/(<link rel="stylesheet" href="\/assets\/site\.css">)/, '$1\n <link rel="stylesheet" href="/assets/kit-gaps.css">')
90
+ .replace("[/favicon.svg]", "/favicon.svg");
91
+ if (main) html = html.replace(/<main id="main">[\s\S]*?<\/main>/, `<main id="main">\n${main}\n </main>`);
92
+ return html;
93
+ }
94
+
95
+ const NOT_FOUND_MAIN = ` <section class="gw-hero gw-hero--compact">
96
+ <div class="gw-wrap">
97
+ <p class="gw-label">404</p>
98
+ <h1><span class="gw-dim">This page is not here.</span> The rest of the site is.</h1>
99
+ <div class="gw-actions">
100
+ <a class="gw-btn" href="/">Go to the home page</a>
101
+ </div>
102
+ </div>
103
+ </section>`;
104
+
105
+ export const SITE_SCRIPTS = {
106
+ "build:site": "gemboss-website build",
107
+ build: "gemboss-website build && node stage-public.mjs && next build",
108
+ start: "next start",
109
+ check: "gemboss-website build --check && node stage-public.mjs --check && gemboss-website check . --ignore node_modules --ignore public",
110
+ };
111
+ export const SITE_DEPS = "next@16.4.2 react@19.2.8 react-dom@19.2.8";
112
+
113
+ function initSite(cwd, done) {
114
+ const write = (to, text) => {
115
+ const abs = resolve(cwd, to);
116
+ if (existsSync(abs)) { done.push([to, "kept (exists)"]); return; }
117
+ mkdirSync(dirname(abs), { recursive: true });
118
+ writeFileSync(abs, text);
119
+ done.push([to, "created"]);
120
+ };
121
+ for (const { from, to } of siteTemplateFiles()) write(to, readFileSync(from, "utf8"));
122
+ write("partials/header.html", readFileSync(join(PKG_DIR, "templates", "header.html"), "utf8"));
123
+ write("partials/footer.html", readFileSync(join(PKG_DIR, "templates", "footer.html"), "utf8"));
124
+ write("index.html", sitePage());
125
+ write("404.html", sitePage(NOT_FOUND_MAIN).replace("<title>[Page title] · [Brand]</title>", "<title>Page not found · [Brand]</title>"));
126
+
127
+ const pkgPath = resolve(cwd, "package.json");
128
+ const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")) : { name: "[site]-www", private: true, type: "module" };
129
+ pkg.scripts = pkg.scripts || {};
130
+ const added = Object.keys(SITE_SCRIPTS).filter((k) => !(k in pkg.scripts));
131
+ for (const k of added) pkg.scripts[k] = SITE_SCRIPTS[k];
132
+ const typed = pkg.type === "module";
133
+ if (!typed && !pkg.type) pkg.type = "module";
134
+ if (added.length || !existsSync(pkgPath) || !typed) {
135
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
136
+ done.push(["package.json", added.length ? `scripts added: ${added.join(", ")}` : "written"]);
137
+ } else done.push(["package.json", "scripts already there"]);
138
+ if (pkg.type !== "module") done.push(["package.json", `"type" is "${pkg.type}": the .mjs files work, next.config.mjs too`]);
139
+ }
140
+
62
141
  export function cmdInit(argv) {
63
- const opts = parseArgs(argv, { force: "bool" });
142
+ const opts = parseArgs(argv, { force: "bool", site: "bool" });
64
143
  if (opts._.length) throw new UsageError(`init takes no arguments (got ${opts._.join(" ")}); run it in the project's root folder`);
65
144
  const cwd = process.cwd();
66
145
  const done = [];
@@ -89,11 +168,22 @@ export function cmdInit(argv) {
89
168
  }
90
169
  }
91
170
 
171
+ if (opts.site) initSite(cwd, done);
172
+
92
173
  const w = Math.max(...done.map(([p]) => p.length));
93
174
  console.log(`gemboss-website init (${PKG_NAME})`);
94
175
  for (const [p, what] of done) console.log(` ${p.padEnd(w)} ${what}`);
95
176
  console.log("");
96
177
  console.log(`Claude Code now reads ${KIT}/AGENTS.md before website work in this project.`);
97
- console.log("Next: start a page from the kit's templates/page.html, then run: npx gemboss-website check");
178
+ if (opts.site) {
179
+ console.log("Next:");
180
+ console.log(` 1. npm install ${SITE_DEPS}`);
181
+ console.log(" 2. Fill partials/header.html and partials/footer.html once (delete what the site does not use),");
182
+ console.log(" the brand's :root block in assets/site.css, and add favicon.svg");
183
+ console.log(" 3. npm run build:site, then compose index.html from the kit's templates/sections/");
184
+ console.log(" 4. npm run check, and look at every page at 320px and 1366px");
185
+ } else {
186
+ console.log("Next: start a page from the kit's templates/page.html, then run: npx gemboss-website check");
187
+ }
98
188
  return 0;
99
189
  }
package/css/article.css CHANGED
@@ -203,7 +203,7 @@
203
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
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
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; }
206
+ .gw-post-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; padding: 0; list-style: none; }
207
207
  .gw-post-list > li { display: flex; min-width: 0; }
208
208
  .gw-post { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; }
209
209
  .gw-post:hover { border-color: var(--gw-line-strong); }