🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add theming in wiki lexicon

juprodh cadddb76 af34b2a9

+288 -12
+37
lexicons/wiki.lichen.wiki.json
··· 35 35 "type": "string", 36 36 "maxLength": 300, 37 37 "description": "Short description of the wiki's purpose or topic." 38 + }, 39 + "theme": { 40 + "type": "object", 41 + "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.", 42 + "required": [ 43 + "background", 44 + "foreground", 45 + "accent", 46 + "accentForeground" 47 + ], 48 + "properties": { 49 + "background": { 50 + "type": "union", 51 + "refs": ["site.standard.theme.color#rgb"], 52 + "description": "Color used for content background." 53 + }, 54 + "foreground": { 55 + "type": "union", 56 + "refs": ["site.standard.theme.color#rgb"], 57 + "description": "Color used for content text." 58 + }, 59 + "accent": { 60 + "type": "union", 61 + "refs": ["site.standard.theme.color#rgb"], 62 + "description": "Color used for links and button backgrounds." 63 + }, 64 + "accentForeground": { 65 + "type": "union", 66 + "refs": ["site.standard.theme.color#rgb"], 67 + "description": "Color used for button text." 68 + } 69 + } 70 + }, 71 + "themeId": { 72 + "type": "string", 73 + "maxLength": 64, 74 + "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." 38 75 } 39 76 } 40 77 }
+52
src/atproto/pds.ts
··· 15 15 size: number; 16 16 } 17 17 18 + /** 19 + * Theme payload written to a wiki record on PDS. The shape mirrors 20 + * `site.standard.theme.basic` for cross-app interop; `themeId` lets us 21 + * restore the named preset on round-trip even if hex values drift. 22 + */ 23 + export interface ThemePdsPayload { 24 + themeId: string; 25 + background: string; 26 + foreground: string; 27 + accent: string; 28 + accentForeground: string; 29 + } 30 + 31 + interface RgbColor { 32 + $type: "site.standard.theme.color#rgb"; 33 + r: number; 34 + g: number; 35 + b: number; 36 + } 37 + 38 + function hexToRgb(hex: string): RgbColor { 39 + const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); 40 + if (!m) { 41 + throw new Error(`Invalid hex color: ${hex}`); 42 + } 43 + return { 44 + $type: "site.standard.theme.color#rgb", 45 + r: Number.parseInt(m[1] as string, 16), 46 + g: Number.parseInt(m[2] as string, 16), 47 + b: Number.parseInt(m[3] as string, 16), 48 + }; 49 + } 50 + 51 + function buildThemeRecord(theme: ThemePdsPayload): { 52 + background: RgbColor; 53 + foreground: RgbColor; 54 + accent: RgbColor; 55 + accentForeground: RgbColor; 56 + } { 57 + return { 58 + background: hexToRgb(theme.background), 59 + foreground: hexToRgb(theme.foreground), 60 + accent: hexToRgb(theme.accent), 61 + accentForeground: hexToRgb(theme.accentForeground), 62 + }; 63 + } 64 + 18 65 async function putRecord( 19 66 rpc: Client, 20 67 did: string, ··· 44 91 createdAt: string, 45 92 language: string, 46 93 description?: string, 94 + theme?: ThemePdsPayload, 47 95 ): Promise<PdsWriteResult> { 48 96 const fields: Record<string, unknown> = { 49 97 name, ··· 52 100 createdAt, 53 101 }; 54 102 if (description) fields["description"] = description; 103 + if (theme) { 104 + fields["theme"] = buildThemeRecord(theme); 105 + fields["themeId"] = theme.themeId; 106 + } 55 107 return putRecord(rpc, did, COLLECTIONS.wiki, slug, fields); 56 108 } 57 109
+14
src/firehose/handlers.ts
··· 9 9 deleteWikiByAtUri, 10 10 getNoteByAtUri, 11 11 getWikiByAtUri, 12 + setWikiTheme, 12 13 upsertBookmark, 13 14 upsertMembership, 14 15 upsertNote, 15 16 upsertRequest, 16 17 upsertWiki, 17 18 } from "../server/db/queries/index.ts"; 19 + import { themes } from "../views/theme/themes.ts"; 18 20 19 21 interface WikiRecord { 20 22 name: string; ··· 22 24 createdAt: string; 23 25 language?: string; 24 26 description?: string; 27 + themeId?: string; 25 28 } 26 29 27 30 interface NoteRecord { ··· 265 268 record.language ?? "en", 266 269 record.description ?? "", 267 270 ); 271 + 272 + // Theme sync from PDS. Presence of a known themeId means the wiki enforces 273 + // that preset; absence (or an unknown id from a future Lichen build, or a 274 + // foreign client that wrote `theme` without `themeId`) falls back to 275 + // reader mode so visitors keep their own theme. 276 + const themeId = record.themeId; 277 + if (typeof themeId === "string" && themeId in themes) { 278 + setWikiTheme(rkey, "enforce", themeId); 279 + } else { 280 + setWikiTheme(rkey, "reader", "light"); 281 + } 268 282 } 269 283 270 284 function handleNote(did: string, atUri: string, record: NoteRecord): void {
+57 -3
src/lib/orchestrators/wiki.ts
··· 1 1 import * as TID from "@atcute/tid"; 2 2 import { 3 3 deleteRecord, 4 + type ThemePdsPayload, 4 5 writeMembershipRecord, 5 6 writeNoteRecord, 6 7 writeRevisionRecord, ··· 32 33 language: string; 33 34 visibility: string; 34 35 description: string; 36 + } 37 + 38 + /** 39 + * Build the standard PDS theme payload from a known preset key. Buttons in 40 + * Lichen always render text-white, so accentForeground is fixed. 41 + */ 42 + function buildThemePdsPayload(themeId: keyof typeof themes): ThemePdsPayload { 43 + const t = themes[themeId]; 44 + return { 45 + themeId, 46 + background: t.bg, 47 + foreground: t.text, 48 + accent: t.accent, 49 + accentForeground: "#ffffff", 50 + }; 51 + } 52 + 53 + /** 54 + * Resolve the theme payload to write alongside a wiki record on PDS. 55 + * Returns undefined when the wiki is in "reader" mode so the field is 56 + * omitted from the record entirely. 57 + */ 58 + function themePayloadFor( 59 + themeMode: string, 60 + themeKey: string, 61 + ): ThemePdsPayload | undefined { 62 + if (themeMode !== "enforce") return undefined; 63 + if (!(themeKey in themes)) return undefined; 64 + return buildThemePdsPayload(themeKey as keyof typeof themes); 35 65 } 36 66 37 67 interface WikiCoreResult { ··· 228 258 229 259 const agent = ctx.session ? getAgent(ctx.session) : null; 230 260 if (agent) { 261 + // Carry the wiki's current theme through PDS update so unrelated edits 262 + // to name/description don't accidentally strip an enforced theme. 263 + const theme = themePayloadFor(ctx.wiki.theme_mode, ctx.wiki.theme); 231 264 await withPdsError("edit wiki", async () => { 232 265 await writeWikiRecord( 233 266 agent, ··· 238 271 ctx.wiki.created_at, 239 272 ctx.wiki.language, 240 273 description || undefined, 274 + theme, 241 275 ); 242 276 }); 243 277 } ··· 256 290 257 291 /** 258 292 * Update a wiki's theme settings. Admin only (route-gated). 259 - * DB-only for now; PDS persistence lands in commit 7. 293 + * Writes the wiki record on PDS with the chosen theme (or strips it for 294 + * "reader" mode), then updates the local DB. 260 295 */ 261 - export function setWikiThemeAction( 296 + export async function setWikiThemeAction( 262 297 ctx: WikiRequestContext, 263 298 fields: { themeMode: string; theme: string }, 264 299 msg: Messages, 265 - ): void { 300 + ): Promise<void> { 266 301 if (!ctx.did) throw new ForbiddenError(); 302 + const did = ctx.did; 267 303 268 304 if (fields.themeMode !== "reader" && fields.themeMode !== "enforce") { 269 305 throw new ValidationError(msg.error.invalidThemeMode); 270 306 } 271 307 if (!(fields.theme in themes)) { 272 308 throw new ValidationError(msg.error.invalidTheme); 309 + } 310 + 311 + const agent = ctx.session ? getAgent(ctx.session) : null; 312 + if (agent) { 313 + const theme = themePayloadFor(fields.themeMode, fields.theme); 314 + await withPdsError("set wiki theme", async () => { 315 + await writeWikiRecord( 316 + agent, 317 + did, 318 + ctx.wiki.slug, 319 + ctx.wiki.name, 320 + ctx.wiki.visibility as "public" | "private", 321 + ctx.wiki.created_at, 322 + ctx.wiki.language, 323 + ctx.wiki.description || undefined, 324 + theme, 325 + ); 326 + }); 273 327 } 274 328 275 329 setWikiTheme(ctx.wiki.slug, fields.themeMode, fields.theme);
+1 -1
src/server/routes/wiki.ts
··· 190 190 const theme = (formData.get("theme") as string | null) ?? "light"; 191 191 192 192 try { 193 - setWikiThemeAction(ctx, { themeMode, theme }, msg); 193 + await setWikiThemeAction(ctx, { themeMode, theme }, msg); 194 194 return redirect(`/wiki/${params.wikiSlug}/-/settings?themeSaved=1`); 195 195 } catch (err) { 196 196 if (err instanceof ValidationError) {
+81
tests/firehose/handlers.test.ts
··· 44 44 "lang-wiki", 45 45 "nolang-wiki", 46 46 "oversize-wiki", 47 + "theme-enforced", 48 + "theme-unknown", 49 + "theme-cleared", 47 50 ]; 48 51 49 52 function cleanupHandlerTestData() { ··· 173 176 174 177 const wiki = getWiki("incomplete-wiki"); 175 178 expect(wiki).toBeNull(); 179 + }); 180 + 181 + test("applies enforced theme from themeId on the wiki record", () => { 182 + handleCommitEvent( 183 + makeCommitEvt({ 184 + event: "create", 185 + collection: "wiki.lichen.wiki", 186 + rkey: "theme-enforced", 187 + did: ALICE_DID, 188 + record: { 189 + name: "Themed Wiki", 190 + visibility: "public", 191 + createdAt: "2026-01-01T00:00:00.000Z", 192 + themeId: "dark", 193 + }, 194 + }), 195 + ); 196 + 197 + const wiki = getWiki("theme-enforced"); 198 + expect(wiki?.theme_mode).toBe("enforce"); 199 + expect(wiki?.theme).toBe("dark"); 200 + }); 201 + 202 + test("falls back to reader mode for unknown themeId", () => { 203 + handleCommitEvent( 204 + makeCommitEvt({ 205 + event: "create", 206 + collection: "wiki.lichen.wiki", 207 + rkey: "theme-unknown", 208 + did: ALICE_DID, 209 + record: { 210 + name: "Future Themed Wiki", 211 + visibility: "public", 212 + createdAt: "2026-01-01T00:00:00.000Z", 213 + themeId: "catppuccin-mocha", 214 + }, 215 + }), 216 + ); 217 + 218 + const wiki = getWiki("theme-unknown"); 219 + expect(wiki?.theme_mode).toBe("reader"); 220 + expect(wiki?.theme).toBe("light"); 221 + }); 222 + 223 + test("clears enforce mode when themeId is removed on update", () => { 224 + handleCommitEvent( 225 + makeCommitEvt({ 226 + event: "create", 227 + collection: "wiki.lichen.wiki", 228 + rkey: "theme-cleared", 229 + did: ALICE_DID, 230 + record: { 231 + name: "Toggle Theme Wiki", 232 + visibility: "public", 233 + createdAt: "2026-01-01T00:00:00.000Z", 234 + themeId: "dark", 235 + }, 236 + }), 237 + ); 238 + expect(getWiki("theme-cleared")?.theme_mode).toBe("enforce"); 239 + 240 + handleCommitEvent( 241 + makeCommitEvt({ 242 + event: "update", 243 + collection: "wiki.lichen.wiki", 244 + rkey: "theme-cleared", 245 + did: ALICE_DID, 246 + record: { 247 + name: "Toggle Theme Wiki", 248 + visibility: "public", 249 + createdAt: "2026-01-01T00:00:00.000Z", 250 + }, 251 + }), 252 + ); 253 + 254 + const wiki = getWiki("theme-cleared"); 255 + expect(wiki?.theme_mode).toBe("reader"); 256 + expect(wiki?.theme).toBe("light"); 176 257 }); 177 258 178 259 test("deletes wiki and cascades", () => {
+46 -8
tests/lib/orchestrators/wiki.test.ts
··· 346 346 347 347 test("updates theme_mode and theme in DB", async () => { 348 348 const wiki = await createTestWiki("Orch Theme Update"); 349 - const ctx = makeWikiCtx(wiki); 349 + const ctx = makeWikiCtx(wiki, { session: null }); 350 350 351 - setWikiThemeAction(ctx, { themeMode: "enforce", theme: "dark" }, dummyMsg); 351 + await setWikiThemeAction( 352 + ctx, 353 + { themeMode: "enforce", theme: "dark" }, 354 + dummyMsg, 355 + ); 352 356 353 357 const db = getDb(); 354 358 const row = db ··· 358 362 expect(row.theme).toBe("dark"); 359 363 }); 360 364 365 + test("writes theme record to PDS when enforcing", async () => { 366 + const wiki = await createTestWiki("Orch Theme PDS"); 367 + mockWriteWikiRecord.mockClear(); 368 + const ctx = makeWikiCtx(wiki); 369 + 370 + await setWikiThemeAction( 371 + ctx, 372 + { themeMode: "enforce", theme: "dark" }, 373 + dummyMsg, 374 + ); 375 + 376 + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); 377 + const call = mockWriteWikiRecord.mock.calls[0] as unknown[]; 378 + const themeArg = call[8] as { themeId: string } | undefined; 379 + expect(themeArg).toBeDefined(); 380 + expect(themeArg?.themeId).toBe("dark"); 381 + }); 382 + 383 + test("strips theme from PDS record in reader mode", async () => { 384 + const wiki = await createTestWiki("Orch Theme PDS Reader"); 385 + mockWriteWikiRecord.mockClear(); 386 + const ctx = makeWikiCtx(wiki); 387 + 388 + await setWikiThemeAction( 389 + ctx, 390 + { themeMode: "reader", theme: "light" }, 391 + dummyMsg, 392 + ); 393 + 394 + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); 395 + const call = mockWriteWikiRecord.mock.calls[0] as unknown[]; 396 + expect(call[8]).toBeUndefined(); 397 + }); 398 + 361 399 test("new wikis default to reader + light", async () => { 362 400 const wiki = await createTestWiki("Orch Theme Defaults"); 363 401 expect(wiki.theme_mode).toBe("reader"); ··· 366 404 367 405 test("throws ValidationError on invalid theme mode", async () => { 368 406 const wiki = await createTestWiki("Orch Theme Bad Mode"); 369 - const ctx = makeWikiCtx(wiki); 407 + const ctx = makeWikiCtx(wiki, { session: null }); 370 408 371 - expect(() => 409 + expect( 372 410 setWikiThemeAction(ctx, { themeMode: "bogus", theme: "light" }, dummyMsg), 373 - ).toThrow(ValidationError); 411 + ).rejects.toBeInstanceOf(ValidationError); 374 412 }); 375 413 376 414 test("throws ValidationError on invalid theme name", async () => { 377 415 const wiki = await createTestWiki("Orch Theme Bad Name"); 378 - const ctx = makeWikiCtx(wiki); 416 + const ctx = makeWikiCtx(wiki, { session: null }); 379 417 380 - expect(() => 418 + expect( 381 419 setWikiThemeAction( 382 420 ctx, 383 421 { themeMode: "enforce", theme: "neon" }, 384 422 dummyMsg, 385 423 ), 386 - ).toThrow(ValidationError); 424 + ).rejects.toBeInstanceOf(ValidationError); 387 425 }); 388 426 }); 389 427