🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add theme settings

juprodh af34b2a9 e53e3937

+381 -19
+10
src/lib/i18n/en.ts
··· 138 138 descriptionLabel: "Description", 139 139 save: "Save changes", 140 140 detailsSaved: "Wiki details saved.", 141 + theme: "Theme", 142 + themeModeLabel: "Theme behavior", 143 + themeReader: "Reader's choice", 144 + themeReaderHint: "Visitors see the wiki in their preferred theme.", 145 + themeEnforce: "Enforce a theme", 146 + themeEnforceHint: "All visitors see the wiki in the theme you pick below.", 147 + themePresetLabel: "Preset", 148 + themeSaved: "Theme saved.", 141 149 dangerZone: "Delete this wiki", 142 150 deleteWikiDescription: 143 151 "This will permanently delete the wiki and all its notes. This action cannot be undone.", ··· 166 174 noMarkdownFiles: "No markdown files found in zip.", 167 175 importFailed: "Import failed: {error}", 168 176 requestFailed: "Something went wrong. Please try again.", 177 + invalidThemeMode: "Invalid theme mode.", 178 + invalidTheme: "Invalid theme.", 169 179 }, 170 180 };
+11
src/lib/i18n/fr.ts
··· 140 140 descriptionLabel: "Description", 141 141 save: "Enregistrer les modifications", 142 142 detailsSaved: "Détails du wiki enregistrés.", 143 + theme: "Thème", 144 + themeModeLabel: "Comportement du thème", 145 + themeReader: "Choix du lecteur", 146 + themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.", 147 + themeEnforce: "Imposer un thème", 148 + themeEnforceHint: 149 + "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.", 150 + themePresetLabel: "Préréglage", 151 + themeSaved: "Thème enregistré.", 143 152 dangerZone: "Supprimer ce wiki", 144 153 deleteWikiDescription: 145 154 "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.", ··· 168 177 noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", 169 178 importFailed: "Échec de l'importation : {error}", 170 179 requestFailed: "Une erreur est survenue. Veuillez réessayer.", 180 + invalidThemeMode: "Mode de thème invalide.", 181 + invalidTheme: "Thème invalide.", 171 182 }, 172 183 };
+10
src/lib/i18n/index.ts
··· 138 138 descriptionLabel: string; 139 139 save: string; 140 140 detailsSaved: string; 141 + theme: string; 142 + themeModeLabel: string; 143 + themeReader: string; 144 + themeReaderHint: string; 145 + themeEnforce: string; 146 + themeEnforceHint: string; 147 + themePresetLabel: string; 148 + themeSaved: string; 141 149 dangerZone: string; 142 150 deleteWikiDescription: string; 143 151 deleteWikiOwnerOnly: string; ··· 164 172 noMarkdownFiles: string; 165 173 importFailed: string; 166 174 requestFailed: string; 175 + invalidThemeMode: string; 176 + invalidTheme: string; 167 177 }; 168 178 } 169 179
+23
src/lib/orchestrators/wiki.ts
··· 12 12 deleteWikiByAtUri, 13 13 getWiki, 14 14 listMembers, 15 + setWikiTheme, 15 16 upsertMembership, 16 17 upsertWiki, 17 18 } from "../../server/db/queries/index.ts"; 19 + import { themes } from "../../views/theme/themes.ts"; 18 20 import type { RequestContext, WikiRequestContext } from "../access.ts"; 19 21 import { parseAtUri } from "../at-uri.ts"; 20 22 import { COLLECTIONS } from "../constants.ts"; ··· 250 252 ctx.wiki.language, 251 253 description, 252 254 ); 255 + } 256 + 257 + /** 258 + * Update a wiki's theme settings. Admin only (route-gated). 259 + * DB-only for now; PDS persistence lands in commit 7. 260 + */ 261 + export function setWikiThemeAction( 262 + ctx: WikiRequestContext, 263 + fields: { themeMode: string; theme: string }, 264 + msg: Messages, 265 + ): void { 266 + if (!ctx.did) throw new ForbiddenError(); 267 + 268 + if (fields.themeMode !== "reader" && fields.themeMode !== "enforce") { 269 + throw new ValidationError(msg.error.invalidThemeMode); 270 + } 271 + if (!(fields.theme in themes)) { 272 + throw new ValidationError(msg.error.invalidTheme); 273 + } 274 + 275 + setWikiTheme(ctx.wiki.slug, fields.themeMode, fields.theme); 253 276 } 254 277 255 278 /**
+1
src/server/db/queries/index.ts
··· 53 53 listCollaboratingWikis, 54 54 listOwnedWikis, 55 55 listPublicWikisPaginated, 56 + setWikiTheme, 56 57 upsertWiki, 57 58 } from "./wiki.ts";
+14
src/server/db/queries/wiki.ts
··· 141 141 ); 142 142 } 143 143 144 + export function setWikiTheme( 145 + slug: string, 146 + themeMode: "reader" | "enforce", 147 + theme: string, 148 + ): void { 149 + const db = getDb(); 150 + db.run( 151 + `UPDATE wikis 152 + SET theme_mode = ?, theme = ?, updated_at = datetime('now') 153 + WHERE slug = ?`, 154 + [themeMode, theme, slug], 155 + ); 156 + } 157 + 144 158 export function deleteWikiByAtUri(atUri: string): void { 145 159 const db = getDb(); 146 160 const wiki = db
+16
src/server/db/schema.ts
··· 13 13 visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), 14 14 language TEXT NOT NULL DEFAULT 'en', 15 15 description TEXT NOT NULL DEFAULT '', 16 + theme_mode TEXT NOT NULL DEFAULT 'reader' CHECK (theme_mode IN ('reader', 'enforce')), 17 + theme TEXT NOT NULL DEFAULT 'light', 16 18 at_uri TEXT NOT NULL UNIQUE, 17 19 created_at TEXT NOT NULL DEFAULT (datetime('now')), 18 20 updated_at TEXT NOT NULL DEFAULT (datetime('now')) 19 21 ) 20 22 `); 23 + 24 + // Migrations for existing DBs that predate the theme columns. 25 + const wikiCols = db.query("PRAGMA table_info(wikis)").all() as { 26 + name: string; 27 + }[]; 28 + const hasCol = (name: string) => wikiCols.some((c) => c.name === name); 29 + if (!hasCol("theme_mode")) { 30 + db.run( 31 + "ALTER TABLE wikis ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'reader'", 32 + ); 33 + } 34 + if (!hasCol("theme")) { 35 + db.run("ALTER TABLE wikis ADD COLUMN theme TEXT NOT NULL DEFAULT 'light'"); 36 + } 21 37 22 38 db.run(` 23 39 CREATE TABLE IF NOT EXISTS notes (
+2
src/server/db/types.ts
··· 5 5 visibility: string; 6 6 language: string; 7 7 description: string; 8 + theme_mode: string; 9 + theme: string; 8 10 at_uri: string; 9 11 created_at: string; 10 12 updated_at: string;
+2
src/server/routes/note.ts
··· 167 167 shareHtml, 168 168 ogTitle: `${data.note.title} - ${ctx.wiki.name}`, 169 169 ogUrl: canonicalUrl, 170 + wikiThemeMode: ctx.wiki.theme_mode, 171 + wikiTheme: ctx.wiki.theme, 170 172 }, 171 173 ctx.wiki.language, 172 174 ),
+54
src/server/routes/wiki.ts
··· 20 20 createWikiAction, 21 21 deleteWikiAction, 22 22 editWikiAction, 23 + setWikiThemeAction, 23 24 } from "../../lib/orchestrators/wiki.ts"; 24 25 import { resolveProfiles } from "../../lib/profile.ts"; 25 26 import { htmlResponse } from "../../lib/response.ts"; ··· 131 132 accessLevel: ctx.access, 132 133 wikiDid: ctx.wiki.did, 133 134 wikiDescription: ctx.wiki.description, 135 + wikiThemeMode: ctx.wiki.theme_mode, 136 + wikiTheme: ctx.wiki.theme, 134 137 detailsSaved: query["saved"] === "1", 138 + themeSaved: query["themeSaved"] === "1", 135 139 }, 136 140 ), 137 141 ); ··· 167 171 accessLevel: ctx.access, 168 172 wikiDid: ctx.wiki.did, 169 173 wikiDescription: description, 174 + wikiThemeMode: ctx.wiki.theme_mode, 175 + wikiTheme: ctx.wiki.theme, 176 + error: err.message, 177 + }, 178 + ), 179 + 400, 180 + ); 181 + } 182 + throw err; 183 + } 184 + }) 185 + .post("/:wikiSlug/-/theme", async ({ params, request }) => { 186 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 187 + const msg = t(ctx.locale); 188 + const formData = await request.formData(); 189 + const themeMode = (formData.get("theme_mode") as string | null) ?? "reader"; 190 + const theme = (formData.get("theme") as string | null) ?? "light"; 191 + 192 + try { 193 + setWikiThemeAction(ctx, { themeMode, theme }, msg); 194 + return redirect(`/wiki/${params.wikiSlug}/-/settings?themeSaved=1`); 195 + } catch (err) { 196 + if (err instanceof ValidationError) { 197 + const isOwner = ctx.did === ctx.wiki.did; 198 + const { members, requests, profiles } = await loadSettingsData( 199 + params.wikiSlug, 200 + ); 201 + return htmlResponse( 202 + settingsPage( 203 + ctx.wiki.name, 204 + params.wikiSlug, 205 + isOwner, 206 + members, 207 + requests, 208 + profiles, 209 + { 210 + session: ctx.session, 211 + locale: ctx.locale, 212 + userTheme: ctx.userTheme, 213 + accessLevel: ctx.access, 214 + wikiDid: ctx.wiki.did, 215 + wikiDescription: ctx.wiki.description, 216 + wikiThemeMode: ctx.wiki.theme_mode, 217 + wikiTheme: ctx.wiki.theme, 170 218 error: err.message, 171 219 }, 172 220 ), ··· 200 248 accessLevel: ctx.access, 201 249 wikiDid: ctx.wiki.did, 202 250 wikiDescription: ctx.wiki.description, 251 + wikiThemeMode: ctx.wiki.theme_mode, 252 + wikiTheme: ctx.wiki.theme, 203 253 error: "Wiki name does not match.", 204 254 }, 205 255 ), ··· 273 323 shareHtml, 274 324 ogTitle: `${homeData.note.title} - ${ctx.wiki.name}`, 275 325 ogUrl: canonicalUrl, 326 + wikiThemeMode: ctx.wiki.theme_mode, 327 + wikiTheme: ctx.wiki.theme, 276 328 }, 277 329 ctx.wiki.language, 278 330 ), ··· 291 343 userTheme: ctx.userTheme, 292 344 accessLevel: ctx.access, 293 345 bookmarkHtml: bmHtml, 346 + wikiThemeMode: ctx.wiki.theme_mode, 347 + wikiTheme: ctx.wiki.theme, 294 348 }, 295 349 ctx.wiki.language, 296 350 ),
+13 -8
src/views/note.ts
··· 1 1 import { type LayoutOptions, layout } from "./layout.ts"; 2 + import { wrapWikiContent } from "./theme/index.ts"; 2 3 3 4 export function notePage( 4 5 wikiName: string, 5 6 wikiSlug: string, 6 7 noteTitle: string, 7 8 renderedHtml: string, 8 - options?: LayoutOptions, 9 + options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, 9 10 wikiLanguage?: string, 10 11 ): string { 11 12 const langAttr = wikiLanguage ? ` lang="${wikiLanguage}"` : ""; 12 - return layout( 13 - noteTitle, 13 + const content = wrapWikiContent( 14 14 ` 15 15 <article class="prose max-w-none"${langAttr}> 16 16 ${renderedHtml} 17 17 </article> 18 18 `, 19 19 { 20 - ...options, 21 - wikiName, 22 - wikiSlug, 23 - pageTitle: noteTitle, 24 - enableSearchShortcut: true, 20 + wikiThemeMode: 21 + options?.wikiThemeMode === "enforce" ? "enforce" : "reader", 22 + wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", 25 23 }, 26 24 ); 25 + return layout(noteTitle, content, { 26 + ...options, 27 + wikiName, 28 + wikiSlug, 29 + pageTitle: noteTitle, 30 + enableSearchShortcut: true, 31 + }); 27 32 }
+73 -1
src/views/settings.ts
··· 12 12 successBanner, 13 13 THEME, 14 14 } from "./theme/index.ts"; 15 + import { themes } from "./theme/themes.ts"; 15 16 16 17 interface SettingsPageOptions extends LayoutOptions { 17 18 wikiDid: string; 18 19 wikiDescription: string; 20 + wikiThemeMode: string; 21 + wikiTheme: string; 19 22 error?: string; 20 23 detailsSaved?: boolean; 24 + themeSaved?: boolean; 21 25 } 22 26 23 27 function renderIdentity(did: string, profile: ProfileInfo | undefined): string { ··· 251 255 </section>`; 252 256 } 253 257 258 + function renderThemeSection( 259 + wikiSlug: string, 260 + wikiThemeMode: string, 261 + wikiTheme: string, 262 + themeSaved: boolean, 263 + locale: string, 264 + ): string { 265 + const msg = t(locale as "en" | "fr"); 266 + const isEnforce = wikiThemeMode === "enforce"; 267 + const themeLabels: Record<string, string> = { 268 + light: msg.nav.themeLight, 269 + dark: msg.nav.themeDark, 270 + }; 271 + const themeOptions = (Object.keys(themes) as (keyof typeof themes)[]) 272 + .map( 273 + (name) => 274 + `<option value="${name}"${name === wikiTheme ? " selected" : ""}>${themeLabels[name] ?? name}</option>`, 275 + ) 276 + .join(""); 277 + 278 + const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; 279 + 280 + return `<section class="mb-10"> 281 + <h2 class="text-lg font-semibold mb-4">${msg.settings.theme}</h2> 282 + ${savedBanner} 283 + <form method="POST" action="/wiki/${wikiSlug}/-/theme" class="flex flex-col gap-4 max-w-xl"> 284 + <fieldset class="flex flex-col gap-2"> 285 + <legend class="text-xs font-medium ${THEME.textMuted} mb-1">${msg.settings.themeModeLabel}</legend> 286 + <label class="flex items-start gap-2 text-sm"> 287 + <input type="radio" name="theme_mode" value="reader" class="mt-1" 288 + ${!isEnforce ? "checked" : ""} 289 + onchange="document.getElementById('theme-preset-select').disabled = true" /> 290 + <span> 291 + <span class="font-medium">${msg.settings.themeReader}</span> 292 + <span class="block ${THEME.textMuted} text-xs">${msg.settings.themeReaderHint}</span> 293 + </span> 294 + </label> 295 + <label class="flex items-start gap-2 text-sm"> 296 + <input type="radio" name="theme_mode" value="enforce" class="mt-1" 297 + ${isEnforce ? "checked" : ""} 298 + onchange="document.getElementById('theme-preset-select').disabled = false" /> 299 + <span> 300 + <span class="font-medium">${msg.settings.themeEnforce}</span> 301 + <span class="block ${THEME.textMuted} text-xs">${msg.settings.themeEnforceHint}</span> 302 + </span> 303 + </label> 304 + </fieldset> 305 + <div class="flex flex-col gap-1"> 306 + <label for="theme-preset-select" class="text-xs font-medium ${THEME.textMuted}">${msg.settings.themePresetLabel}</label> 307 + <select id="theme-preset-select" name="theme" ${isEnforce ? "" : "disabled"} 308 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none focus:border-[var(--accent-focus-input)] max-w-xs disabled:opacity-50"> 309 + ${themeOptions} 310 + </select> 311 + </div> 312 + <div> 313 + <button type="submit" class="${primarySmallButtonClass}">${msg.settings.save}</button> 314 + </div> 315 + </form> 316 + </section>`; 317 + } 318 + 254 319 function renderDetailsSection( 255 320 wikiSlug: string, 256 321 wikiName: string, ··· 302 367 options.detailsSaved ?? false, 303 368 locale, 304 369 ); 370 + const themeHtml = renderThemeSection( 371 + wikiSlug, 372 + options.wikiThemeMode, 373 + options.wikiTheme, 374 + options.themeSaved ?? false, 375 + locale, 376 + ); 305 377 const membersHtml = renderMembersSection( 306 378 wikiSlug, 307 379 members, ··· 317 389 318 390 return layout( 319 391 `${msg.settings.heading} — ${wikiName}`, 320 - `${backLink}<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${detailsHtml}${membersHtml}${dangerHtml}`, 392 + `${backLink}<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${detailsHtml}${themeHtml}${membersHtml}${dangerHtml}`, 321 393 { ...options, wikiName, wikiSlug }, 322 394 ); 323 395 }
+26 -1
src/views/theme/apply.ts
··· 1 - import type { UserTheme } from "./resolve.ts"; 1 + import { 2 + resolveTheme, 3 + type ThemeName, 4 + type UserTheme, 5 + type WikiThemeMode, 6 + } from "./resolve.ts"; 2 7 import { type Theme, themes } from "./themes.ts"; 3 8 4 9 const kebab = (s: string): string => ··· 20 25 if (userTheme === "dark") return `body { ${themeVars(themes.dark)} }`; 21 26 return `body { ${themeVars(themes.light)} } @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`; 22 27 } 28 + 29 + function themeStyleAttr(theme: Theme): string { 30 + return Object.entries(theme) 31 + .map(([k, v]) => `--${kebab(k)}: ${v}`) 32 + .join("; "); 33 + } 34 + 35 + /** 36 + * Wraps wiki-content HTML in a div that overrides the chrome theme when the 37 + * wiki enforces its own. In reader mode the content inherits via CSS cascade 38 + * and no wrapper is emitted. 39 + */ 40 + export function wrapWikiContent( 41 + html: string, 42 + args: { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName } = {}, 43 + ): string { 44 + const theme = resolveTheme({ scope: "wikiContent", ...args }); 45 + if (!theme) return html; 46 + return `<div style="${themeStyleAttr(theme)}">${html}</div>`; 47 + }
+1 -1
src/views/theme/index.ts
··· 1 - export { themeRootStyle } from "./apply.ts"; 1 + export { themeRootStyle, wrapWikiContent } from "./apply.ts"; 2 2 export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts"; 3 3 export { 4 4 dangerButtonClass,
+37
src/views/theme/resolve.ts
··· 1 + import { type Theme, themes } from "./themes.ts"; 2 + 1 3 export const USER_THEMES = ["light", "dark", "system"] as const; 2 4 export type UserTheme = (typeof USER_THEMES)[number]; 5 + 6 + /** 7 + * How a wiki applies its theme: 8 + * - "reader": fall through to the visitor's chosen theme (chrome theme) 9 + * - "enforce": override with the wiki's own theme regardless of visitor preference 10 + */ 11 + export type WikiThemeMode = "reader" | "enforce"; 12 + 13 + /** Named theme keys present in `themes` (light/dark presets). */ 14 + export type ThemeName = keyof typeof themes; 15 + 16 + type ThemeScope = "chrome" | "wikiContent"; 17 + 18 + type ResolveThemeArgs = { 19 + scope: ThemeScope; 20 + wikiThemeMode?: WikiThemeMode; 21 + wikiTheme?: ThemeName; 22 + }; 23 + 24 + /** 25 + * Returns the palette an element should apply via inline style, or null when 26 + * the element should inherit from its cascading parent. 27 + * 28 + * Chrome scope is always handled by the body <style> block (which natively 29 + * supports system preference via @media), so resolveTheme returns null for it. 30 + * Wiki-content scope returns the wiki's theme only when it enforces an 31 + * override; otherwise it inherits the chrome theme via cascade. 32 + */ 33 + export function resolveTheme(args: ResolveThemeArgs): Theme | null { 34 + if (args.scope === "chrome") return null; 35 + if (args.wikiThemeMode === "enforce" && args.wikiTheme) { 36 + return themes[args.wikiTheme]; 37 + } 38 + return null; 39 + } 3 40 4 41 export function resolveUserTheme( 5 42 cookieHeader: string | null | undefined,
+19 -5
src/views/wiki.ts
··· 1 1 import { canEdit } from "../lib/access.ts"; 2 2 import { t } from "../lib/i18n/index.ts"; 3 3 import { type LayoutOptions, layout } from "./layout.ts"; 4 - import { primarySmallButtonClass, THEME } from "./theme/index.ts"; 4 + import { 5 + primarySmallButtonClass, 6 + THEME, 7 + wrapWikiContent, 8 + } from "./theme/index.ts"; 5 9 6 10 export function wikiPage( 7 11 wikiName: string, 8 12 wikiSlug: string, 9 - options?: LayoutOptions, 13 + options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, 10 14 wikiLanguage?: string, 11 15 ): string { 12 16 const locale = options?.locale ?? "en"; ··· 30 34 ? `<a href="/wiki/${wikiSlug}/new" class="${primarySmallButtonClass}">${msg.wiki.newNote}</a>` 31 35 : ""; 32 36 33 - return layout( 34 - wikiName, 37 + const content = wrapWikiContent( 35 38 ` 36 39 <h1 class="text-2xl font-bold mb-2">${wikiName}${langBadge}</h1> 37 40 <p class="${THEME.textMuted} mb-6">/${wikiSlug}</p> ··· 43 46 ${noteList || `<li class="${THEME.textMuted}">${msg.wiki.noNotesYet}</li>`} 44 47 </ul> 45 48 `, 46 - { ...options, wikiName, wikiSlug, enableSearchShortcut: true }, 49 + { 50 + wikiThemeMode: 51 + options?.wikiThemeMode === "enforce" ? "enforce" : "reader", 52 + wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", 53 + }, 47 54 ); 55 + 56 + return layout(wikiName, content, { 57 + ...options, 58 + wikiName, 59 + wikiSlug, 60 + enableSearchShortcut: true, 61 + }); 48 62 }
+2
tests/lib/orchestrators/membership.test.ts
··· 53 53 visibility: "public", 54 54 language: "en", 55 55 description: "", 56 + theme_mode: "reader", 57 + theme: "light", 56 58 at_uri: `at://${OWNER_DID}/wiki.lichen.wiki/${WIKI_SLUG}`, 57 59 created_at: "2026-01-01T00:00:00.000Z", 58 60 updated_at: "2026-01-01T00:00:00.000Z",
+2
tests/lib/orchestrators/note.test.ts
··· 50 50 visibility: "public", 51 51 language: "en", 52 52 description: "", 53 + theme_mode: "reader", 54 + theme: "light", 53 55 at_uri: `at://${WIKI_DID}/wiki.lichen.wiki/${WIKI_SLUG}`, 54 56 created_at: "2026-01-01T00:00:00.000Z", 55 57 updated_at: "2026-01-01T00:00:00.000Z",
+65 -3
tests/lib/orchestrators/wiki.test.ts
··· 42 42 getAgent: mockGetAgent, 43 43 })); 44 44 45 - const { createWikiAction, deleteWikiAction, editWikiAction } = await import( 46 - "../../../src/lib/orchestrators/wiki.ts" 47 - ); 45 + const { 46 + createWikiAction, 47 + deleteWikiAction, 48 + editWikiAction, 49 + setWikiThemeAction, 50 + } = await import("../../../src/lib/orchestrators/wiki.ts"); 48 51 49 52 const { ForbiddenError, PdsWriteError, ValidationError } = await import( 50 53 "../../../src/lib/errors.ts" ··· 57 60 invalidSlug: "Invalid slug: {title}", 58 61 wikiSlugExists: "Slug exists: {slug}", 59 62 wikiNameTooLong: "Too long (max {max})", 63 + invalidThemeMode: "Invalid theme mode", 64 + invalidTheme: "Invalid theme", 60 65 }, 61 66 } as never; 62 67 ··· 322 327 323 328 const idx = createdSlugs.indexOf(wiki.slug); 324 329 if (idx !== -1) createdSlugs.splice(idx, 1); 330 + }); 331 + }); 332 + 333 + describe("setWikiThemeAction", () => { 334 + async function createTestWiki(name: string): Promise<WikiRow> { 335 + const result = await createWikiAction( 336 + makeCtx({ session: null }), 337 + { name, language: "en", visibility: "public", description: "" }, 338 + dummyMsg, 339 + ); 340 + createdSlugs.push(result.wikiSlug); 341 + const db = getDb(); 342 + return db 343 + .query("SELECT * FROM wikis WHERE slug = ?") 344 + .get(result.wikiSlug) as WikiRow; 345 + } 346 + 347 + test("updates theme_mode and theme in DB", async () => { 348 + const wiki = await createTestWiki("Orch Theme Update"); 349 + const ctx = makeWikiCtx(wiki); 350 + 351 + setWikiThemeAction(ctx, { themeMode: "enforce", theme: "dark" }, dummyMsg); 352 + 353 + const db = getDb(); 354 + const row = db 355 + .query("SELECT theme_mode, theme FROM wikis WHERE slug = ?") 356 + .get(wiki.slug) as { theme_mode: string; theme: string }; 357 + expect(row.theme_mode).toBe("enforce"); 358 + expect(row.theme).toBe("dark"); 359 + }); 360 + 361 + test("new wikis default to reader + light", async () => { 362 + const wiki = await createTestWiki("Orch Theme Defaults"); 363 + expect(wiki.theme_mode).toBe("reader"); 364 + expect(wiki.theme).toBe("light"); 365 + }); 366 + 367 + test("throws ValidationError on invalid theme mode", async () => { 368 + const wiki = await createTestWiki("Orch Theme Bad Mode"); 369 + const ctx = makeWikiCtx(wiki); 370 + 371 + expect(() => 372 + setWikiThemeAction(ctx, { themeMode: "bogus", theme: "light" }, dummyMsg), 373 + ).toThrow(ValidationError); 374 + }); 375 + 376 + test("throws ValidationError on invalid theme name", async () => { 377 + const wiki = await createTestWiki("Orch Theme Bad Name"); 378 + const ctx = makeWikiCtx(wiki); 379 + 380 + expect(() => 381 + setWikiThemeAction( 382 + ctx, 383 + { themeMode: "enforce", theme: "neon" }, 384 + dummyMsg, 385 + ), 386 + ).toThrow(ValidationError); 325 387 }); 326 388 }); 327 389