···138138 descriptionLabel: "Description",
139139 save: "Save changes",
140140 detailsSaved: "Wiki details saved.",
141141+ theme: "Theme",
142142+ themeModeLabel: "Theme behavior",
143143+ themeReader: "Reader's choice",
144144+ themeReaderHint: "Visitors see the wiki in their preferred theme.",
145145+ themeEnforce: "Enforce a theme",
146146+ themeEnforceHint: "All visitors see the wiki in the theme you pick below.",
147147+ themePresetLabel: "Preset",
148148+ themeSaved: "Theme saved.",
141149 dangerZone: "Delete this wiki",
142150 deleteWikiDescription:
143151 "This will permanently delete the wiki and all its notes. This action cannot be undone.",
···166174 noMarkdownFiles: "No markdown files found in zip.",
167175 importFailed: "Import failed: {error}",
168176 requestFailed: "Something went wrong. Please try again.",
177177+ invalidThemeMode: "Invalid theme mode.",
178178+ invalidTheme: "Invalid theme.",
169179 },
170180};
+11
src/lib/i18n/fr.ts
···140140 descriptionLabel: "Description",
141141 save: "Enregistrer les modifications",
142142 detailsSaved: "Détails du wiki enregistrés.",
143143+ theme: "Thème",
144144+ themeModeLabel: "Comportement du thème",
145145+ themeReader: "Choix du lecteur",
146146+ themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.",
147147+ themeEnforce: "Imposer un thème",
148148+ themeEnforceHint:
149149+ "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.",
150150+ themePresetLabel: "Préréglage",
151151+ themeSaved: "Thème enregistré.",
143152 dangerZone: "Supprimer ce wiki",
144153 deleteWikiDescription:
145154 "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.",
···168177 noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.",
169178 importFailed: "Échec de l'importation : {error}",
170179 requestFailed: "Une erreur est survenue. Veuillez réessayer.",
180180+ invalidThemeMode: "Mode de thème invalide.",
181181+ invalidTheme: "Thème invalide.",
171182 },
172183};
···141141 );
142142}
143143144144+export function setWikiTheme(
145145+ slug: string,
146146+ themeMode: "reader" | "enforce",
147147+ theme: string,
148148+): void {
149149+ const db = getDb();
150150+ db.run(
151151+ `UPDATE wikis
152152+ SET theme_mode = ?, theme = ?, updated_at = datetime('now')
153153+ WHERE slug = ?`,
154154+ [themeMode, theme, slug],
155155+ );
156156+}
157157+144158export function deleteWikiByAtUri(atUri: string): void {
145159 const db = getDb();
146160 const wiki = db
+16
src/server/db/schema.ts
···1313 visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
1414 language TEXT NOT NULL DEFAULT 'en',
1515 description TEXT NOT NULL DEFAULT '',
1616+ theme_mode TEXT NOT NULL DEFAULT 'reader' CHECK (theme_mode IN ('reader', 'enforce')),
1717+ theme TEXT NOT NULL DEFAULT 'light',
1618 at_uri TEXT NOT NULL UNIQUE,
1719 created_at TEXT NOT NULL DEFAULT (datetime('now')),
1820 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1921 )
2022 `);
2323+2424+ // Migrations for existing DBs that predate the theme columns.
2525+ const wikiCols = db.query("PRAGMA table_info(wikis)").all() as {
2626+ name: string;
2727+ }[];
2828+ const hasCol = (name: string) => wikiCols.some((c) => c.name === name);
2929+ if (!hasCol("theme_mode")) {
3030+ db.run(
3131+ "ALTER TABLE wikis ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'reader'",
3232+ );
3333+ }
3434+ if (!hasCol("theme")) {
3535+ db.run("ALTER TABLE wikis ADD COLUMN theme TEXT NOT NULL DEFAULT 'light'");
3636+ }
21372238 db.run(`
2339 CREATE TABLE IF NOT EXISTS notes (
···11-import type { UserTheme } from "./resolve.ts";
11+import {
22+ resolveTheme,
33+ type ThemeName,
44+ type UserTheme,
55+ type WikiThemeMode,
66+} from "./resolve.ts";
27import { type Theme, themes } from "./themes.ts";
3849const kebab = (s: string): string =>
···2025 if (userTheme === "dark") return `body { ${themeVars(themes.dark)} }`;
2126 return `body { ${themeVars(themes.light)} } @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`;
2227}
2828+2929+function themeStyleAttr(theme: Theme): string {
3030+ return Object.entries(theme)
3131+ .map(([k, v]) => `--${kebab(k)}: ${v}`)
3232+ .join("; ");
3333+}
3434+3535+/**
3636+ * Wraps wiki-content HTML in a div that overrides the chrome theme when the
3737+ * wiki enforces its own. In reader mode the content inherits via CSS cascade
3838+ * and no wrapper is emitted.
3939+ */
4040+export function wrapWikiContent(
4141+ html: string,
4242+ args: { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName } = {},
4343+): string {
4444+ const theme = resolveTheme({ scope: "wikiContent", ...args });
4545+ if (!theme) return html;
4646+ return `<div style="${themeStyleAttr(theme)}">${html}</div>`;
4747+}
+1-1
src/views/theme/index.ts
···11-export { themeRootStyle } from "./apply.ts";
11+export { themeRootStyle, wrapWikiContent } from "./apply.ts";
22export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts";
33export {
44 dangerButtonClass,
+37
src/views/theme/resolve.ts
···11+import { type Theme, themes } from "./themes.ts";
22+13export const USER_THEMES = ["light", "dark", "system"] as const;
24export type UserTheme = (typeof USER_THEMES)[number];
55+66+/**
77+ * How a wiki applies its theme:
88+ * - "reader": fall through to the visitor's chosen theme (chrome theme)
99+ * - "enforce": override with the wiki's own theme regardless of visitor preference
1010+ */
1111+export type WikiThemeMode = "reader" | "enforce";
1212+1313+/** Named theme keys present in `themes` (light/dark presets). */
1414+export type ThemeName = keyof typeof themes;
1515+1616+type ThemeScope = "chrome" | "wikiContent";
1717+1818+type ResolveThemeArgs = {
1919+ scope: ThemeScope;
2020+ wikiThemeMode?: WikiThemeMode;
2121+ wikiTheme?: ThemeName;
2222+};
2323+2424+/**
2525+ * Returns the palette an element should apply via inline style, or null when
2626+ * the element should inherit from its cascading parent.
2727+ *
2828+ * Chrome scope is always handled by the body <style> block (which natively
2929+ * supports system preference via @media), so resolveTheme returns null for it.
3030+ * Wiki-content scope returns the wiki's theme only when it enforces an
3131+ * override; otherwise it inherits the chrome theme via cascade.
3232+ */
3333+export function resolveTheme(args: ResolveThemeArgs): Theme | null {
3434+ if (args.scope === "chrome") return null;
3535+ if (args.wikiThemeMode === "enforce" && args.wikiTheme) {
3636+ return themes[args.wikiTheme];
3737+ }
3838+ return null;
3939+}
340441export function resolveUserTheme(
542 cookieHeader: string | null | undefined,