@gemboss/website-ui 0.12.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/docs/theming.md CHANGED
@@ -3,8 +3,9 @@
3
3
  A GemCommerce brand (GemPages, Gemians…) uses the kit as it is and chooses six things, **and nothing else**:
4
4
 
5
5
  1. **the accent variables** (eight, light and dark),
6
- 2. **the wash hues** (only if the brand has its own),
7
- 3. **the logo** (and the favicon),
6
+ 2. **the wash hues** (only if the brand has its own), and the colours of its **announcement bar** and **Stage** (0.13.0,
7
+ only if it uses them and wants its own),
8
+ 3. **the logo** (and the favicon): a mark beside the name, or one drawing of both (0.13.0),
8
9
  4. **the action colour mode**: `data-gw-action="ink|accent"` on `<html>`,
9
10
  5. **the radius profile**: `data-gw-radius="tight|soft"` on `<html>`,
10
11
  6. **the header style**: the flat bar, or `.gw-header--float`.
@@ -46,9 +47,30 @@ brand's page lights in the brand's own colour, in light, in dark and in night ba
46
47
  takes the corners of the element it lights, so both radius profiles hold. A brand does not choose its colour, its
47
48
  strength or its size (they are tokens), and never a second colour.
48
49
 
49
- The logo and the favicon are files, not variables: the `<img class="gw-brand__mark">` (26×26, SVG preferred, with
50
- `alt=""` because the name is written next to it) and the `<span class="gw-brand__name">` in the header and footer
51
- partials, and `<link rel="icon">` in the page head.
50
+ The logo and the favicon are files, not variables, in the header and footer partials and `<link rel="icon">` in the
51
+ page head. The logo is one of two shapes:
52
+
53
+ - **a mark beside the name** (GemBoss): `<img class="gw-brand__mark">` (34×34, SVG preferred, `alt=""` because the name
54
+ is written next to it) and `<span class="gw-brand__name">`;
55
+ - **one drawing of both** (0.13.0, Gemians: the mark and the wordmark in one file): `<img class="gw-brand__logo">`, 26px
56
+ tall, its width the file's, its `alt` the brand's name. A logo that only works on a dark ground (a white wordmark)
57
+ comes with a second file in ink for the light theme, or the other way round: the file for light grounds first, then
58
+ the file for dark grounds as `class="gw-brand__logo gw-brand__logo--dark"`. The kit shows one per theme (the system's
59
+ setting or the toggle). Keep the brand's own drawing: recolour the wordmark, never redraw it in the kit's type.
60
+
61
+ ```html
62
+ <a class="gw-brand" href="/"><img class="gw-brand__logo" src="/assets/img/logo.png" alt="Gemians" width="150" height="26"><img class="gw-brand__logo gw-brand__logo--dark" src="/assets/img/logo-on-dark.png" alt="Gemians" width="150" height="26"></a>
63
+ ```
64
+
65
+ `check` reports a logo without its name in `alt`, without `width` and `height`, or a dark file without the light one
66
+ right before it (`brand-markup`).
67
+
68
+ **The announcement bar and the Stage** (0.13.0). The bar over the header (`--gw-announce-*`, `system.json`
69
+ `color.announce`) and the Stage the mega menu's pictures sit on (`--gw-stage-*`, `color.stage`) are GemBoss's iris by
70
+ default. A brand that shows them may set its own in the same `:root` block: the bar's `bg`, `ink`, `line`, `tag`,
71
+ `tag-ink`, `link`, `link-line`; the Stage's `glow`, `glow-2`, `top`, `bottom`, `dot`, `ink`. Both stay the same in
72
+ light and dark. `check` computes the bar's words and link on its ground and the Stage's words on both ends of its
73
+ gradient, and fails below 4.5:1 (`brand-contrast`).
52
74
 
53
75
  The footer (0.7.0, `templates/footer.html`) is the same for every brand; a brand fills it. Its family row lists the
54
76
  brand's siblings (a GemPages site: GemBoss, GemX, GemUpsells), its Ask AI row asks about the brand, and its wordmark
@@ -106,8 +128,9 @@ button filled from site CSS (`accent-button`).
106
128
 
107
129
  `check` also reports: colours outside the tokens, a redefined kit token (`token-redefined`, including the radius and
108
130
  action tokens), overrides of `.gw-*` classes, an unknown profile or one set on another element than `<html>`
109
- (`profile-unknown`), `.gw-header--float` anywhere but the header itself (`header-float-markup`), and, on a page with
110
- `data-gw-action="accent"`, accent button words below 4.5:1 (`action-contrast`).
131
+ (`profile-unknown`), `.gw-header--float` anywhere but the header itself (`header-float-markup`), on a page with
132
+ `data-gw-action="accent"`, accent button words below 4.5:1 (`action-contrast`), a brand's own announcement bar or
133
+ Stage with words below 4.5:1 (`brand-contrast`), and a one-file logo without its markup (`brand-markup`).
111
134
 
112
135
  ## A full example
113
136
 
@@ -136,7 +159,7 @@ action tokens), overrides of `.gw-*` classes, an unknown profile or one set on a
136
159
  …
137
160
  <header class="gw-header gw-header--float" data-gw-header>
138
161
  <div class="gw-wrap gw-header__bar">
139
- <a class="gw-brand" href="/"><img class="gw-brand__mark" src="/logo-mark.svg" alt="" width="26" height="26"><span class="gw-brand__name">GemPages</span></a>
162
+ <a class="gw-brand" href="/"><img class="gw-brand__mark" src="/logo-mark.svg" alt="" width="34" height="34"><span class="gw-brand__name">GemPages</span></a>
140
163
  ```
141
164
 
142
165
  ## Check the contrast before you ship
@@ -145,8 +168,10 @@ The accent family is read as text: the kit draws links, step numbers, active men
145
168
  `--gw-accent-strong`, and the label square, focus ring, check marks and the featured plan's hairline in `--gw-accent`.
146
169
  So:
147
170
 
148
- 1. **Accent text on white at least 4.5:1**: `--gw-accent-light` and `--gw-accent-strong-light` on `#ffffff`.
149
- Check `--gw-accent-strong-light` on `--gw-bg-soft` (`#f2f3f5`) too, where links sit in soft sections.
171
+ 1. **Accent text on white at least 4.5:1**: `--gw-accent-strong-light` on `#ffffff` and on `--gw-bg-soft`
172
+ (`#f2f3f5`), where links sit in soft sections. The plain `--gw-accent-light` is never text (icons, marks, the focus
173
+ ring, the featured plan's hairline): **3:1** on `#ffffff` is enough (WCAG 1.4.11), so a gold or a light green can
174
+ stay bright there (0.13.0; this line asked 4.5:1 of it before, which no gold passes).
150
175
  2. `--gw-accent-strong-light` on `--gw-accent-soft-light`: at least 4.5:1 (the accent badge, the active chip and the
151
176
  chosen option of a segmented control).
152
177
  3. `--gw-accent-dark` and `--gw-accent-strong-dark` on `#0d0d12` (dark page) and on `#0c0e14` (night band): at
@@ -158,7 +183,9 @@ So:
158
183
  build does the same for its own colours.
159
184
 
160
185
  Any WCAG contrast checker works (paste the two hex values). A bright brand colour (yellow, light green, cyan) almost
161
- never passes on white: darken it for `-light` and keep the bright one for `-dark`. If you cannot get 4.5:1 without
186
+ never passes as text on white: darken it for `-strong-light`, keep it near the brand for `-light` (3:1) and bright
187
+ for `-dark`. A colour that needs dark words on it (gold, yellow) cannot be the button's fill and its text colour at
188
+ once: keep the ink button (Gemians does). If you cannot get 4.5:1 without
162
189
  changing the brand colour beyond recognition, keep the kit's accent and use the brand colour only in the logo and
163
190
  illustrations, and keep the ink button.
164
191
 
package/js/website.js CHANGED
@@ -1,9 +1,9 @@
1
- /*! @gemboss/website-ui 0.12.0 website.js | GENERATED by scripts/build.mjs from js/website.mjs (the source, with its comments): edit that file | spec: docs/markup-contract.md | load with defer */
1
+ /*! @gemboss/website-ui 0.13.0 website.js | GENERATED by scripts/build.mjs from js/website.mjs (the source, with its comments): edit that file | spec: docs/markup-contract.md | load with defer */
2
2
  (function () {
3
3
  'use strict';
4
4
  if (window.gwWebsite) return;
5
5
 
6
- var version = '0.12.0';
6
+ var version = '0.13.0';
7
7
  var api = { version: version, init: init, destroy: destroy };
8
8
  var doc, root, mine, fine, sys, wide, desk, mid, live = [], queued = false;
9
9
  var still, lights;
package/js/website.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // the export line and the // comments, plus auto-init): edit this file, then run the build. Keep comments on lines of
7
7
  // their own, so the build can leave them out.
8
8
 
9
- var version = '0.12.0';
9
+ var version = '0.13.0';
10
10
  var api = { version: version, init: init, destroy: destroy };
11
11
  // Page state. Set up by the first init(), never at import: a server can import this file.
12
12
  var doc, root, mine, fine, sys, wide, desk, mid, live = [], queued = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The GemBoss website kit: tokens, header, footer, sections and the rules (AGENTS.md) that GemCommerce public websites share, plus a CLI that checks a site, reports kit gaps and upgrades.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -134,7 +134,7 @@
134
134
  <div class="gw-wrap">
135
135
  <div class="gw-footer__grid">
136
136
  <div class="gw-footer__brand">
137
- <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="26" height="26"><span class="gw-brand__name">[Brand]</span></a>
137
+ <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="34" height="34"><span class="gw-brand__name">[Brand]</span></a>
138
138
  <p>[One sentence on what the brand does for the reader.]</p>
139
139
  </div>
140
140
  <nav class="gw-footer__col" aria-label="[Column heading]">
@@ -11,6 +11,8 @@
11
11
  role="status" line ("Sent. We'll reply to name@example.com.").
12
12
  - Consent boxes start unchecked, say exactly what the reader gets, and are never required to get something else.
13
13
  - One field and its button (a newsletter, "email me the guide"): the .gw-form-inline row, see templates/footer.html.
14
+ As a page's first screen (a contact page, 0.13.0): the heading is the page's one <h1>, drawn at the section size
15
+ (<h1 class="gw-h2">), and the page needs no hero above it.
14
16
  Limits: three fields, one .gw-btn; no field that the team does not read.
15
17
  -->
16
18
  <section class="gw-section" aria-labelledby="[form-h]">
@@ -3,7 +3,8 @@
3
3
  Variants: .gw-hero--center (centred, for short promises), .gw-hero--compact (subpages: less height, h2-sized h1).
4
4
  Add .gw-band--night for a dark hero (it counts as one of the page's three night bands).
5
5
  Limits: a short label, a two-part headline (muted first sentence, ink second), at most two lines under it,
6
- one .gw-btn and one secondary action. The optional window holds a real product screen (img with alt, width, height)
6
+ one .gw-btn and one secondary action. Instead of the window, a photo: <figure class="gw-figure"> with its caption.
7
+ The optional window holds a real product screen (img with alt, width, height)
7
8
  or a drawn mock-up. Numbers in it that are not real get the caption "Illustrative data." (vi: "Dữ liệu minh hoạ.",
8
9
  zh: "示意数据。"); real ones get their source there. Never tuck the hero under the header.
9
10
  -->
@@ -2,7 +2,7 @@
2
2
  Split: one idea told in words beside a figure (a real screen, or a drawn mock-up of the product).
3
3
  .gw-split--reverse puts the figure on the left on wide screens; on phones the words always come first.
4
4
  Alternate --reverse between consecutive splits. Limits: label, h2, at most two lines, a check list of three to
5
- five items, one action (a link or one button). The figure is a .gw-window or a <figure> with alt text; numbers in
5
+ five items, one action (a link or one button). The figure is a .gw-window (a screen) or a <figure class="gw-figure"> (a photo, with alt text and a caption); numbers in
6
6
  a window that are not real get the caption "Illustrative data.", real ones their source.
7
7
  -->
8
8
  <section class="gw-section">
@@ -0,0 +1,23 @@
1
+ // Every static HTML page, served byte for byte from the built file and prerendered at build time, so each page is a
2
+ // file on the CDN. A more specific route (app/blog/[slug]/page.js…) wins over this catch-all. Unknown URL: 404.html, 404.
3
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { ROOT, pages } from "../../lib/pages.mjs";
7
+
8
+ export const dynamic = "force-static";
9
+ export const dynamicParams = true;
10
+
11
+ export function generateStaticParams() {
12
+ return [...pages().keys()].map((route) => ({ path: route === "/" ? [] : route.slice(1).split("/") }));
13
+ }
14
+
15
+ const HTML = { "content-type": "text/html; charset=utf-8" };
16
+
17
+ export async function GET(_req, { params }) {
18
+ const { path } = await params;
19
+ const route = "/" + (path || []).join("/");
20
+ const file = pages().get(route);
21
+ if (file) return new Response(await readFile(join(/*turbopackIgnore: true*/ ROOT, file)), { status: 200, headers: HTML });
22
+ return new Response(await readFile(join(/*turbopackIgnore: true*/ ROOT, "404.html")), { status: 404, headers: HTML });
23
+ }
@@ -0,0 +1,3 @@
1
+ /* Stopgaps for what @gemboss/website-ui does not have yet (AGENTS.md section 9). Each: classes prefixed kitgap-, the
2
+ kit's tokens and scales only, never a .gw-* override, and a comment naming its gap (gap #N, from
3
+ npx gemboss-website gap). npx gemboss-website upgrade says when the kit ships it: then delete the stopgap. */
@@ -0,0 +1,16 @@
1
+ /* The site's own CSS, linked after the kit (website.css). Two jobs only (AGENTS.md sections 7 and 8):
2
+ 1. the brand's choices, in ONE :root block: its accent family and wash, and if it has its own, its announcement bar
3
+ and Stage colours (docs/theming.md). Delete the lines the brand keeps from the kit; GemBoss sets none.
4
+ 2. what the kit cannot know: an illustration, a drawn mock-up of the product. Tokens and scales only, classes with
5
+ the site's own prefix, never .gw-*. Something reusable the kit lacks is a gap: kit-gaps.css. */
6
+ :root {
7
+ /* --gw-accent-light: #5661f5; the accent on light pages: links' marks, focus ring, label square, check marks */
8
+ /* --gw-accent-strong-light: #3a44de; accent text: 4.5:1 on --gw-bg and --gw-bg-soft */
9
+ /* --gw-accent-soft-light: #eef0fe; its tint */
10
+ /* --gw-on-accent-light: #ffffff; words on it (the button, under data-gw-action="accent") */
11
+ /* --gw-accent-dark: #7c84ff; the same four in dark mode and night bands */
12
+ /* --gw-accent-strong-dark: #a3a9ff; */
13
+ /* --gw-accent-soft-dark: #1f2140; */
14
+ /* --gw-on-accent-dark: #0d0d12; */
15
+ /* --gw-wash-pink: #ff6f5e; --gw-wash-violet: #7c8af7; --gw-wash-sky: #43b0ff; --gw-wash-mint: #1fcf9f; */
16
+ }
@@ -0,0 +1,4 @@
1
+ /node_modules/
2
+ /.next/
3
+ /public/
4
+ .DS_Store
@@ -0,0 +1,41 @@
1
+ // The static HTML pages this site serves, and at which URL: the Next route (app/[[...path]]/route.js) serves them, and
2
+ // `npx gemboss-website build` writes the header and footer into them. An allow-list, not "every .html": partials/ and
3
+ // node_modules/ are full of HTML that is not a page. A new folder of pages goes into DIRS on purpose, e.g. ["blog", true].
4
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
5
+ import { existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join, relative, sep } from "node:path";
7
+
8
+ export const ROOT = process.cwd();
9
+ // [folder, recursive?]
10
+ const DIRS = [["", false]];
11
+
12
+ // turbopackIgnore: these reads only run at BUILD (prerendering every page); without it Next traces the whole project
13
+ // into the function. At runtime the function only needs 404.html (next.config.mjs).
14
+ function htmlIn(dir, deep) {
15
+ const abs = join(/*turbopackIgnore: true*/ ROOT, dir);
16
+ if (!existsSync(/*turbopackIgnore: true*/ abs)) return [];
17
+ const out = [];
18
+ for (const name of readdirSync(/*turbopackIgnore: true*/ abs)) {
19
+ const full = join(/*turbopackIgnore: true*/ abs, name);
20
+ if (statSync(/*turbopackIgnore: true*/ full).isDirectory()) {
21
+ if (deep) out.push(...htmlIn(join(dir, name), true));
22
+ } else if (name.endsWith(".html")) {
23
+ out.push(relative(ROOT, full).split(sep).join("/"));
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** file → clean URL, like Vercel's cleanUrls: index.html → /, pricing.html → /pricing. */
30
+ export function routeOf(file) {
31
+ const stem = file.slice(0, -".html".length);
32
+ if (stem === "index") return "/";
33
+ return "/" + stem.replace(/\/index$/, "");
34
+ }
35
+
36
+ /** URL → file for every served page (404.html is the error page, not a page). */
37
+ export function pages() {
38
+ const map = new Map();
39
+ for (const [dir, deep] of DIRS) for (const f of htmlIn(dir, deep)) if (f !== "404.html") map.set(routeOf(f), f);
40
+ return map;
41
+ }
@@ -0,0 +1,15 @@
1
+ // Clean URLs: /pricing.html and /index.html redirect to the clean URL, /pricing/ to /pricing.
2
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
3
+ /** @type {import('next').NextConfig} */
4
+ export default {
5
+ trailingSlash: false,
6
+ poweredByHeader: false,
7
+ async redirects() {
8
+ return [
9
+ { source: "/index.html", destination: "/", permanent: true },
10
+ { source: "/:path(.*)\\.html", destination: "/:path", permanent: true },
11
+ ];
12
+ },
13
+ // an unknown URL runs the route at request time and reads 404.html: ship that file with the function
14
+ outputFileTracingIncludes: { "/*": ["./404.html"] },
15
+ };
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Copy the site's static files (not its HTML pages) into public/ before `next build`: Next serves only public/ and the
3
+ // routes in app/. An ALLOW-LIST: only what is listed here becomes a public URL. Written by
4
+ // `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
5
+ // node stage-public.mjs # rebuild public/ (npm run build runs it)
6
+ // node stage-public.mjs --check # CI: public/ has what it needs and nothing internal
7
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
8
+ import { join, relative, sep } from "node:path";
9
+
10
+ const ROOT = process.cwd();
11
+ const OUT = join(ROOT, "public");
12
+ const ROOT_FILES = ["favicon.svg", "robots.txt", "sitemap.xml"];
13
+ const DIRS = ["assets"];
14
+ const SKIP = (name) => name.endsWith(".html") || name.endsWith(".md") || name === ".DS_Store";
15
+
16
+ function walk(dir) {
17
+ const out = [];
18
+ for (const name of readdirSync(dir)) {
19
+ const full = join(dir, name);
20
+ if (statSync(full).isDirectory()) out.push(...walk(full));
21
+ else out.push(relative(OUT, full).split(sep).join("/"));
22
+ }
23
+ return out;
24
+ }
25
+
26
+ function stage() {
27
+ rmSync(OUT, { recursive: true, force: true });
28
+ mkdirSync(OUT, { recursive: true });
29
+ for (const f of ROOT_FILES) if (existsSync(join(ROOT, f))) cpSync(join(ROOT, f), join(OUT, f));
30
+ for (const d of DIRS) if (existsSync(join(ROOT, d))) cpSync(join(ROOT, d), join(OUT, d), { recursive: true, filter: (src) => !SKIP(src.split(sep).pop()) });
31
+ const files = walk(OUT);
32
+ console.log(`public/: ${files.length} files`);
33
+ return files;
34
+ }
35
+
36
+ function check() {
37
+ const files = stage();
38
+ const bad = [];
39
+ for (const need of ["favicon.svg", "assets/kit/website.css", "assets/kit/website.js", "assets/site.css"]) {
40
+ if (!files.includes(need)) bad.push(`missing public file: ${need}`);
41
+ }
42
+ for (const f of files) if (/\.(html|md|py|mjs|sh)$/.test(f) || /(^|\/)(package(-lock)?\.json|vercel\.json)$/.test(f)) bad.push(`internal file in public/: ${f}`);
43
+ for (const b of bad) console.error(" " + b);
44
+ console.log(bad.length ? `stage-public: RED, ${bad.length}` : "stage-public: green");
45
+ return bad.length ? 1 : 0;
46
+ }
47
+
48
+ if (process.argv.includes("--check")) process.exit(check());
49
+ stage();
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs",
4
+ "buildCommand": "npm run build"
5
+ }
package/website.css CHANGED
@@ -1,4 +1,4 @@
1
- /*! @gemboss/website-ui 0.12.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
1
+ /*! @gemboss/website-ui 0.13.0 | generated by scripts/build.mjs from the files in css/. Edit those and not this file */
2
2
  /* @gemboss/website-ui tokens. GENERATED from system.json by scripts/build.mjs: do not edit by hand. */
3
3
  :root {
4
4
  color-scheme: light;
@@ -740,6 +740,13 @@ h5, h6 { font-size: 16px; font-weight: 600; letter-spacing: 0; }
740
740
  }
741
741
  .gw-window__body:has(> img:only-child) + .gw-window__caption { padding-top: 12px; }
742
742
 
743
+ /* figure (0.13.0, Gemians): a photo and its caption where a section shows one outside the prose (beside words in a
744
+ split, under a hero's actions): the picture at the card radius, the caption 14px, muted, 12px under it, as a figure
745
+ in .gw-prose. A screen of the product goes in .gw-window instead. */
746
+ .gw-figure > :is(img, video, picture) { display: block; width: 100%; height: auto; border-radius: var(--gw-radius-card); }
747
+ .gw-figure > picture > img { display: block; width: 100%; height: auto; border-radius: inherit; }
748
+ .gw-figure > figcaption { margin-top: 12px; color: var(--gw-muted); font-size: 14px; line-height: 1.5; }
749
+
743
750
  /* icon: 20px inline stroke SVG (1.7 to 2px stroke, currentColor) */
744
751
  .gw-icon { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 20px; height: 20px; }
745
752
  .gw-icon svg { width: 20px; height: 20px; }
@@ -1541,6 +1548,19 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
1541
1548
  :root:not([data-theme="light"]) .gw-theme-toggle__moon { display: none; }
1542
1549
  }
1543
1550
 
1551
+ /* ---- 0.13.0: the brand's logo as one drawing (Gemians): the mark and the wordmark in one file, in place of the mark and
1552
+ the name, 26px tall, its width the file's. Its alt is the brand's name. A logo drawn for dark grounds only (a white
1553
+ wordmark) comes as a second file, .gw-brand__logo--dark, right after the first: it shows in dark (the system's or the
1554
+ toggle's) and the first one hides, the toggle icons' rule. One file that reads on both: no --dark. */
1555
+ .gw-brand__logo { display: block; flex: none; width: auto; height: 26px; }
1556
+ .gw-brand__logo--dark { display: none; }
1557
+ :root[data-theme="dark"] .gw-brand__logo--dark { display: block; }
1558
+ :root[data-theme="dark"] .gw-brand__logo:has(+ .gw-brand__logo--dark) { display: none; }
1559
+ @media (prefers-color-scheme: dark) {
1560
+ :root:not([data-theme="light"]) .gw-brand__logo--dark { display: block; }
1561
+ :root:not([data-theme="light"]) .gw-brand__logo:has(+ .gw-brand__logo--dark) { display: none; }
1562
+ }
1563
+
1544
1564
  /* footer: brand block, link columns, bottom row. One hairline on top, white like the page. */
1545
1565
  .gw-footer {
1546
1566
  padding-block: 64px 32px;
@@ -1807,7 +1827,7 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
1807
1827
  radial-gradient(420px 320px at 52% 118%, color-mix(in srgb, var(--gw-wash-mint) 10%, transparent), transparent 62%);
1808
1828
  }
1809
1829
  .gw-hero h1 .gw-dim { display: block; }
1810
- .gw-hero > .gw-wrap > .gw-window { margin-top: 48px; }
1830
+ .gw-hero > .gw-wrap > :is(.gw-window, .gw-figure) { margin-top: 48px; }
1811
1831
  .gw-hero--center { text-align: center; }
1812
1832
  .gw-hero--center > .gw-wrap > * { margin-inline: auto; }
1813
1833
  .gw-hero--center .gw-actions { justify-content: center; }
@@ -1840,7 +1860,7 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
1840
1860
  .gw-logos img { width: auto; height: 28px; }
1841
1861
 
1842
1862
  /* stats: three or four facts, each with its source */
1843
- .gw-stats { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; margin: 0; padding: 0; list-style: none; }
1863
+ .gw-stats { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; padding: 0; list-style: none; }
1844
1864
  .gw-stats__value { font-size: var(--gw-display-h2); font-weight: 600; letter-spacing: -0.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
1845
1865
  .gw-stats__label { margin-top: 8px; max-width: 28ch; color: var(--gw-muted); font-size: 14px; }
1846
1866
  .gw-stats__source { margin-top: 8px; color: var(--gw-muted); font-size: 12px; }
@@ -1873,7 +1893,6 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
1873
1893
  display: grid;
1874
1894
  grid-template-columns: minmax(0, 1fr);
1875
1895
  gap: 32px 24px;
1876
- margin: 0;
1877
1896
  padding: 0;
1878
1897
  list-style: none;
1879
1898
  counter-reset: gw-step;
@@ -2305,7 +2324,7 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
2305
2324
  .gw-card__media first, a .gw-label for the category, the title (its link covers the card, so the card is one target
2306
2325
  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
2307
2326
  column on phones, two from 768px, three from 1080px. --compact: one column of rows, no card and no picture. */
2308
- .gw-post-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; margin: 0; padding: 0; list-style: none; }
2327
+ .gw-post-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; padding: 0; list-style: none; }
2309
2328
  .gw-post-list > li { display: flex; min-width: 0; }
2310
2329
  .gw-post { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; }
2311
2330
  .gw-post:hover { border-color: var(--gw-line-strong); }
@@ -2521,7 +2540,6 @@ html.is-menu-open .gw-header:has(> .gw-mobile-menu.is-drill) { position: sticky;
2521
2540
  display: grid;
2522
2541
  grid-template-columns: minmax(0, 1fr);
2523
2542
  gap: 16px;
2524
- margin: 0;
2525
2543
  padding: 0;
2526
2544
  list-style: none;
2527
2545
  }