@gemboss/website-ui 0.11.0 → 0.13.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 +14 -2
- package/CHANGELOG.md +77 -0
- package/README.md +6 -3
- package/bin/gemboss-website.mjs +13 -1
- package/bin/lib/build.mjs +101 -0
- package/bin/lib/check.mjs +50 -5
- package/bin/lib/init.mjs +93 -3
- package/css/article.css +1 -1
- package/css/base.css +23 -18
- package/css/components.css +29 -9
- package/css/forms.css +1 -1
- package/css/header.css +52 -5
- package/css/layout.css +7 -3
- package/css/ratings.css +0 -1
- package/css/sections.css +3 -4
- package/css/tokens.css +11 -0
- package/demo/header.html +1 -1
- package/docs/components.md +39 -14
- package/docs/markup-contract.md +10 -6
- package/docs/theming.md +39 -12
- package/js/website.js +33 -9
- package/js/website.mjs +37 -10
- package/package.json +1 -1
- package/system.json +28 -4
- package/templates/header.html +3 -2
- package/templates/page.html +2 -2
- package/templates/sections/form.html +2 -0
- package/templates/sections/hero.html +2 -1
- package/templates/sections/split.html +1 -1
- package/templates/site/app/[[...path]]/route.js +23 -0
- package/templates/site/assets/kit-gaps.css +3 -0
- package/templates/site/assets/site.css +16 -0
- package/templates/site/gitignore +4 -0
- package/templates/site/lib/pages.mjs +41 -0
- package/templates/site/next.config.mjs +15 -0
- package/templates/site/stage-public.mjs +49 -0
- package/templates/site/vercel.json +5 -0
- package/website.css +128 -43
- package/website.min.css +1 -1
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) |
|
|
@@ -60,6 +61,13 @@ Files you will use, all inside `node_modules/@gemboss/website-ui/`:
|
|
|
60
61
|
|
|
61
62
|
## 2. Build a page
|
|
62
63
|
|
|
64
|
+
**A new site** (0.13.0, the way gemboss.ai and gemians.com are built): `npx gemboss-website init --site` in an empty
|
|
65
|
+
folder writes the header and footer partials, `index.html` and `404.html`, `assets/site.css`, `assets/kit-gaps.css` and
|
|
66
|
+
the Next.js wrapper that serves the static pages, and adds the scripts to `package.json`. Then each page is a file with
|
|
67
|
+
`<!-- gw:header -->` and `<!-- gw:footer -->` markers, and `npx gemboss-website build` writes the partials into every
|
|
68
|
+
page (with `aria-current="page"` on its own links) and copies the kit into `assets/kit/`. Never hand-copy a header into
|
|
69
|
+
a page. A page of such a site starts from `index.html`, not from `templates/page.html`.
|
|
70
|
+
|
|
63
71
|
1. Copy `templates/page.html` into the site. It links `website.css` and `website.js`, has the skip link, the
|
|
64
72
|
header, the `<main>` and the footer. Fill the header and footer once (they are the same on every page).
|
|
65
73
|
**The footer comes from `templates/footer.html`.** A brand fills its rows (the columns, the family rows, the
|
|
@@ -225,7 +233,9 @@ A brand other than GemBoss chooses **only** these, and nothing else:
|
|
|
225
233
|
Do not set `--gw-accent` itself: the kit points it at those per theme, and a direct override loses as soon as the
|
|
226
234
|
theme toggle is pressed. Accent text uses `--gw-accent-strong`, so it must reach 4.5:1 on `--gw-bg` and
|
|
227
235
|
`--gw-bg-soft`;
|
|
228
|
-
- **the wash hues**, **the logo** in the header and footer
|
|
236
|
+
- **the wash hues**, **the logo** in the header and footer (a mark beside the name, or one drawing of both:
|
|
237
|
+
`.gw-brand__logo`, with a second file for dark grounds when the brand has one), and **the favicon**;
|
|
238
|
+
- **the announcement bar's and the Stage's colours** (`--gw-announce-*`, `--gw-stage-*`), only when it shows them;
|
|
229
239
|
- **three named profiles**, once for the site, never free values:
|
|
230
240
|
- action colour: `data-gw-action="accent"` on `<html>` fills the primary button with the accent (default `ink`).
|
|
231
241
|
Its words (`--gw-on-accent`) must reach 4.5:1 on `--gw-accent` and `--gw-accent-strong`, light and dark, or
|
|
@@ -242,7 +252,9 @@ examples: `demo/theme-example.html` (accent only) and `demo/brand-profiles.html`
|
|
|
242
252
|
- Link the kit, then the site's own CSS after it: `website.css` → `site.css`.
|
|
243
253
|
- The site's own CSS is for content the kit cannot know (an illustration, a product mock-up) and uses only the
|
|
244
254
|
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
|
|
255
|
+
- Shared header and footer markup lives in one place in the site (a partial or component), not copied per page:
|
|
256
|
+
`partials/header.html` and `partials/footer.html`, written into the pages by `npx gemboss-website build` (CI runs
|
|
257
|
+
`build --check`, which fails when a page is out of date).
|
|
246
258
|
|
|
247
259
|
## 9. When the kit is missing something
|
|
248
260
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ 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.13.0 (2026-10-26)
|
|
8
|
+
|
|
9
|
+
What building a second site on the kit taught it. gemians.com was rebuilt on 0.12.0 from npm, kit sections only (Chris,
|
|
10
|
+
26/10/2026: inherit from the GemBoss website kit, fix the kit where it gets in the way, then update Gemians and
|
|
11
|
+
GemBoss), and every place the kit fell short became one of these. A minor version: nothing a 0.12.0 site uses changes
|
|
12
|
+
its look, except a list right under a section's head (below), which now gets the head's spacing it was always meant to.
|
|
13
|
+
|
|
14
|
+
- **A new site in one command**: `npx gemboss-website init --site` writes what gemboss.ai and gemians.com both had to
|
|
15
|
+
write by hand: the header and footer partials (from the kit's templates), `index.html` and `404.html` with the
|
|
16
|
+
build's markers, `assets/site.css` (the brand's `:root` block, commented) and `assets/kit-gaps.css`, and the Next.js
|
|
17
|
+
wrapper that serves the static pages byte for byte (`app/[[...path]]/route.js`, `lib/pages.mjs`, `next.config.mjs`
|
|
18
|
+
with clean URLs, `stage-public.mjs` with its allow-list, `vercel.json`, `.gitignore`), from the new
|
|
19
|
+
`templates/site/`. It never overwrites a file and adds the scripts a site needs to `package.json`.
|
|
20
|
+
- **`npx gemboss-website build`** (new): copies the kit into `assets/kit/` as published, writes `partials/header.html`
|
|
21
|
+
and `partials/footer.html` into every page between `<!-- gw:header -->` and `<!-- gw:footer -->` (their leading
|
|
22
|
+
comments, the templates' notes, stay out), puts `aria-current="page"` on the header's links to that page, and the
|
|
23
|
+
kit's version on its two links (`?v=0.13.0`). `--check` writes nothing and fails when a page is out of date (CI).
|
|
24
|
+
One build for every site, so a fix reaches all of them with the kit, instead of a script per site that drifts.
|
|
25
|
+
- **A logo drawn as one file** (`.gw-brand__logo`, new): the mark and the wordmark together, 26px tall, in place of
|
|
26
|
+
`__mark` and `__name`, its `alt` the brand's name. A logo made for dark grounds (Gemians' white wordmark) comes with a
|
|
27
|
+
second file, `.gw-brand__logo--dark`, shown in dark only (the system's setting or the toggle). `check`:
|
|
28
|
+
`brand-markup`.
|
|
29
|
+
- **A photo beside words** (`.gw-figure`, new): a `<figure>` with its picture at the card radius and a 14px muted
|
|
30
|
+
caption 12px under it, in a split or under a hero's actions (48px below, as a window). Until now only the prose had
|
|
31
|
+
one; a split could hold a product window but not a photo of the team.
|
|
32
|
+
- **A list under a section's head gets the head's spacing**: `.gw-steps`, `.gw-stats`, `.gw-post-list` and
|
|
33
|
+
`.gw-testimonials` no longer reset their own margin, which beat 0.12.0's "what follows a head starts 40px below it
|
|
34
|
+
(48px from 768px)", so the steps sat right under the heading. The base still zeroes a list's margins, at no
|
|
35
|
+
specificity.
|
|
36
|
+
- **A brand's announcement bar and Stage**: `--gw-announce-*` and `--gw-stage-*` may be set in the brand's `:root` block
|
|
37
|
+
(`system.json` always said "a brand sets its own bar here", and `check` called it `token-redefined`). `check` computes
|
|
38
|
+
the bar's words and link on its ground and the Stage's words on its gradient (`brand-contrast`, 4.5:1).
|
|
39
|
+
- Docs: `docs/theming.md` asks 4.5:1 only of the accent's text form (`--gw-accent-strong-*`); the plain accent is never
|
|
40
|
+
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
|
|
41
|
+
brand mark is 34×34 (the docs and `templates/page.html` still said 26). A contact page's form is its first screen
|
|
42
|
+
with the page's one `<h1>` (`templates/sections/form.html`). The demo's announcement pill says "New", in sentence case.
|
|
43
|
+
|
|
44
|
+
## 0.12.0 (2026-10-26)
|
|
45
|
+
|
|
46
|
+
The grammar of every page, 1:1 with gemboss.ai: the announcement bar, the skip link, the button, the type (headings,
|
|
47
|
+
lead, label, the dim first sentence), a section, its head, the wrap, and the scroll reveal. After the footer (0.10.0)
|
|
48
|
+
and the header (0.11.0), this is the first step of the pages' own bodies onto the kit (Chris, 26/10/2026: every value in
|
|
49
|
+
the kit, so that GemPages, Gemians and GemX inherit them). gemboss.ai's 83 pages now draw these parts with the kit's
|
|
50
|
+
classes, and were measured against the pages before, in Chromium: the computed style of every one of these parts (2,300
|
|
51
|
+
of them) at 320, 390, 768, 1080, 1366 and 1920px, light and dark, and every page pixel by pixel at 390 and 1366px; the
|
|
52
|
+
reveal's delays and counts, a hovered button, the focused skip link.
|
|
53
|
+
|
|
54
|
+
A minor version: a site on 0.11.0 sees these values change (below), none of the markup.
|
|
55
|
+
|
|
56
|
+
- The announcement bar (`.gw-announce`): gemboss.ai's, on its own ground in both themes, from the new tokens
|
|
57
|
+
`--gw-announce-*` (`system.json` `color.announce`: ground, words, hairline, the pill, the link and its underline). A
|
|
58
|
+
small pill may lead the sentence (`.gw-announce__tag`, new); the link's underline is a hairline that brightens on
|
|
59
|
+
hover. 12px words in a slimmer bar up to 520px.
|
|
60
|
+
- The skip link (`.gw-skip`): an accent tab out of the top left corner (was an ink button 8px in).
|
|
61
|
+
- The button (`.gw-btn`): on hover the filled one rises 1px onto the rest shadow (was a darker fill); a framed or
|
|
62
|
+
frameless one does not move. Its face is the new `--gw-font-action` (`type.button.family`, the brand face alone, as
|
|
63
|
+
gemboss.ai names it: a glyph Inter's web subset lacks, the arrow, is then the system's own, the width gemboss.ai's
|
|
64
|
+
buttons have). Its line height is its surroundings'. A label wider than needed stays at the start (centred only in
|
|
65
|
+
`--block`, a phone's row of actions in a hero or CTA, an inline form). An arrow after the label is
|
|
66
|
+
`.gw-btn__arrow` (new): 3px further on hover. Up to 768px every button is at least 44px tall, by the width only (it was
|
|
67
|
+
also by a coarse pointer); a label wraps only up to 480px and only in `<main>`, at 1.35 lines (was up to 768px,
|
|
68
|
+
balanced).
|
|
69
|
+
- Type: h1 at line height 1.1 and h2 at 1.06 (new tokens `--gw-display-h1-leading`, `--gw-display-h2-leading`; at 1.02
|
|
70
|
+
the descenders of a first line touched the capitals of the second). h3 and `.gw-h3` 20px / 600, tracking −0.02em,
|
|
71
|
+
1.2 lines, as every h3 on gemboss.ai (was 24px / 650). The lead at most 58ch (was 60ch). The label's square is ink
|
|
72
|
+
with 2px corners (was the accent, square) and its line height its surroundings'; the input `--gw-label-mark` (new, on
|
|
73
|
+
the label or anything around it) paints the square another colour, a gradient too.
|
|
74
|
+
- A section's head (`.gw-section__head`) up to 760px (was 660px). A lead under a heading 20px below it (in a head, a
|
|
75
|
+
split column, a CTA; 16px in the hero). What follows a head starts 40px below it, 48px from 768px, as a top margin
|
|
76
|
+
of the next thing at no specificity, so a block that brings its own keeps it (was the head's bottom margin).
|
|
77
|
+
- The scroll reveal: 20px (new token `--gw-reveal-distance`, was 24px), once 12% of it is in view with the bottom 6% of
|
|
78
|
+
the view not counting (was 15%); each `.gw-reveal` after the first among its siblings waits 70ms more, at most 380ms;
|
|
79
|
+
a `[data-gw-count]` number inside counts up from 0 over 1.1s (new); the script's failsafe at 1.6s (was 2.5s). The
|
|
80
|
+
values are `system.json` `motion.reveal`.
|
|
81
|
+
- `check`: `--gw-label-mark` is an input, like a mega panel's widths.
|
|
82
|
+
- `website.js` is 10.4 KB gzipped (the budget is 10.5 KB, was 10 KB: the reveal's stagger and count-up).
|
|
83
|
+
|
|
7
84
|
## 0.11.0 (2026-10-26)
|
|
8
85
|
|
|
9
86
|
The header, 1:1 with gemboss.ai. Since the footer (0.10.0), the header was the last part of gemboss.ai not drawn by
|
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.
|
|
44
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.13.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.
|
|
46
|
+
<script src="https://cdn.jsdelivr.net/npm/@gemboss/website-ui@0.13.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 |
|
package/bin/gemboss-website.mjs
CHANGED
|
@@ -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.",
|
|
@@ -336,9 +346,12 @@ const ATTR_THEME = String.raw`\[\s*data-theme(?:\s*[~|^$*]?=\s*(?:"[^"]*"|'[^']*
|
|
|
336
346
|
const ROOTISH = new RegExp(`^(?::root|html)?(?:${ATTR_THEME}|:not\\(\\s*${ATTR_THEME}\\s*\\))*$`, "i");
|
|
337
347
|
const isRootish = (sel) => splitTop(sel).every((s) => s && ROOTISH.test(s));
|
|
338
348
|
// set on one element in the markup, read by the kit's CSS: the stars' value (style="--gw-stars: 4.9"); a mega panel's width,
|
|
339
|
-
// its pictures' width and its columns (0.11.0: style="--gw-mega-w: 1120px; --gw-mega-pv: 380px; --gw-mega-cols: 2")
|
|
340
|
-
|
|
341
|
-
const
|
|
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
|
|
350
|
+
// a label's square (0.12.0: --gw-label-mark, on the label or around it)
|
|
351
|
+
const INPUTS = new Set(["--gw-stars", "--gw-mega-w", "--gw-mega-pv", "--gw-mega-cols", "--gw-label-mark"]);
|
|
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-]+)$/;
|
|
342
355
|
|
|
343
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)$/;
|
|
344
357
|
const NAMED = new Set(("white black red green blue yellow orange purple pink gray grey silver gold navy teal maroon olive lime aqua " +
|
|
@@ -479,6 +492,28 @@ function brandAccent(cssRules, page) {
|
|
|
479
492
|
return vals;
|
|
480
493
|
}
|
|
481
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
|
+
|
|
482
517
|
/** Pages with data-gw-action="accent": the words of the primary button on its fills, light and dark (action-contrast). */
|
|
483
518
|
function checkActionContrast(out, add) {
|
|
484
519
|
for (const pg of out.pageProfiles.filter((p) => p.action === "accent")) {
|
|
@@ -658,6 +693,15 @@ function analyzeHtml(file, text, out) {
|
|
|
658
693
|
].filter(Boolean);
|
|
659
694
|
if (missing.length) add("stars-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 100)} missing: ${missing.join(", ")}`));
|
|
660
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
|
+
}
|
|
661
705
|
if (cls.includes("gw-footer__wordmark") && (a["aria-hidden"] || "").trim() !== "true") {
|
|
662
706
|
add("footer-markup", where(`${m[0].replace(/\s+/g, " ").slice(0, 80)} missing: aria-hidden="true" (the wordmark is decoration)`));
|
|
663
707
|
}
|
|
@@ -824,6 +868,7 @@ export function runCheck(paths, { ignore = [], includeKit = false } = {}) {
|
|
|
824
868
|
}
|
|
825
869
|
|
|
826
870
|
checkActionContrast(out, add);
|
|
871
|
+
checkBrandContrast(out, add);
|
|
827
872
|
|
|
828
873
|
// Classes styled in site CSS: first definition of each.
|
|
829
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
|
-
|
|
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;
|
|
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); }
|
package/css/base.css
CHANGED
|
@@ -32,33 +32,34 @@ h1, h2, h3, h4, h5, h6, .gw-h1, .gw-h2, .gw-h3, .gw-h4 {
|
|
|
32
32
|
text-wrap: balance;
|
|
33
33
|
}
|
|
34
34
|
h1, .gw-h1, h2, .gw-h2 { font-weight: var(--gw-display-weight); letter-spacing: var(--gw-display-tracking); }
|
|
35
|
-
h1, .gw-h1 { font-size: var(--gw-display-h1); }
|
|
36
|
-
h2, .gw-h2 { font-size: var(--gw-display-h2); }
|
|
37
|
-
h3,
|
|
35
|
+
h1, .gw-h1 { font-size: var(--gw-display-h1); line-height: var(--gw-display-h1-leading); }
|
|
36
|
+
h2, .gw-h2 { font-size: var(--gw-display-h2); line-height: var(--gw-display-h2-leading); }
|
|
37
|
+
/* h3 (0.12.0): 20px/600, as every h3 on gemboss.ai */
|
|
38
|
+
h3, .gw-h3 { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.2; }
|
|
38
39
|
h4, .gw-h4 { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
|
|
39
40
|
h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
40
41
|
|
|
41
|
-
.gw-lead { font-size: 18px; line-height: 1.55; color: var(--gw-muted); max-width:
|
|
42
|
+
.gw-lead { font-size: 18px; line-height: 1.55; color: var(--gw-muted); max-width: 58ch; }
|
|
42
43
|
.gw-note { font-size: 14px; color: var(--gw-muted); }
|
|
43
44
|
.gw-dim { color: var(--gw-muted); }
|
|
44
45
|
|
|
45
|
-
/* the label above a heading: sentence case, muted, led by the 8px
|
|
46
|
+
/* the label above a heading: sentence case, muted, led by the 8px square, ink by default (0.12.0, as gemboss.ai). The
|
|
47
|
+
input --gw-label-mark paints the square another colour (a product's own, say): set it on the label or on anything
|
|
48
|
+
around it, e.g. style="--gw-label-mark: var(--gw-accent)". */
|
|
46
49
|
.gw-label {
|
|
47
50
|
display: inline-flex;
|
|
48
51
|
align-items: center;
|
|
49
52
|
gap: 8px;
|
|
50
53
|
font-size: 13px;
|
|
51
54
|
font-weight: 500;
|
|
52
|
-
line-height: 1.4;
|
|
53
55
|
color: var(--gw-muted);
|
|
54
56
|
}
|
|
55
57
|
.gw-label::before {
|
|
56
58
|
content: "";
|
|
57
|
-
flex: none;
|
|
58
59
|
width: 8px;
|
|
59
60
|
height: 8px;
|
|
60
|
-
border-radius: var(--gw-radius-
|
|
61
|
-
background: var(--gw-
|
|
61
|
+
border-radius: var(--gw-radius-2);
|
|
62
|
+
background: var(--gw-label-mark, var(--gw-ink));
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/* links inside running text (and anywhere in .gw-prose) get a visible link style; everything else opts in with .gw-link */
|
|
@@ -78,19 +79,22 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
78
79
|
:focus-visible { border-radius: var(--gw-radius-4); outline: 2px solid var(--gw-accent); outline-offset: 3px; }
|
|
79
80
|
[id] { scroll-margin-top: calc(var(--gw-header-height) + 16px); }
|
|
80
81
|
|
|
82
|
+
/* the skip link (0.12.0, gemboss.ai's): the accent tab in the top left corner, shown while it has the focus */
|
|
81
83
|
.gw-skip {
|
|
82
84
|
position: absolute;
|
|
83
85
|
left: -9999px;
|
|
84
|
-
top:
|
|
85
|
-
z-index:
|
|
86
|
-
padding: 12px
|
|
87
|
-
border-radius: var(--gw-radius-
|
|
88
|
-
background: var(--gw-
|
|
89
|
-
color: var(--gw-on-
|
|
86
|
+
top: 0;
|
|
87
|
+
z-index: 200;
|
|
88
|
+
padding: 12px 20px;
|
|
89
|
+
border-radius: 0 0 var(--gw-radius-card) 0;
|
|
90
|
+
background: var(--gw-accent);
|
|
91
|
+
color: var(--gw-on-accent);
|
|
92
|
+
box-shadow: var(--gw-shadow);
|
|
90
93
|
font-size: 14px;
|
|
91
94
|
font-weight: 600;
|
|
92
95
|
}
|
|
93
|
-
|
|
96
|
+
/* its corner again with the focus: a page's own :focus-visible corners, loaded after the kit, must not round the tab */
|
|
97
|
+
.gw-skip:focus { left: 0; border-radius: 0 0 var(--gw-radius-card) 0; }
|
|
94
98
|
|
|
95
99
|
/* words for a screen reader only (0.7.0): " (opens in a new tab)" in a link with target="_blank" */
|
|
96
100
|
.gw-visually-hidden {
|
|
@@ -104,10 +108,11 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
|
|
|
104
108
|
border: 0;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
/* scroll reveal: the script adds .is-in
|
|
111
|
+
/* scroll reveal: the script adds .is-in (system.json motion.reveal: when, the stagger, the failsafe); the animation is
|
|
112
|
+
a second failsafe, for a page whose script never ran, that shows it anyway after 2.5s */
|
|
108
113
|
.gw-reveal {
|
|
109
114
|
opacity: 0;
|
|
110
|
-
transform: translateY(
|
|
115
|
+
transform: translateY(var(--gw-reveal-distance));
|
|
111
116
|
transition: opacity var(--gw-reveal-ms) var(--gw-ease), transform var(--gw-reveal-ms) var(--gw-ease);
|
|
112
117
|
animation: gw-reveal-failsafe 1ms linear 2.5s forwards;
|
|
113
118
|
}
|