diff --git a/lexicons/wiki.lichen.wiki.json b/lexicons/wiki.lichen.wiki.json index 77a6d11..4ed23c0 100644 --- a/lexicons/wiki.lichen.wiki.json +++ b/lexicons/wiki.lichen.wiki.json @@ -35,6 +35,43 @@ "type": "string", "maxLength": 300, "description": "Short description of the wiki's purpose or topic." + }, + "theme": { + "type": "object", + "description": "Optional visual theme enforced by the wiki. Mirrors the shape of site.standard.theme.basic so other atproto apps can honor it. When present, visitors see this color scheme regardless of their reader preference; when absent, the reader's preferred theme is used.", + "required": [ + "background", + "foreground", + "accent", + "accentForeground" + ], + "properties": { + "background": { + "type": "union", + "refs": ["site.standard.theme.color#rgb"], + "description": "Color used for content background." + }, + "foreground": { + "type": "union", + "refs": ["site.standard.theme.color#rgb"], + "description": "Color used for content text." + }, + "accent": { + "type": "union", + "refs": ["site.standard.theme.color#rgb"], + "description": "Color used for links and button backgrounds." + }, + "accentForeground": { + "type": "union", + "refs": ["site.standard.theme.color#rgb"], + "description": "Color used for button text." + } + } + }, + "themeId": { + "type": "string", + "maxLength": 64, + "description": "Lichen-internal theme preset identifier (e.g. 'light', 'dark'). Lets the appview restore the named preset on round-trip even if a preset's hex values drift between versions. Foreign clients can ignore this and read 'theme' directly." } } } diff --git a/src/atproto/pds.ts b/src/atproto/pds.ts index 42d5505..e68f466 100644 --- a/src/atproto/pds.ts +++ b/src/atproto/pds.ts @@ -15,6 +15,53 @@ export interface RevisionBlob { size: number; } +/** + * Theme payload written to a wiki record on PDS. The shape mirrors + * `site.standard.theme.basic` for cross-app interop; `themeId` lets us + * restore the named preset on round-trip even if hex values drift. + */ +export interface ThemePdsPayload { + themeId: string; + background: string; + foreground: string; + accent: string; + accentForeground: string; +} + +interface RgbColor { + $type: "site.standard.theme.color#rgb"; + r: number; + g: number; + b: number; +} + +function hexToRgb(hex: string): RgbColor { + const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (!m) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { + $type: "site.standard.theme.color#rgb", + r: Number.parseInt(m[1] as string, 16), + g: Number.parseInt(m[2] as string, 16), + b: Number.parseInt(m[3] as string, 16), + }; +} + +function buildThemeRecord(theme: ThemePdsPayload): { + background: RgbColor; + foreground: RgbColor; + accent: RgbColor; + accentForeground: RgbColor; +} { + return { + background: hexToRgb(theme.background), + foreground: hexToRgb(theme.foreground), + accent: hexToRgb(theme.accent), + accentForeground: hexToRgb(theme.accentForeground), + }; +} + async function putRecord( rpc: Client, did: string, @@ -44,6 +91,7 @@ export function writeWikiRecord( createdAt: string, language: string, description?: string, + theme?: ThemePdsPayload, ): Promise { const fields: Record = { name, @@ -52,6 +100,10 @@ export function writeWikiRecord( createdAt, }; if (description) fields["description"] = description; + if (theme) { + fields["theme"] = buildThemeRecord(theme); + fields["themeId"] = theme.themeId; + } return putRecord(rpc, did, COLLECTIONS.wiki, slug, fields); } diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index e6eb25f..0ba26d7 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -9,12 +9,14 @@ import { deleteWikiByAtUri, getNoteByAtUri, getWikiByAtUri, + setWikiTheme, upsertBookmark, upsertMembership, upsertNote, upsertRequest, upsertWiki, } from "../server/db/queries/index.ts"; +import { themes } from "../views/theme/themes.ts"; interface WikiRecord { name: string; @@ -22,6 +24,7 @@ interface WikiRecord { createdAt: string; language?: string; description?: string; + themeId?: string; } interface NoteRecord { @@ -265,6 +268,17 @@ function handleWiki( record.language ?? "en", record.description ?? "", ); + + // Theme sync from PDS. Presence of a known themeId means the wiki enforces + // that preset; absence (or an unknown id from a future Lichen build, or a + // foreign client that wrote `theme` without `themeId`) falls back to + // reader mode so visitors keep their own theme. + const themeId = record.themeId; + if (typeof themeId === "string" && themeId in themes) { + setWikiTheme(rkey, "enforce", themeId); + } else { + setWikiTheme(rkey, "reader", "light"); + } } function handleNote(did: string, atUri: string, record: NoteRecord): void { diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 3c98471..9d3ed96 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -1,6 +1,7 @@ import * as TID from "@atcute/tid"; import { deleteRecord, + type ThemePdsPayload, writeMembershipRecord, writeNoteRecord, writeRevisionRecord, @@ -34,6 +35,35 @@ export interface WikiFormFields { description: string; } +/** + * Build the standard PDS theme payload from a known preset key. Buttons in + * Lichen always render text-white, so accentForeground is fixed. + */ +function buildThemePdsPayload(themeId: keyof typeof themes): ThemePdsPayload { + const t = themes[themeId]; + return { + themeId, + background: t.bg, + foreground: t.text, + accent: t.accent, + accentForeground: "#ffffff", + }; +} + +/** + * Resolve the theme payload to write alongside a wiki record on PDS. + * Returns undefined when the wiki is in "reader" mode so the field is + * omitted from the record entirely. + */ +function themePayloadFor( + themeMode: string, + themeKey: string, +): ThemePdsPayload | undefined { + if (themeMode !== "enforce") return undefined; + if (!(themeKey in themes)) return undefined; + return buildThemePdsPayload(themeKey as keyof typeof themes); +} + interface WikiCoreResult { wikiSlug: string; wikiAtUri: string; @@ -228,6 +258,9 @@ export async function editWikiAction( const agent = ctx.session ? getAgent(ctx.session) : null; if (agent) { + // Carry the wiki's current theme through PDS update so unrelated edits + // to name/description don't accidentally strip an enforced theme. + const theme = themePayloadFor(ctx.wiki.theme_mode, ctx.wiki.theme); await withPdsError("edit wiki", async () => { await writeWikiRecord( agent, @@ -238,6 +271,7 @@ export async function editWikiAction( ctx.wiki.created_at, ctx.wiki.language, description || undefined, + theme, ); }); } @@ -256,14 +290,16 @@ export async function editWikiAction( /** * Update a wiki's theme settings. Admin only (route-gated). - * DB-only for now; PDS persistence lands in commit 7. + * Writes the wiki record on PDS with the chosen theme (or strips it for + * "reader" mode), then updates the local DB. */ -export function setWikiThemeAction( +export async function setWikiThemeAction( ctx: WikiRequestContext, fields: { themeMode: string; theme: string }, msg: Messages, -): void { +): Promise { if (!ctx.did) throw new ForbiddenError(); + const did = ctx.did; if (fields.themeMode !== "reader" && fields.themeMode !== "enforce") { throw new ValidationError(msg.error.invalidThemeMode); @@ -272,6 +308,24 @@ export function setWikiThemeAction( throw new ValidationError(msg.error.invalidTheme); } + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { + const theme = themePayloadFor(fields.themeMode, fields.theme); + await withPdsError("set wiki theme", async () => { + await writeWikiRecord( + agent, + did, + ctx.wiki.slug, + ctx.wiki.name, + ctx.wiki.visibility as "public" | "private", + ctx.wiki.created_at, + ctx.wiki.language, + ctx.wiki.description || undefined, + theme, + ); + }); + } + setWikiTheme(ctx.wiki.slug, fields.themeMode, fields.theme); } diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index bb33050..9a21230 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -190,7 +190,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const theme = (formData.get("theme") as string | null) ?? "light"; try { - setWikiThemeAction(ctx, { themeMode, theme }, msg); + await setWikiThemeAction(ctx, { themeMode, theme }, msg); return redirect(`/wiki/${params.wikiSlug}/-/settings?themeSaved=1`); } catch (err) { if (err instanceof ValidationError) { diff --git a/tests/firehose/handlers.test.ts b/tests/firehose/handlers.test.ts index 7f9baea..2afc097 100644 --- a/tests/firehose/handlers.test.ts +++ b/tests/firehose/handlers.test.ts @@ -44,6 +44,9 @@ const HANDLER_TEST_WIKIS = [ "lang-wiki", "nolang-wiki", "oversize-wiki", + "theme-enforced", + "theme-unknown", + "theme-cleared", ]; function cleanupHandlerTestData() { @@ -175,6 +178,84 @@ describe("wiki handler", () => { expect(wiki).toBeNull(); }); + test("applies enforced theme from themeId on the wiki record", () => { + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.wiki", + rkey: "theme-enforced", + did: ALICE_DID, + record: { + name: "Themed Wiki", + visibility: "public", + createdAt: "2026-01-01T00:00:00.000Z", + themeId: "dark", + }, + }), + ); + + const wiki = getWiki("theme-enforced"); + expect(wiki?.theme_mode).toBe("enforce"); + expect(wiki?.theme).toBe("dark"); + }); + + test("falls back to reader mode for unknown themeId", () => { + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.wiki", + rkey: "theme-unknown", + did: ALICE_DID, + record: { + name: "Future Themed Wiki", + visibility: "public", + createdAt: "2026-01-01T00:00:00.000Z", + themeId: "catppuccin-mocha", + }, + }), + ); + + const wiki = getWiki("theme-unknown"); + expect(wiki?.theme_mode).toBe("reader"); + expect(wiki?.theme).toBe("light"); + }); + + test("clears enforce mode when themeId is removed on update", () => { + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.wiki", + rkey: "theme-cleared", + did: ALICE_DID, + record: { + name: "Toggle Theme Wiki", + visibility: "public", + createdAt: "2026-01-01T00:00:00.000Z", + themeId: "dark", + }, + }), + ); + expect(getWiki("theme-cleared")?.theme_mode).toBe("enforce"); + + handleCommitEvent( + makeCommitEvt({ + event: "update", + collection: "wiki.lichen.wiki", + rkey: "theme-cleared", + did: ALICE_DID, + record: { + name: "Toggle Theme Wiki", + visibility: "public", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + ); + + const wiki = getWiki("theme-cleared"); + expect(wiki?.theme_mode).toBe("reader"); + expect(wiki?.theme).toBe("light"); + }); + test("deletes wiki and cascades", () => { handleCommitEvent( makeCommitEvt({ diff --git a/tests/lib/orchestrators/wiki.test.ts b/tests/lib/orchestrators/wiki.test.ts index 6b2523f..5025c6b 100644 --- a/tests/lib/orchestrators/wiki.test.ts +++ b/tests/lib/orchestrators/wiki.test.ts @@ -346,9 +346,13 @@ describe("setWikiThemeAction", () => { test("updates theme_mode and theme in DB", async () => { const wiki = await createTestWiki("Orch Theme Update"); - const ctx = makeWikiCtx(wiki); + const ctx = makeWikiCtx(wiki, { session: null }); - setWikiThemeAction(ctx, { themeMode: "enforce", theme: "dark" }, dummyMsg); + await setWikiThemeAction( + ctx, + { themeMode: "enforce", theme: "dark" }, + dummyMsg, + ); const db = getDb(); const row = db @@ -358,6 +362,40 @@ describe("setWikiThemeAction", () => { expect(row.theme).toBe("dark"); }); + test("writes theme record to PDS when enforcing", async () => { + const wiki = await createTestWiki("Orch Theme PDS"); + mockWriteWikiRecord.mockClear(); + const ctx = makeWikiCtx(wiki); + + await setWikiThemeAction( + ctx, + { themeMode: "enforce", theme: "dark" }, + dummyMsg, + ); + + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); + const call = mockWriteWikiRecord.mock.calls[0] as unknown[]; + const themeArg = call[8] as { themeId: string } | undefined; + expect(themeArg).toBeDefined(); + expect(themeArg?.themeId).toBe("dark"); + }); + + test("strips theme from PDS record in reader mode", async () => { + const wiki = await createTestWiki("Orch Theme PDS Reader"); + mockWriteWikiRecord.mockClear(); + const ctx = makeWikiCtx(wiki); + + await setWikiThemeAction( + ctx, + { themeMode: "reader", theme: "light" }, + dummyMsg, + ); + + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); + const call = mockWriteWikiRecord.mock.calls[0] as unknown[]; + expect(call[8]).toBeUndefined(); + }); + test("new wikis default to reader + light", async () => { const wiki = await createTestWiki("Orch Theme Defaults"); expect(wiki.theme_mode).toBe("reader"); @@ -366,24 +404,24 @@ describe("setWikiThemeAction", () => { test("throws ValidationError on invalid theme mode", async () => { const wiki = await createTestWiki("Orch Theme Bad Mode"); - const ctx = makeWikiCtx(wiki); + const ctx = makeWikiCtx(wiki, { session: null }); - expect(() => + expect( setWikiThemeAction(ctx, { themeMode: "bogus", theme: "light" }, dummyMsg), - ).toThrow(ValidationError); + ).rejects.toBeInstanceOf(ValidationError); }); test("throws ValidationError on invalid theme name", async () => { const wiki = await createTestWiki("Orch Theme Bad Name"); - const ctx = makeWikiCtx(wiki); + const ctx = makeWikiCtx(wiki, { session: null }); - expect(() => + expect( setWikiThemeAction( ctx, { themeMode: "enforce", theme: "neon" }, dummyMsg, ), - ).toThrow(ValidationError); + ).rejects.toBeInstanceOf(ValidationError); }); });