@rmdes/indiekit-endpoint-site-config 1.0.0-alpha.3 → 1.0.0-alpha.7
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/README.md +89 -3
- package/index.js +2 -0
- package/lib/controllers/api.js +302 -20
- package/lib/controllers/branding.js +546 -48
- package/lib/render/resolve-tier2.js +101 -0
- package/lib/render/surface-presets.js +16 -2
- package/lib/render/write-critical-css.js +169 -0
- package/lib/render/write-site-json.js +2 -1
- package/lib/render/write-theme-css.js +224 -46
- package/lib/storage/defaults.js +66 -13
- package/lib/validators/color.js +65 -5
- package/lib/validators/contrast.js +205 -0
- package/locales/en.json +64 -10
- package/package.json +3 -1
- package/views/partials/role-override.njk +114 -0
- package/views/site-config-branding.njk +700 -53
package/README.md
CHANGED
|
@@ -1,9 +1,95 @@
|
|
|
1
1
|
# @rmdes/indiekit-endpoint-site-config
|
|
2
2
|
|
|
3
|
-
Site identity, branding, layout, and feature
|
|
3
|
+
Site identity, branding, layout, and feature-flag configuration endpoint for [Indiekit](https://getindiekit.com).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Provides an admin UI for configuring a multi-tenant Indiekit deployment from a single canonical theme. Runtime CSS generation lets operators customize colors, typography, and layout without redeploying the theme.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
`1.0.0-alpha.7` — usable, in production on [rmendes.net](https://rmendes.net). API may still shift before `1.0.0`.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **12-control admin UI** for site theming (palette presets, semantic role overrides, mode preference)
|
|
14
|
+
- **Runtime CSS generation** — writes `theme.css` and `critical.css` to disk on each save; Eleventy picks them up via `inlineFile` filter on next rebuild
|
|
15
|
+
- **APCA Lc contrast validation** — blocks saves with unreadable color combinations (Lc < 30 hard, < 45 warn)
|
|
16
|
+
- **Version history** — last 10 saves snapshot to MongoDB; one-click revert
|
|
17
|
+
- **Reset per-section + global** — undo any subsection or all branding back to defaults
|
|
18
|
+
- **Live preview iframe** — pending form state previewed before save via query-param-driven endpoint
|
|
19
|
+
- **Mode-aware preview toggle** — preview light or dark mode independently of OS preference
|
|
20
|
+
|
|
21
|
+
## Architecture — 3-Tier Token System
|
|
22
|
+
|
|
23
|
+
| Tier | What | Examples |
|
|
24
|
+
|------|------|----------|
|
|
25
|
+
| **1. Reference (palette)** | Derived OKLCH-based color scales | `--c-surface-50..950`, `--c-accent-50..950` |
|
|
26
|
+
| **2. Semantic (roles)** | What templates actually USE | `--c-bg`, `--c-fg`, `--c-fg-muted`, `--c-heading`, `--c-link`, `--c-action`, `--c-action-fg`, `--c-surface`, `--c-border`, `--c-focus` |
|
|
27
|
+
| **3. Alert states** | Fixed for accessibility | `--c-success`, `--c-warning`, `--c-danger` (with `-fg` variants) |
|
|
28
|
+
|
|
29
|
+
Templates reference Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.). When the admin saves a role override, only that semantic token changes — every template element bound to that role updates within one Eleventy rebuild cycle.
|
|
30
|
+
|
|
31
|
+
This mirrors the established CMS pattern documented by [WordPress theme.json](https://developer.wordpress.org/themes/global-settings-and-styles/), [Material Design 3](https://m3.material.io/styles/color/system/overview), and [W3C Design Tokens Community Group](https://design-tokens.github.io/community-group/format/).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @rmdes/indiekit-endpoint-site-config
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
In your `indiekit.config.js`:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import SiteConfigEndpoint from "@rmdes/indiekit-endpoint-site-config";
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
plugins: [
|
|
48
|
+
new SiteConfigEndpoint({
|
|
49
|
+
mountPath: "/site-config", // default
|
|
50
|
+
}),
|
|
51
|
+
// ... other plugins
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Storage
|
|
57
|
+
|
|
58
|
+
- MongoDB collection: `siteConfig`
|
|
59
|
+
- Document `_id`: `"primary"` (singleton per deployment)
|
|
60
|
+
- Schema version: `2` (Path D, Phase 2a+)
|
|
61
|
+
|
|
62
|
+
## Theme integration
|
|
63
|
+
|
|
64
|
+
The companion Eleventy theme [`indiekit-eleventy-theme`](https://github.com/rmdes/indiekit-eleventy-theme) reads:
|
|
65
|
+
- `/app/data/content/_data/theme.css` (runtime CSS vars, via `inlineFile` filter in a `theme.css.njk` template)
|
|
66
|
+
- `/app/data/content/_data/critical.css` (per-site critical CSS for first paint)
|
|
67
|
+
- `/app/data/content/_data/site-config.json` (structured config for `_data/site.js`)
|
|
68
|
+
|
|
69
|
+
The theme's `tailwind.config.js` exposes Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.) bound to the CSS variables this plugin emits.
|
|
70
|
+
|
|
71
|
+
## Mode handling
|
|
72
|
+
|
|
73
|
+
Three states: `light`, `dark`, `auto`. In `auto` mode the plugin emits both `@media (prefers-color-scheme: dark)` AND a `.dark` class block, with the `@media` rule scoped to `:root:not(.light)` so an explicit user override (via JS toggle adding `.light`) wins over OS preference.
|
|
74
|
+
|
|
75
|
+
## Testing
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm test
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Currently 159 tests covering schema, storage, palette derivation, semantic resolution, contrast validation, history snapshotting, reset paths, and form parsing.
|
|
6
82
|
|
|
7
83
|
## Dependencies
|
|
8
84
|
|
|
9
|
-
- `
|
|
85
|
+
- `apca-w3` + `colorparsley` — APCA Lc contrast calculation
|
|
86
|
+
- `culori` — OKLCH palette derivation
|
|
87
|
+
- `@indiekit/error`, `@indiekit/frontend`, `express@^5`
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
This plugin is developed inside the [Indiekit development workspace](https://github.com/rmdes/indiekit-dev). The design spec lives at `documentation-central/plans/2026-05-24-theming-v2-design.md` in that workspace.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { apiRouter } from "./lib/controllers/api.js";
|
|
|
10
10
|
import { getSiteConfig } from "./lib/storage/get-site-config.js";
|
|
11
11
|
import { maybeSeedFromEnv } from "./lib/storage/seed-from-env.js";
|
|
12
12
|
import { writeThemeCss } from "./lib/render/write-theme-css.js";
|
|
13
|
+
import { writeCriticalCss } from "./lib/render/write-critical-css.js";
|
|
13
14
|
import { writeSiteJson } from "./lib/render/write-site-json.js";
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -69,6 +70,7 @@ export default class SiteConfigEndpoint {
|
|
|
69
70
|
await maybeSeedFromEnv(Indiekit);
|
|
70
71
|
const config = await getSiteConfig(Indiekit);
|
|
71
72
|
await writeThemeCss(config);
|
|
73
|
+
await writeCriticalCss(config);
|
|
72
74
|
await writeSiteJson(config);
|
|
73
75
|
} catch (error) {
|
|
74
76
|
console.warn("[site-config] initial render skipped:", error.message);
|
package/lib/controllers/api.js
CHANGED
|
@@ -1,43 +1,325 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import { getSiteConfig } from "../storage/get-site-config.js";
|
|
2
|
+
import { getSiteConfig, mergeWithDefaults } from "../storage/get-site-config.js";
|
|
3
3
|
import { renderThemeCss } from "../render/write-theme-css.js";
|
|
4
|
+
import { parseBrandingForm } from "./branding.js";
|
|
5
|
+
import { validateBranding } from "../validators/contrast.js";
|
|
4
6
|
|
|
5
7
|
export function apiRouter(Indiekit) {
|
|
6
8
|
const router = express.Router();
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* GET /site-config/api/preview
|
|
12
|
+
*
|
|
13
|
+
* Renders a live preview of a theme — either the persisted state (when no
|
|
14
|
+
* query params are present) or a pending state derived from query params.
|
|
15
|
+
*
|
|
16
|
+
* The branding form's color pickers submit `input` events to the iframe
|
|
17
|
+
* (debounced ~200ms) and re-set the iframe's src with a serialized form
|
|
18
|
+
* snapshot. This endpoint runs the same parser the POST handler uses —
|
|
19
|
+
* with `skipContrastCheck: true` so a low-contrast color choice can
|
|
20
|
+
* still be PREVIEWED, only blocked from being SAVED.
|
|
21
|
+
*
|
|
22
|
+
* Query params (all optional):
|
|
23
|
+
* surfacePreset — Tier 1 input
|
|
24
|
+
* accentBase — Tier 1 input (hex)
|
|
25
|
+
* accentPreset — metadata
|
|
26
|
+
* mode — light | dark | auto
|
|
27
|
+
* surfaceCustom_<tone> — when surfacePreset=custom
|
|
28
|
+
* roles_<role>_inherit=1 — inherit palette default
|
|
29
|
+
* roles_<role>_light/dark — override (both required if neither inherit)
|
|
30
|
+
* typography_sans/serif/mono/hosting
|
|
31
|
+
* previewMode — light | dark | auto (overrides mode for preview only)
|
|
32
|
+
*
|
|
33
|
+
* The endpoint always returns 200 with HTML; for invalid inputs (e.g. bad
|
|
34
|
+
* accent hex), it falls back to the persisted state with a warning banner
|
|
35
|
+
* so the user can SEE the broken state and recover from it.
|
|
36
|
+
*/
|
|
8
37
|
router.get("/preview", async (req, res, next) => {
|
|
9
38
|
try {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
39
|
+
const persisted = await getSiteConfig(Indiekit);
|
|
40
|
+
const hasQuery = req.query && Object.keys(req.query).length > 0;
|
|
41
|
+
|
|
42
|
+
let configForPreview = persisted;
|
|
43
|
+
let parseError = null;
|
|
44
|
+
let contrastResults = [];
|
|
45
|
+
|
|
46
|
+
if (hasQuery) {
|
|
47
|
+
// Bridge multipart-friendly query params to the urlencoded shape
|
|
48
|
+
// parseBrandingForm expects. Express already gives us a plain
|
|
49
|
+
// object via req.query — Object.entries casts arrays to strings
|
|
50
|
+
// when a key appears more than once (we ignore that edge case).
|
|
51
|
+
const parsed = parseBrandingForm(
|
|
52
|
+
req.query,
|
|
53
|
+
persisted.branding?.roles || {},
|
|
54
|
+
{ skipContrastCheck: true },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (parsed.ok) {
|
|
58
|
+
// Build a synthetic full config: take the persisted state and
|
|
59
|
+
// overlay the pending branding subtree. mergeWithDefaults guards
|
|
60
|
+
// against missing keys.
|
|
61
|
+
configForPreview = mergeWithDefaults({
|
|
62
|
+
...persisted,
|
|
63
|
+
branding: {
|
|
64
|
+
...persisted.branding,
|
|
65
|
+
...parsed.patch.branding,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
parseError = parsed.message;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Per-mode preview override: the iframe's light/dark toggle button
|
|
74
|
+
// sets `previewMode` independent of the persisted mode setting.
|
|
75
|
+
const previewMode =
|
|
76
|
+
typeof req.query.previewMode === "string" &&
|
|
77
|
+
["light", "dark", "auto"].includes(req.query.previewMode)
|
|
78
|
+
? req.query.previewMode
|
|
79
|
+
: configForPreview.branding.mode;
|
|
80
|
+
|
|
81
|
+
// We render theme.css with the pending mode so the preview iframe
|
|
82
|
+
// shows colors for the chosen side without needing JS to flip
|
|
83
|
+
// .dark on the iframe (which works too — both paths are wired).
|
|
84
|
+
const themeConfig = {
|
|
85
|
+
...configForPreview,
|
|
86
|
+
branding: { ...configForPreview.branding, mode: previewMode },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Run contrast check on the preview's resolved state so the iframe
|
|
90
|
+
// can show whether the visible colors are passing. Wrapped in try
|
|
91
|
+
// so a broken accentBase doesn't blow up the preview.
|
|
92
|
+
try {
|
|
93
|
+
contrastResults = validateBranding(configForPreview.branding);
|
|
94
|
+
} catch {
|
|
95
|
+
contrastResults = [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const themeCss = renderThemeCss(themeConfig);
|
|
12
99
|
res.setHeader("Content-Type", "text/html");
|
|
13
100
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
14
101
|
res.setHeader("Cache-Control", "no-store");
|
|
15
|
-
res.send(
|
|
16
|
-
|
|
102
|
+
res.send(renderPreviewHtml({
|
|
103
|
+
themeCss,
|
|
104
|
+
config: configForPreview,
|
|
105
|
+
previewMode,
|
|
106
|
+
parseError,
|
|
107
|
+
contrastResults,
|
|
108
|
+
}));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
next(error);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return router;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render the preview HTML body. Includes a comprehensive sample of theme
|
|
119
|
+
* elements (heading, body text, link, action button, card, focus state,
|
|
120
|
+
* alert pills) so users see how each token affects real surfaces.
|
|
121
|
+
*
|
|
122
|
+
* The light/dark mode toggle is a vanilla `<button>` with inline JS that
|
|
123
|
+
* adds/removes a `.dark` class on the document element AND persists the
|
|
124
|
+
* choice in localStorage. The persisted choice is read on load and
|
|
125
|
+
* applied to the html element.
|
|
126
|
+
*
|
|
127
|
+
* Exported for unit testing.
|
|
128
|
+
*/
|
|
129
|
+
export function renderPreviewHtml({ themeCss, config, previewMode, parseError, contrastResults }) {
|
|
130
|
+
const locale = config.identity?.locale || "en";
|
|
131
|
+
const siteName = config.identity?.name || "Untitled site";
|
|
132
|
+
const tagline = config.identity?.tagline || "";
|
|
133
|
+
const description = config.identity?.description || "Sample description for the preview.";
|
|
134
|
+
|
|
135
|
+
const failures = (contrastResults || []).filter((r) => r.status === "fail");
|
|
136
|
+
const warnings = (contrastResults || []).filter((r) => r.status === "warn");
|
|
137
|
+
|
|
138
|
+
// The button toggles `.dark` on <html> regardless of preview mode so
|
|
139
|
+
// operators can flip between sides without re-saving.
|
|
140
|
+
return `<!doctype html>
|
|
141
|
+
<html lang="${escapeHtml(locale)}">
|
|
17
142
|
<head>
|
|
18
143
|
<meta charset="utf-8">
|
|
19
144
|
<title>Preview</title>
|
|
20
145
|
<style>
|
|
21
146
|
${themeCss}
|
|
22
|
-
body {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
147
|
+
html, body { margin: 0; padding: 0; }
|
|
148
|
+
body {
|
|
149
|
+
font-family: var(--font-sans);
|
|
150
|
+
background: rgb(var(--c-bg));
|
|
151
|
+
color: rgb(var(--c-fg));
|
|
152
|
+
padding: 1.5rem;
|
|
153
|
+
min-height: 100vh;
|
|
154
|
+
}
|
|
155
|
+
h1, h2, h3 { font-family: var(--font-serif); color: rgb(var(--c-heading)); margin: 0 0 0.5rem; }
|
|
156
|
+
h1 { font-size: 1.75rem; }
|
|
157
|
+
h2 { font-size: 1.25rem; margin-top: 1.5rem; }
|
|
158
|
+
h3 { font-size: 1rem; }
|
|
159
|
+
p { line-height: 1.6; margin: 0 0 0.75rem; color: rgb(var(--c-fg)); }
|
|
160
|
+
.muted { color: rgb(var(--c-fg-muted)); font-size: 0.875rem; }
|
|
161
|
+
a { color: rgb(var(--c-link)); text-decoration: underline; }
|
|
162
|
+
.button {
|
|
163
|
+
background: rgb(var(--c-action));
|
|
164
|
+
color: rgb(var(--c-action-fg));
|
|
165
|
+
padding: 0.5rem 1rem;
|
|
166
|
+
border-radius: 0.4rem;
|
|
167
|
+
display: inline-block;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
border: none;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
}
|
|
172
|
+
.button:focus {
|
|
173
|
+
outline: 2px solid rgb(var(--c-focus));
|
|
174
|
+
outline-offset: 2px;
|
|
175
|
+
}
|
|
176
|
+
.card {
|
|
177
|
+
background: rgb(var(--c-surface));
|
|
178
|
+
border: 1px solid rgb(var(--c-border));
|
|
179
|
+
border-radius: 0.5rem;
|
|
180
|
+
padding: 1rem;
|
|
181
|
+
margin-block: 1rem;
|
|
182
|
+
}
|
|
183
|
+
code {
|
|
184
|
+
font-family: var(--font-mono);
|
|
185
|
+
background: rgb(var(--c-surface));
|
|
186
|
+
padding: 0.1em 0.3em;
|
|
187
|
+
border-radius: 0.2em;
|
|
188
|
+
border: 1px solid rgb(var(--c-border));
|
|
189
|
+
}
|
|
190
|
+
.pills { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 0.75rem; }
|
|
191
|
+
.pill {
|
|
192
|
+
display: inline-block;
|
|
193
|
+
padding: 0.25rem 0.6rem;
|
|
194
|
+
border-radius: 999px;
|
|
195
|
+
font-size: 0.75rem;
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
}
|
|
198
|
+
.pill--success { background: rgb(var(--c-success)); color: rgb(var(--c-success-fg)); }
|
|
199
|
+
.pill--warning { background: rgb(var(--c-warning)); color: rgb(var(--c-warning-fg)); }
|
|
200
|
+
.pill--danger { background: rgb(var(--c-danger)); color: rgb(var(--c-danger-fg)); }
|
|
201
|
+
|
|
202
|
+
.pv-toolbar {
|
|
203
|
+
position: sticky;
|
|
204
|
+
top: 0;
|
|
205
|
+
display: flex;
|
|
206
|
+
gap: 0.5rem;
|
|
207
|
+
align-items: center;
|
|
208
|
+
padding: 0.5rem 0.75rem;
|
|
209
|
+
margin: -1.5rem -1.5rem 1rem;
|
|
210
|
+
background: rgb(var(--c-surface));
|
|
211
|
+
border-bottom: 1px solid rgb(var(--c-border));
|
|
212
|
+
font-size: 0.75rem;
|
|
213
|
+
}
|
|
214
|
+
.pv-toggle {
|
|
215
|
+
background: transparent;
|
|
216
|
+
color: rgb(var(--c-fg));
|
|
217
|
+
border: 1px solid rgb(var(--c-border));
|
|
218
|
+
padding: 0.25rem 0.6rem;
|
|
219
|
+
border-radius: 0.25rem;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
font: inherit;
|
|
222
|
+
}
|
|
223
|
+
.pv-toggle--active {
|
|
224
|
+
background: rgb(var(--c-action));
|
|
225
|
+
color: rgb(var(--c-action-fg));
|
|
226
|
+
border-color: rgb(var(--c-action));
|
|
227
|
+
}
|
|
228
|
+
.pv-note { color: rgb(var(--c-fg-muted)); margin-left: auto; }
|
|
229
|
+
.pv-error {
|
|
230
|
+
background: rgb(var(--c-danger));
|
|
231
|
+
color: rgb(var(--c-danger-fg));
|
|
232
|
+
padding: 0.5rem 0.75rem;
|
|
233
|
+
border-radius: 0.25rem;
|
|
234
|
+
margin-block-end: 1rem;
|
|
235
|
+
font-size: 0.875rem;
|
|
236
|
+
}
|
|
237
|
+
.pv-warn {
|
|
238
|
+
background: rgb(var(--c-warning));
|
|
239
|
+
color: rgb(var(--c-warning-fg));
|
|
240
|
+
padding: 0.5rem 0.75rem;
|
|
241
|
+
border-radius: 0.25rem;
|
|
242
|
+
margin-block-end: 1rem;
|
|
243
|
+
font-size: 0.875rem;
|
|
244
|
+
}
|
|
245
|
+
.pv-fail {
|
|
246
|
+
background: rgb(var(--c-danger));
|
|
247
|
+
color: rgb(var(--c-danger-fg));
|
|
248
|
+
padding: 0.5rem 0.75rem;
|
|
249
|
+
border-radius: 0.25rem;
|
|
250
|
+
margin-block-end: 1rem;
|
|
251
|
+
font-size: 0.875rem;
|
|
252
|
+
}
|
|
27
253
|
</style>
|
|
28
254
|
</head>
|
|
29
255
|
<body>
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</
|
|
34
|
-
</
|
|
35
|
-
} catch (error) {
|
|
36
|
-
next(error);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
256
|
+
<div class="pv-toolbar">
|
|
257
|
+
<button type="button" class="pv-toggle" data-pv-mode="light">Light</button>
|
|
258
|
+
<button type="button" class="pv-toggle" data-pv-mode="dark">Dark</button>
|
|
259
|
+
<span class="pv-note">Mode: ${escapeHtml(previewMode)}</span>
|
|
260
|
+
</div>
|
|
39
261
|
|
|
40
|
-
|
|
262
|
+
${parseError ? `<div class="pv-error">Preview parse error: ${escapeHtml(parseError)}</div>` : ""}
|
|
263
|
+
${failures.length > 0 ? `<div class="pv-fail">Contrast fails: ${escapeHtml(failures.map((f) => f.message).join("; "))}</div>` : ""}
|
|
264
|
+
${warnings.length > 0 ? `<div class="pv-warn">Contrast warnings: ${escapeHtml(warnings.map((w) => w.message).join("; "))}</div>` : ""}
|
|
265
|
+
|
|
266
|
+
<h1>${escapeHtml(siteName)}</h1>
|
|
267
|
+
${tagline ? `<p class="muted">${escapeHtml(tagline)}</p>` : ""}
|
|
268
|
+
<p>${escapeHtml(description)}</p>
|
|
269
|
+
|
|
270
|
+
<p>This paragraph contains <a href="#">a sample link</a> and some <code>inline code</code> alongside <span class="muted">muted secondary text</span>.</p>
|
|
271
|
+
|
|
272
|
+
<h2>Card surface</h2>
|
|
273
|
+
<div class="card">
|
|
274
|
+
<h3>Card heading</h3>
|
|
275
|
+
<p>Cards use the <code>surface</code> role for their background and <code>border</code> for the outline. Body text inside cards inherits the page foreground color.</p>
|
|
276
|
+
<button class="button" type="button">Primary action</button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<h2>Alert tokens</h2>
|
|
280
|
+
<p class="muted">Fixed Tier 3 colors — not user-configurable.</p>
|
|
281
|
+
<div class="pills">
|
|
282
|
+
<span class="pill pill--success">Success</span>
|
|
283
|
+
<span class="pill pill--warning">Warning</span>
|
|
284
|
+
<span class="pill pill--danger">Danger</span>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<h2>Focus ring</h2>
|
|
288
|
+
<p class="muted">Tab to the button below to see the focus outline.</p>
|
|
289
|
+
<p><button class="button" type="button">Focusable</button></p>
|
|
290
|
+
|
|
291
|
+
<script>
|
|
292
|
+
(function () {
|
|
293
|
+
var KEY = 'sc.preview.mode';
|
|
294
|
+
var stored = null;
|
|
295
|
+
try { stored = localStorage.getItem(KEY); } catch (e) {}
|
|
296
|
+
var html = document.documentElement;
|
|
297
|
+
var buttons = document.querySelectorAll('[data-pv-mode]');
|
|
298
|
+
|
|
299
|
+
function apply(mode) {
|
|
300
|
+
if (mode === 'dark') html.classList.add('dark');
|
|
301
|
+
else html.classList.remove('dark');
|
|
302
|
+
buttons.forEach(function (b) {
|
|
303
|
+
var active = b.getAttribute('data-pv-mode') === mode;
|
|
304
|
+
b.classList.toggle('pv-toggle--active', active);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Initial state: stored preference > URL-given previewMode > light
|
|
309
|
+
var initial = stored || ${JSON.stringify(previewMode === "dark" ? "dark" : "light")};
|
|
310
|
+
apply(initial);
|
|
311
|
+
|
|
312
|
+
buttons.forEach(function (btn) {
|
|
313
|
+
btn.addEventListener('click', function () {
|
|
314
|
+
var mode = btn.getAttribute('data-pv-mode');
|
|
315
|
+
try { localStorage.setItem(KEY, mode); } catch (e) {}
|
|
316
|
+
apply(mode);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
})();
|
|
320
|
+
</script>
|
|
321
|
+
</body>
|
|
322
|
+
</html>`;
|
|
41
323
|
}
|
|
42
324
|
|
|
43
325
|
export function escapeHtml(s) {
|