@rmdes/indiekit-endpoint-site-config 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -79,14 +79,6 @@ export function apiRouter(Indiekit) {
79
79
  ? req.query.previewMode
80
80
  : configForPreview.branding.mode;
81
81
 
82
- // We render theme.css with the pending mode so the preview iframe
83
- // shows colors for the chosen side without needing JS to flip
84
- // .dark on the iframe (which works too — both paths are wired).
85
- const themeConfig = {
86
- ...configForPreview,
87
- branding: { ...configForPreview.branding, mode: previewMode },
88
- };
89
-
90
82
  // Run contrast check on the preview's resolved state so the iframe
91
83
  // can show whether the visible colors are passing. Wrapped in try
92
84
  // so a broken accentBase doesn't blow up the preview.
@@ -96,7 +88,10 @@ export function apiRouter(Indiekit) {
96
88
  contrastResults = [];
97
89
  }
98
90
 
99
- const themeCss = renderThemeCss(themeConfig);
91
+ // Preview always emits both light + dark (class-scoped) so the iframe's
92
+ // Light/Dark toggle works regardless of the saved mode. `previewMode`
93
+ // only selects which side is shown FIRST (via the toggle JS below).
94
+ const themeCss = renderThemeCss(configForPreview, { preview: true });
100
95
  res.setHeader("Content-Type", "text/html");
101
96
  res.setHeader("X-Content-Type-Options", "nosniff");
102
97
  res.setHeader("Cache-Control", "no-store");
@@ -186,10 +181,12 @@ export function apiRouter(Indiekit) {
186
181
  * elements (heading, body text, link, action button, card, focus state,
187
182
  * alert pills) so users see how each token affects real surfaces.
188
183
  *
189
- * The light/dark mode toggle is a vanilla `<button>` with inline JS that
190
- * adds/removes a `.dark` class on the document element AND persists the
191
- * choice in localStorage. The persisted choice is read on load and
192
- * applied to the html element.
184
+ * The light/dark mode toggle is a vanilla `<button>` with inline JS that sets
185
+ * an explicit `.light` OR `.dark` class on the document element (mutually
186
+ * exclusive) AND persists the choice in localStorage. Because the preview
187
+ * theme CSS (rendered with { preview: true }) ships both class-scoped blocks,
188
+ * the toggle always works — independent of the saved mode and the host OS
189
+ * color-scheme preference. The persisted choice is read on load and applied.
193
190
  *
194
191
  * Exported for unit testing.
195
192
  */
@@ -364,8 +361,13 @@ ${themeCss}
364
361
  var buttons = document.querySelectorAll('[data-pv-mode]');
365
362
 
366
363
  function apply(mode) {
367
- if (mode === 'dark') html.classList.add('dark');
368
- else html.classList.remove('dark');
364
+ if (mode === 'dark') {
365
+ html.classList.add('dark');
366
+ html.classList.remove('light');
367
+ } else {
368
+ html.classList.add('light');
369
+ html.classList.remove('dark');
370
+ }
369
371
  buttons.forEach(function (b) {
370
372
  var active = b.getAttribute('data-pv-mode') === mode;
371
373
  b.classList.toggle('pv-toggle--active', active);
@@ -53,11 +53,11 @@ export const ACCENT_SUGGESTIONS = Object.freeze([
53
53
  * @type {ReadonlyArray<{slug: string, label: string}>}
54
54
  */
55
55
  export const SURFACE_PRESET_OPTIONS = Object.freeze([
56
- Object.freeze({ slug: "warm-stone", label: "Warm Stone" }),
57
- Object.freeze({ slug: "warm-gray", label: "Warm Gray" }),
58
- Object.freeze({ slug: "stone", label: "Stone (Neutral)" }),
59
- Object.freeze({ slug: "cool-slate", label: "Cool Slate" }),
60
- Object.freeze({ slug: "neutral-zinc", label: "Neutral Zinc" }),
56
+ Object.freeze({ slug: "warm-stone", label: "Warm Stone" }),
57
+ Object.freeze({ slug: "clay", label: "Clay" }),
58
+ Object.freeze({ slug: "stone", label: "Stone (Neutral)" }),
59
+ Object.freeze({ slug: "cool-slate", label: "Cool Slate" }),
60
+ Object.freeze({ slug: "sage", label: "Sage" }),
61
61
  ]);
62
62
 
63
63
  /**
@@ -454,7 +454,14 @@ export function brandingRouter(Indiekit) {
454
454
  activeTab: "branding",
455
455
  curatedFonts: CURATED_FONTS,
456
456
  roleDefaults,
457
- surfacePresets: SURFACE_PRESET_OPTIONS,
457
+ // Attach each preset's tone ramp (50→950) so the view can render a
458
+ // swatch strip — letting operators SEE each palette's identity before
459
+ // selecting it (the light-end tones look similar; the ramp shows the
460
+ // full character of each preset).
461
+ surfacePresets: SURFACE_PRESET_OPTIONS.map((opt) => ({
462
+ ...opt,
463
+ ramp: Object.values(SURFACE_PRESETS[opt.slug] || {}),
464
+ })),
458
465
  accentSuggestions: ACCENT_SUGGESTIONS,
459
466
  contrastResults,
460
467
  contrastFailures,
@@ -1,8 +1,14 @@
1
- // `cool-slate`, `neutral-zinc`, `warm-gray`, and `stone` palette values are
2
- // derived from Tailwind CSS, MIT License Copyright (c) Tailwind Labs, Inc.
3
- // https://tailwindcss.com/docs/customizing-colors
1
+ // Surface presets — the neutral/tinted base palettes offered in the branding
2
+ // admin. Each is an 11-step tone ramp (50 lightest 950 darkest).
4
3
  //
5
- // `warm-stone` is the original rmendes.net palette.
4
+ // warm-stone warm taupe/brown (original rmendes.net palette)
5
+ // cool-slate — blue-gray (Tailwind "slate")
6
+ // stone — true neutral gray (Tailwind "neutral")
7
+ // sage — green-tinted neutral
8
+ // clay — terracotta/rose-tinted warm
9
+ //
10
+ // `cool-slate` and `stone` ramps are from Tailwind CSS (MIT License,
11
+ // Copyright (c) Tailwind Labs, Inc. — https://tailwindcss.com/docs/colors).
6
12
  export const SURFACE_PRESETS = Object.freeze({
7
13
  "warm-stone": Object.freeze({
8
14
  50: "#faf8f5", 100: "#f4f2ee", 200: "#e8e5df",
@@ -16,18 +22,19 @@ export const SURFACE_PRESETS = Object.freeze({
16
22
  600: "#475569", 700: "#334155", 800: "#1e293b",
17
23
  900: "#0f172a", 950: "#020617",
18
24
  }),
19
- "neutral-zinc": Object.freeze({
20
- 50: "#fafafa", 100: "#f4f4f5", 200: "#e4e4e7",
21
- 300: "#d4d4d8", 400: "#a1a1aa", 500: "#71717a",
22
- 600: "#52525b", 700: "#3f3f46", 800: "#27272a",
23
- 900: "#18181b", 950: "#09090b",
25
+ // Sage — green-tinted neutral.
26
+ "sage": Object.freeze({
27
+ 50: "#f3f7f1", 100: "#e6efe3", 200: "#cfe0ca",
28
+ 300: "#aecaa6", 400: "#82a878", 500: "#5e8a5a",
29
+ 600: "#496e47", 700: "#3a5638", 800: "#283a27",
30
+ 900: "#1a271a", 950: "#0e150e",
24
31
  }),
25
- "warm-gray": Object.freeze({
26
- // Tailwind "stone" rebranded as a warm-leaning gray.
27
- 50: "#fafaf9", 100: "#f5f5f4", 200: "#e7e5e4",
28
- 300: "#d6d3d1", 400: "#a8a29e", 500: "#78716c",
29
- 600: "#57534e", 700: "#44403c", 800: "#292524",
30
- 900: "#1c1917", 950: "#0c0a09",
32
+ // Clay — terracotta/rose-tinted warm.
33
+ "clay": Object.freeze({
34
+ 50: "#faf4f1", 100: "#f4e7e1", 200: "#e8d2c7",
35
+ 300: "#d8b3a2", 400: "#c08b73", 500: "#a8705a",
36
+ 600: "#8a5642", 700: "#6d4334", 800: "#492d23",
37
+ 900: "#2f1d16", 950: "#1a0f0b",
31
38
  }),
32
39
  "stone": Object.freeze({
33
40
  // Tailwind "neutral" — the truest gray with no warm or cool bias.
@@ -209,9 +209,18 @@ export function resolveBothModes(branding) {
209
209
  * Tier 3 contract. Mode handling follows the v2 spec §6.1.
210
210
  *
211
211
  * @param {object} config - Merged site config object (output of mergeWithDefaults)
212
+ * @param {object} [options]
213
+ * @param {boolean} [options.preview=false] - Preview mode. The live-preview
214
+ * iframe is a design tool: its Light/Dark toggle must ALWAYS work regardless
215
+ * of the saved `mode`. So in preview mode we emit BOTH a light and a dark
216
+ * set, scoped to explicit `.light` / `.dark` classes (driven by the toolbar
217
+ * toggle), with `:root` defaulting to light. No `prefers-color-scheme` media
218
+ * query is used — the operator's explicit toggle choice should win over the
219
+ * host OS preference while inspecting a design. The production path (no
220
+ * options) is unchanged and still honors `mode` per spec §6.1.
212
221
  * @returns {string} CSS source ready to be written or served
213
222
  */
214
- export function renderThemeCss(config) {
223
+ export function renderThemeCss(config, options = {}) {
215
224
  const branding = config.branding;
216
225
  const { light, dark, surface, accent } = resolveBothModes(branding);
217
226
  const mode = branding.mode || "auto";
@@ -225,6 +234,36 @@ export function renderThemeCss(config) {
225
234
  typographyBlock(branding.typography),
226
235
  ].join("\n");
227
236
 
237
+ // Preview mode: emit both light and dark as explicit, class-scoped blocks so
238
+ // the iframe's Light/Dark toggle is always functional and OS-independent.
239
+ if (options.preview === true) {
240
+ return [
241
+ ":root {",
242
+ shared,
243
+ " /* Tier 3 — alerts (light) */",
244
+ tier3Block("light"),
245
+ " /* Tier 2 — semantic roles (light) */",
246
+ tier2Block(light),
247
+ "}",
248
+ "",
249
+ // Explicit light class so the toggle can force light over any default.
250
+ ".light {",
251
+ " /* Tier 3 — alerts (light) */",
252
+ tier3Block("light"),
253
+ " /* Tier 2 — semantic roles (light) */",
254
+ tier2Block(light),
255
+ "}",
256
+ "",
257
+ ".dark {",
258
+ " /* Tier 3 — alerts (dark) */",
259
+ tier3Block("dark"),
260
+ " /* Tier 2 — semantic roles (dark) */",
261
+ tier2Block(dark),
262
+ "}",
263
+ "",
264
+ ].join("\n");
265
+ }
266
+
228
267
  // Tier 3 alerts vary by mode (different RGB values for light vs dark).
229
268
  // For "light" / "dark" modes we emit only the matching set in :root.
230
269
  // For "auto" we put light alerts in :root and dark alerts in both
package/locales/en.json CHANGED
@@ -78,7 +78,7 @@
78
78
  "advancedDesc": "Most sites should leave these alone — the palette already derives them. Override only if you have a strong reason."
79
79
  },
80
80
  "surfacePreset": "Surface preset",
81
- "surfacePresetHint": "Pick a neutral base palette. Warm Stone is cream/brown. Cool Slate is blue-gray. Neutral Zinc is a pure gray. Custom lets you define all 11 tones manually.",
81
+ "surfacePresetHint": "Pick a base palette. Warm Stone is cream/brown, Clay is terracotta/rose, Stone is a pure gray, Cool Slate is blue-gray, Sage is green-tinted. Custom lets you define all 11 tones manually.",
82
82
  "surfacePresetCustom": "Custom (advanced)",
83
83
  "surfaceCustom": "Custom surface tones",
84
84
  "surfaceCustomHint": "11 tones from lightest (50) to darkest (950). Applied only when the surface preset above is set to Custom.",
package/locales/fr.json CHANGED
@@ -78,7 +78,7 @@
78
78
  "advancedDesc": "La plupart des sites devraient laisser ces options telles quelles — la palette les dérive déjà. Ne les remplacez qu'avec une bonne raison."
79
79
  },
80
80
  "surfacePreset": "Palette de fond",
81
- "surfacePresetHint": "Choisissez une palette neutre de base. Warm Stone est crème/brun. Cool Slate est bleu-gris. Neutral Zinc est un gris pur. Custom vous permet de définir manuellement les 11 tons.",
81
+ "surfacePresetHint": "Choisissez une palette de base. Warm Stone est crème/brun, Clay est terracotta/rosé, Stone est un gris pur, Cool Slate est bleu-gris, Sage est teinté vert. Custom vous permet de définir manuellement les 11 tons.",
82
82
  "surfacePresetCustom": "Personnalisé (avancé)",
83
83
  "surfaceCustom": "Tons personnalisés",
84
84
  "surfaceCustomHint": "11 tons du plus clair (50) au plus sombre (950). Appliqué uniquement lorsque la palette de fond ci-dessus est définie sur Personnalisé.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-site-config",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "type": "module",
5
5
  "description": "Site identity, branding, and navigation configuration for Indiekit",
6
6
  "main": "index.js",
@@ -122,6 +122,32 @@
122
122
  background: color-mix(in srgb, var(--color-primary, #0066cc) 8%, var(--color-background, #fff));
123
123
  }
124
124
 
125
+ /* Surface preset options with a tone-ramp preview */
126
+ .sc-preset-option {
127
+ flex-direction: column;
128
+ align-items: stretch;
129
+ gap: var(--space-2xs, 0.375rem);
130
+ min-width: 9rem;
131
+ }
132
+
133
+ .sc-preset-option__head {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: var(--space-2xs, 0.375rem);
137
+ }
138
+
139
+ .sc-preset-ramp {
140
+ display: flex;
141
+ height: 0.85rem;
142
+ border-radius: 0.2rem;
143
+ overflow: hidden;
144
+ border: 1px solid var(--color-outline-variant, #ddd);
145
+ }
146
+
147
+ .sc-preset-chip {
148
+ flex: 1 1 0;
149
+ }
150
+
125
151
  /* Generic color picker (palette inputs) */
126
152
  .sc-color-picker {
127
153
  display: flex;
@@ -495,10 +521,19 @@
495
521
  <p class="field__hint">{{ __("siteConfig.branding.surfacePresetHint") }}</p>
496
522
  <div class="sc-radio-group">
497
523
  {% for preset in surfacePresets %}
498
- <label class="sc-radio-label">
499
- <input type="radio" name="surfacePreset" value="{{ preset.slug }}"
500
- {% if config.branding.surfacePreset == preset.slug %}checked{% endif %}>
501
- {{ preset.label }}
524
+ <label class="sc-radio-label sc-preset-option">
525
+ <span class="sc-preset-option__head">
526
+ <input type="radio" name="surfacePreset" value="{{ preset.slug }}"
527
+ {% if config.branding.surfacePreset == preset.slug %}checked{% endif %}>
528
+ {{ preset.label }}
529
+ </span>
530
+ {% if preset.ramp %}
531
+ <span class="sc-preset-ramp" aria-hidden="true">
532
+ {% for tone in preset.ramp %}
533
+ <span class="sc-preset-chip" style="background-color: {{ tone }};"></span>
534
+ {% endfor %}
535
+ </span>
536
+ {% endif %}
502
537
  </label>
503
538
  {% endfor %}
504
539
  <label class="sc-radio-label">