@rmdes/indiekit-endpoint-site-config 1.0.0-beta.2 → 1.0.0-beta.4

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/index.js CHANGED
@@ -7,7 +7,6 @@ import { brandingRouter } from "./lib/controllers/branding.js";
7
7
  import { homepageRouter } from "./lib/controllers/homepage.js";
8
8
  import { blogRouter } from "./lib/controllers/blog.js";
9
9
  import { navigationRouter } from "./lib/controllers/navigation.js";
10
- import { featuresRouter } from "./lib/controllers/features.js";
11
10
  import { apiRouter } from "./lib/controllers/api.js";
12
11
 
13
12
  import { getSiteConfig } from "./lib/storage/get-site-config.js";
@@ -78,7 +77,6 @@ export default class SiteConfigEndpoint {
78
77
  protectedRouter.use("/homepage", homepageRouter(Indiekit));
79
78
  protectedRouter.use("/blog", blogRouter(Indiekit));
80
79
  protectedRouter.use("/navigation", navigationRouter(Indiekit));
81
- protectedRouter.use("/features", featuresRouter(Indiekit));
82
80
 
83
81
  this.routes = protectedRouter;
84
82
 
@@ -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);
@@ -52,12 +52,15 @@ export const ACCENT_SUGGESTIONS = Object.freeze([
52
52
  *
53
53
  * @type {ReadonlyArray<{slug: string, label: string}>}
54
54
  */
55
+ // NOTE: `slug` values are stable identifiers persisted in MongoDB and MUST NOT
56
+ // change (renaming would orphan saved configs). The `neutral-zinc` and
57
+ // `warm-gray` slugs now carry the Sage / Clay palettes — see surface-presets.js.
55
58
  export const SURFACE_PRESET_OPTIONS = Object.freeze([
56
59
  Object.freeze({ slug: "warm-stone", label: "Warm Stone" }),
57
- Object.freeze({ slug: "warm-gray", label: "Warm Gray" }),
60
+ Object.freeze({ slug: "warm-gray", label: "Clay" }),
58
61
  Object.freeze({ slug: "stone", label: "Stone (Neutral)" }),
59
62
  Object.freeze({ slug: "cool-slate", label: "Cool Slate" }),
60
- Object.freeze({ slug: "neutral-zinc", label: "Neutral Zinc" }),
63
+ Object.freeze({ slug: "neutral-zinc", label: "Sage" }),
61
64
  ]);
62
65
 
63
66
  /**
@@ -454,7 +457,14 @@ export function brandingRouter(Indiekit) {
454
457
  activeTab: "branding",
455
458
  curatedFonts: CURATED_FONTS,
456
459
  roleDefaults,
457
- surfacePresets: SURFACE_PRESET_OPTIONS,
460
+ // Attach each preset's tone ramp (50→950) so the view can render a
461
+ // swatch strip — letting operators SEE each palette's identity before
462
+ // selecting it (the light-end tones look similar; the ramp shows the
463
+ // full character of each preset).
464
+ surfacePresets: SURFACE_PRESET_OPTIONS.map((opt) => ({
465
+ ...opt,
466
+ ramp: Object.values(SURFACE_PRESETS[opt.slug] || {}),
467
+ })),
458
468
  accentSuggestions: ACCENT_SUGGESTIONS,
459
469
  contrastResults,
460
470
  contrastFailures,
@@ -1,8 +1,23 @@
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
+ // Design note (2026-06-06): the original five were warm-stone, cool-slate,
5
+ // stone, neutral-zinc and warm-gray. Three of those (stone, neutral-zinc,
6
+ // warm-gray) were near-identical Tailwind neutrals — stone and neutral-zinc
7
+ // even shared an identical tone-50 (#fafafa) — so switching between them
8
+ // produced no visible change in light mode (where the page background is
9
+ // tone-50). To keep persisted configs valid, the SLUGS are unchanged, but the
10
+ // `neutral-zinc` and `warm-gray` ramps were re-skinned into distinct hue
11
+ // families (Sage / Clay). Labels live in lib/controllers/branding.js.
12
+ //
13
+ // warm-stone — warm taupe/brown (original rmendes.net palette)
14
+ // cool-slate — blue-gray (Tailwind "slate")
15
+ // stone — true neutral gray (Tailwind "neutral")
16
+ // neutral-zinc — Sage: green-tinted neutral
17
+ // warm-gray — Clay: terracotta/rose-tinted warm
18
+ //
19
+ // `cool-slate` and `stone` ramps are from Tailwind CSS (MIT License,
20
+ // Copyright (c) Tailwind Labs, Inc. — https://tailwindcss.com/docs/colors).
6
21
  export const SURFACE_PRESETS = Object.freeze({
7
22
  "warm-stone": Object.freeze({
8
23
  50: "#faf8f5", 100: "#f4f2ee", 200: "#e8e5df",
@@ -16,18 +31,19 @@ export const SURFACE_PRESETS = Object.freeze({
16
31
  600: "#475569", 700: "#334155", 800: "#1e293b",
17
32
  900: "#0f172a", 950: "#020617",
18
33
  }),
34
+ // Sage — green-tinted neutral. Slug kept as "neutral-zinc" for persistence.
19
35
  "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",
36
+ 50: "#f3f7f1", 100: "#e6efe3", 200: "#cfe0ca",
37
+ 300: "#aecaa6", 400: "#82a878", 500: "#5e8a5a",
38
+ 600: "#496e47", 700: "#3a5638", 800: "#283a27",
39
+ 900: "#1a271a", 950: "#0e150e",
24
40
  }),
41
+ // Clay — terracotta/rose-tinted warm. Slug kept as "warm-gray".
25
42
  "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",
43
+ 50: "#faf4f1", 100: "#f4e7e1", 200: "#e8d2c7",
44
+ 300: "#d8b3a2", 400: "#c08b73", 500: "#a8705a",
45
+ 600: "#8a5642", 700: "#6d4334", 800: "#492d23",
46
+ 900: "#2f1d16", 950: "#1a0f0b",
31
47
  }),
32
48
  "stone": Object.freeze({
33
49
  // Tailwind "neutral" — the truest gray with no warm or cool bias.
@@ -47,7 +47,6 @@ export function renderSiteJson(config) {
47
47
  identity: config.identity,
48
48
  branding: config.branding,
49
49
  navigation: config.navigation,
50
- features: config.features,
51
50
  updatedAt: config.updatedAt,
52
51
  };
53
52
  const replacer = (key, value) => (PRIVATE_KEYS.has(key) ? undefined : value);
@@ -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
@@ -57,7 +57,7 @@ export async function maybeBackfillIdentity(Indiekit) {
57
57
  if (!homepageDoc || !hasRichIdentity(homepageDoc.identity)) return false;
58
58
 
59
59
  // Copy the rich identity over. saveSiteConfig handles the deepMerge + replaceOne
60
- // so we preserve any other siteConfig keys (branding, navigation, features).
60
+ // so we preserve any other siteConfig keys (branding, navigation).
61
61
  await saveSiteConfig(Indiekit, { identity: homepageDoc.identity }, "backfill-from-homepage");
62
62
 
63
63
  console.log("[site-config] backfilled identity from homepageConfig (one-time migration)");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Default values for siteConfig collection (identity, branding, navigation, features).
2
+ * Default values for siteConfig collection (identity, branding, navigation).
3
3
  * Singleton _id: "primary".
4
4
  *
5
5
  * Schema v3 (unification):
@@ -7,7 +7,8 @@
7
7
  * - layout subtree removed entirely (replaced by homepageConfig + navigation)
8
8
  * - navigation.items[] added (was siteConfig.layout.navItems[])
9
9
  * - branding subtree unchanged from v2 (Path D)
10
- * - features subtree unchanged from v2
10
+ * - features subtree DROPPED in v3.x (was unused; per-plugin config lives in
11
+ * each plugin's own admin UI, plugin loadout lives in plugins.yaml).
11
12
  *
12
13
  * Frozen for immutability — never mutate this object directly.
13
14
  * @module storage/defaults-site
@@ -77,13 +78,6 @@ export const DEFAULTS_SITE = Object.freeze({
77
78
  navigation: Object.freeze({
78
79
  items: Object.freeze([]),
79
80
  }),
80
- features: Object.freeze({
81
- webmentions: true,
82
- syndication: true,
83
- activitypub: false,
84
- search: true,
85
- rss: true,
86
- }),
87
81
  });
88
82
 
89
83
  // Legacy export name for any code path that hasn't been updated yet.
package/locales/en.json CHANGED
@@ -7,8 +7,7 @@
7
7
  "branding": "Branding",
8
8
  "homepage": "Homepage",
9
9
  "blog": "Blog",
10
- "navigation": "Navigation",
11
- "features": "Feature Flags"
10
+ "navigation": "Navigation"
12
11
  },
13
12
  "common": {
14
13
  "save": "Save",
@@ -79,7 +78,7 @@
79
78
  "advancedDesc": "Most sites should leave these alone — the palette already derives them. Override only if you have a strong reason."
80
79
  },
81
80
  "surfacePreset": "Surface preset",
82
- "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.",
83
82
  "surfacePresetCustom": "Custom (advanced)",
84
83
  "surfaceCustom": "Custom surface tones",
85
84
  "surfaceCustomHint": "11 tones from lightest (50) to darkest (950). Applied only when the surface preset above is set to Custom.",
@@ -218,10 +217,6 @@
218
217
  "sidebarEnabled": "Sidebar enabled",
219
218
  "sidebarSide": "Sidebar side",
220
219
  "navItems": "Navigation items"
221
- },
222
- "features": {
223
- "title": "Feature Flags",
224
- "empty": "No plugins declare a feature flag yet."
225
220
  }
226
221
  }
227
222
  }
package/locales/fr.json CHANGED
@@ -7,8 +7,7 @@
7
7
  "branding": "Identité visuelle",
8
8
  "homepage": "Accueil",
9
9
  "blog": "Blog",
10
- "navigation": "Navigation",
11
- "features": "Fonctionnalités"
10
+ "navigation": "Navigation"
12
11
  },
13
12
  "common": {
14
13
  "save": "Enregistrer",
@@ -79,7 +78,7 @@
79
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."
80
79
  },
81
80
  "surfacePreset": "Palette de fond",
82
- "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.",
83
82
  "surfacePresetCustom": "Personnalisé (avancé)",
84
83
  "surfaceCustom": "Tons personnalisés",
85
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é.",
@@ -218,10 +217,6 @@
218
217
  "sidebarEnabled": "Barre latérale activée",
219
218
  "sidebarSide": "Côté de la barre latérale",
220
219
  "navItems": "Éléments de navigation"
221
- },
222
- "features": {
223
- "title": "Fonctionnalités",
224
- "empty": "Aucun plugin ne déclare encore de drapeau de fonctionnalité."
225
220
  }
226
221
  }
227
222
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-site-config",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "type": "module",
5
- "description": "Site identity, branding, layout, and feature flag configuration for Indiekit",
5
+ "description": "Site identity, branding, and navigation configuration for Indiekit",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js"
@@ -1,5 +1,5 @@
1
1
  {# Tab navigation for site-config admin - server-rendered URL tabs #}
2
- {# Expects: activeTab (string, one of identity|branding|homepage|blog|navigation|features) #}
2
+ {# Expects: activeTab (string, one of identity|branding|homepage|blog|navigation) #}
3
3
  <style>
4
4
  .sc-tab-nav {
5
5
  display: flex;
@@ -31,8 +31,7 @@
31
31
  {key: 'branding', href: '/site-config/branding'},
32
32
  {key: 'homepage', href: '/site-config/homepage'},
33
33
  {key: 'blog', href: '/site-config/blog'},
34
- {key: 'navigation', href: '/site-config/navigation'},
35
- {key: 'features', href: '/site-config/features'}
34
+ {key: 'navigation', href: '/site-config/navigation'}
36
35
  ] %}
37
36
  {% for tab in tabs %}
38
37
  <a href="{{ tab.href }}"
@@ -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">
@@ -1,52 +0,0 @@
1
- import express from "express";
2
- import { getSiteConfig } from "../storage/get-site-config.js";
3
- import { saveSiteConfig } from "../storage/save-site-config.js";
4
- import { writeSiteJson } from "../render/write-site-json.js";
5
-
6
- /**
7
- * Discover feature flags from loaded plugins by reading `plugin.featureFlag`.
8
- * Plugins without the capability are skipped.
9
- */
10
- export function discoverFlags(Indiekit) {
11
- const plugins = Indiekit.config?.plugins || [];
12
- return plugins
13
- .map((p) => p.featureFlag)
14
- .filter(Boolean)
15
- .sort((a, b) => (a.category || "").localeCompare(b.category || ""));
16
- }
17
-
18
- export function featuresRouter(Indiekit) {
19
- const router = express.Router();
20
-
21
- router.get("/", async (req, res, next) => {
22
- try {
23
- const config = await getSiteConfig(Indiekit);
24
- const flags = discoverFlags(Indiekit);
25
- res.render("site-config-features", {
26
- config,
27
- activeTab: "features",
28
- flags,
29
- });
30
- } catch (error) {
31
- next(error);
32
- }
33
- });
34
-
35
- router.post("/", async (req, res, next) => {
36
- try {
37
- const flags = discoverFlags(Indiekit);
38
- const features = {};
39
- for (const flag of flags) {
40
- features[flag.key] = req.body[`feature_${flag.key}`] === "on";
41
- }
42
- const userIdent = Indiekit.config?.publication?.me || "unknown";
43
- const updated = await saveSiteConfig(Indiekit, { features }, userIdent);
44
- await writeSiteJson(updated);
45
- res.redirect("/site-config/features?saved=1");
46
- } catch (error) {
47
- next(error);
48
- }
49
- });
50
-
51
- return router;
52
- }
@@ -1,166 +0,0 @@
1
- {% extends "document.njk" %}
2
-
3
- {% block title %}{{ __('siteConfig.features.title') }}{% endblock %}
4
-
5
- {% block content %}
6
- <style>
7
- .sc-dashboard {
8
- display: flex;
9
- flex-direction: column;
10
- gap: var(--space-xl, 2rem);
11
- }
12
-
13
- .sc-section {
14
- background: var(--color-offset, #f5f5f5);
15
- border-radius: var(--border-radius-small, 0.5rem);
16
- padding: var(--space-m, 1.5rem);
17
- }
18
-
19
- .sc-section h2 {
20
- font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
21
- margin-block-end: var(--space-s, 0.75rem);
22
- padding-block-end: var(--space-xs, 0.5rem);
23
- border-block-end: 1px solid var(--color-outline-variant, #ddd);
24
- }
25
-
26
- .sc-section__desc {
27
- color: var(--color-on-offset, #666);
28
- font: var(--font-body, 0.875rem/1.5 sans-serif);
29
- margin-block-end: var(--space-s, 0.75rem);
30
- }
31
-
32
- .hp-success {
33
- background: var(--color-success-container, #d4edda);
34
- border: 1px solid var(--color-success, #28a745);
35
- border-radius: var(--border-radius-small, 0.25rem);
36
- padding: var(--space-s, 0.75rem);
37
- margin-block-end: var(--space-m, 1rem);
38
- }
39
-
40
- /* Empty state */
41
- .sc-empty-state {
42
- text-align: center;
43
- padding: var(--space-xl, 2rem) var(--space-m, 1rem);
44
- color: var(--color-on-offset, #888);
45
- font: var(--font-body, 0.875rem/1.5 sans-serif);
46
- background: var(--color-offset, #f5f5f5);
47
- border: 1px dashed var(--color-outline-variant, #ddd);
48
- border-radius: var(--border-radius-small, 0.5rem);
49
- }
50
-
51
- .sc-empty-state p {
52
- margin: 0;
53
- }
54
-
55
- /* Flag rows */
56
- .sc-flag-row {
57
- display: flex;
58
- align-items: flex-start;
59
- gap: var(--space-s, 0.75rem);
60
- padding: var(--space-s, 0.75rem) 0;
61
- border-bottom: 1px solid var(--color-outline-variant, #eee);
62
- }
63
-
64
- .sc-flag-row:last-child {
65
- border-bottom: none;
66
- padding-bottom: 0;
67
- }
68
-
69
- .sc-flag-row:first-child {
70
- padding-top: 0;
71
- }
72
-
73
- .sc-flag-label {
74
- display: flex;
75
- flex-direction: column;
76
- gap: 0.125rem;
77
- flex: 1;
78
- }
79
-
80
- .sc-flag-label strong {
81
- font: var(--font-body, 0.875rem/1.5 sans-serif);
82
- font-weight: 600;
83
- color: var(--color-on-surface, inherit);
84
- }
85
-
86
- .sc-flag-hint {
87
- font: var(--font-caption, 0.75rem/1.4 sans-serif);
88
- color: var(--color-on-offset, #888);
89
- margin: 0;
90
- }
91
-
92
- .sc-restart-badge {
93
- display: inline-flex;
94
- align-items: center;
95
- font: var(--font-caption, 0.7rem/1.4 sans-serif);
96
- font-weight: 600;
97
- color: var(--color-warning, #856404);
98
- background: color-mix(in srgb, var(--color-warning, #856404) 12%, var(--color-background, #fff));
99
- border: 1px solid color-mix(in srgb, var(--color-warning, #856404) 30%, transparent);
100
- border-radius: var(--border-radius-small, 0.25rem);
101
- padding: 0.1rem 0.4rem;
102
- white-space: nowrap;
103
- }
104
- </style>
105
-
106
- <header class="page-header">
107
- <h1 class="page-header__title">{{ __('siteConfig.title') }}</h1>
108
- </header>
109
-
110
- {% include "partials/tab-strip.njk" %}
111
-
112
- {% if request.query.saved %}
113
- <div class="hp-success">
114
- <p>{{ __('siteConfig.common.saved') }}</p>
115
- </div>
116
- {% endif %}
117
-
118
- {% if flags.length == 0 %}
119
-
120
- <div class="sc-empty-state">
121
- <p>{{ __('siteConfig.features.empty') }}</p>
122
- </div>
123
-
124
- {% else %}
125
-
126
- <form method="post" action="/site-config/features" class="sc-dashboard">
127
-
128
- {% set currentCategory = '' %}
129
- {% for flag in flags %}
130
-
131
- {% if flag.category != currentCategory %}
132
- {% set currentCategory = flag.category %}
133
- <section class="sc-section">
134
- <h2>{{ flag.category }}</h2>
135
- {% endif %}
136
-
137
- <div class="sc-flag-row">
138
- <input type="checkbox"
139
- id="feature_{{ flag.key }}"
140
- name="feature_{{ flag.key }}"
141
- {% if config.features[flag.key] %}checked{% elif flag.default %}checked{% endif %}>
142
- <label class="sc-flag-label" for="feature_{{ flag.key }}">
143
- <strong>{{ flag.label }}</strong>
144
- {% if flag.description %}
145
- <p class="sc-flag-hint">{{ flag.description }}</p>
146
- {% endif %}
147
- {% if flag.requiresRestart %}
148
- <span class="sc-restart-badge">Restart required</span>
149
- {% endif %}
150
- </label>
151
- </div>
152
-
153
- {% if loop.last or flags[loop.index].category != flag.category %}
154
- </section>
155
- {% endif %}
156
-
157
- {% endfor %}
158
-
159
- <div class="button-group">
160
- <button type="submit" class="button button--primary">{{ __('siteConfig.common.save') }}</button>
161
- </div>
162
-
163
- </form>
164
-
165
- {% endif %}
166
- {% endblock %}