@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 +0 -2
- package/lib/controllers/api.js +17 -15
- package/lib/controllers/branding.js +13 -3
- package/lib/render/surface-presets.js +29 -13
- package/lib/render/write-site-json.js +0 -1
- package/lib/render/write-theme-css.js +40 -1
- package/lib/storage/backfill-identity.js +1 -1
- package/lib/storage/defaults-site.js +3 -9
- package/locales/en.json +2 -7
- package/locales/fr.json +2 -7
- package/package.json +2 -2
- package/views/partials/tab-strip.njk +2 -3
- package/views/site-config-branding.njk +39 -4
- package/lib/controllers/features.js +0 -52
- package/views/site-config-features.njk +0 -166
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
|
|
package/lib/controllers/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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')
|
|
368
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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: "#
|
|
21
|
-
300: "#
|
|
22
|
-
600: "#
|
|
23
|
-
900: "#
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.0.0-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Site identity, branding,
|
|
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
|
|
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
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
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 %}
|