diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 79f0a56..f7b7eee 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -14,7 +14,7 @@ lichen.wiki { # Static assets served from disk handle /public/* { uri strip_prefix /public - root * /var/www/lichen/public + root * /opt/lichen/public file_server header Cache-Control "public, max-age=31536000, immutable" diff --git a/src/atproto/session.ts b/src/atproto/session.ts index 9ac7699..cb413cb 100644 --- a/src/atproto/session.ts +++ b/src/atproto/session.ts @@ -8,6 +8,7 @@ import { getAtprotoEnv, getDevAccounts } from "./env.ts"; export interface Session { did: string; handle: string; + avatar?: string | null; oauthSession?: OAuthSession; } @@ -25,7 +26,12 @@ async function getSession( try { const oauthSession = await client.restore(did as Did); const profile = await resolveProfile(did); - return { did, handle: profile.handle ?? did, oauthSession }; + return { + did, + handle: profile.handle ?? did, + avatar: profile.avatar, + oauthSession, + }; } catch { return null; } @@ -48,7 +54,7 @@ function getDevSession(cookieHeader: string | undefined): Session | null { const account = Object.values(accounts).find((a) => a.did === did); if (!account) return null; - return { did, handle: account.handle }; + return { did, handle: account.handle, avatar: null }; } /** diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 5cccc23..55956b7 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -141,12 +141,8 @@ export const en: Messages = { save: "Save changes", detailsSaved: "Wiki details saved.", theme: "Theme", - themeModeLabel: "Theme behavior", - themeReader: "Reader's choice", - themeReaderHint: "Visitors see the wiki in their preferred theme.", - themeEnforce: "Enforce a theme", - themeEnforceHint: "All visitors see the wiki in the theme you pick below.", - themePresetLabel: "Preset", + themeChoiceLabel: "Theme", + themeReaderDefault: "User's default", themeSaved: "Theme saved.", dangerZone: "Delete this wiki", deleteWikiDescription: diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 8ac0d1e..fe0bac4 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -143,13 +143,8 @@ export const fr: PartialMessages = { save: "Enregistrer les modifications", detailsSaved: "Détails du wiki enregistrés.", theme: "Thème", - themeModeLabel: "Comportement du thème", - themeReader: "Choix du lecteur", - themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.", - themeEnforce: "Imposer un thème", - themeEnforceHint: - "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.", - themePresetLabel: "Préréglage", + themeChoiceLabel: "Thème", + themeReaderDefault: "Choix du lecteur", themeSaved: "Thème enregistré.", dangerZone: "Supprimer ce wiki", deleteWikiDescription: diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index c3bd131..775a56b 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -141,12 +141,8 @@ export interface Messages { save: string; detailsSaved: string; theme: string; - themeModeLabel: string; - themeReader: string; - themeReaderHint: string; - themeEnforce: string; - themeEnforceHint: string; - themePresetLabel: string; + themeChoiceLabel: string; + themeReaderDefault: string; themeSaved: string; dangerZone: string; deleteWikiDescription: string; diff --git a/src/lib/og-card.ts b/src/lib/og-card.ts new file mode 100644 index 0000000..674eee9 --- /dev/null +++ b/src/lib/og-card.ts @@ -0,0 +1,231 @@ +import sharp from "sharp"; +import { escapeHtml } from "./html.ts"; + +const OG_CARD_WIDTH = 1200; +const OG_CARD_HEIGHT = 630; + +// OG cards always render against the light palette so previews look the same +// in every Bluesky/Discord/Twitter feed regardless of the viewer's device theme. +const PALETTE = { + bg: "#fafaf9", + surface: "#ffffff", + border: "#e7e5e4", + text: "#1c1917", + textSecondary: "#44403c", + textMuted: "#78716c", + accent: "#0f766e", + accentSoft: "#f0fdfa", +}; + +const FONT_STACK = + "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif"; + +// Geometry copied verbatim from public/logo.svg so the card mark matches the +// favicon and nav logo. Re-centred to 0,0 with a viewBox the size of the path. +const LICHEN_LOGO_SVG = (size: number, color: string): string => + ``; + +/** + * Greedy word-wrap on character count. Approximate but works fine for sans-serif + * at the sizes we use (a real metric-aware wrapper would need font loading and + * isn't worth the complexity for an OG card). + */ +function wrapText( + text: string, + maxCharsPerLine: number, + maxLines: number, +): string[] { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxCharsPerLine) { + current = candidate; + continue; + } + if (current) lines.push(current); + current = word; + if (lines.length >= maxLines) break; + } + if (current && lines.length < maxLines) lines.push(current); + if (lines.length === maxLines) { + const last = lines[maxLines - 1] ?? ""; + const remainingWords = words.slice( + lines.flatMap((l) => l.split(/\s+/)).length, + ); + if (remainingWords.length > 0) { + const truncated = + last.length > maxCharsPerLine - 1 + ? `${last.slice(0, maxCharsPerLine - 1)}…` + : `${last}…`; + lines[maxLines - 1] = truncated; + } + } + return lines; +} + +function truncate(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text; +} + +interface BrandingArgs { + x: number; + y: number; +} + +function brandingMark({ x, y }: BrandingArgs): string { + const logoSize = 36; + return ` + ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} + Lichen + `; +} + +interface WikiCardData { + name: string; + description: string; + language: string | null; + noteCount: number; + ownerHandle: string | null; +} + +export function buildWikiCardSvg(data: WikiCardData): string { + const PAD = 60; + const INNER_PAD = 64; + const innerX = PAD; + const innerY = PAD; + const innerW = OG_CARD_WIDTH - PAD * 2; + const innerH = OG_CARD_HEIGHT - PAD * 2; + + const contentX = innerX + INNER_PAD; + const contentRight = innerX + innerW - INNER_PAD; + + const wikiNameTruncated = truncate(data.name, 28); + const descLines = wrapText(data.description ?? "", 50, 3); + + const langBadge = data.language + ? ` + + ${escapeHtml(data.language.toUpperCase())} + ` + : ""; + + const descY = data.language ? 320 : 280; + const descSvg = descLines + .map( + (line, i) => + `${escapeHtml(line)}`, + ) + .join(""); + + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; + const noteLabel = `${data.noteCount} ${data.noteCount === 1 ? "note" : "notes"}`; + + const ownerBlock = data.ownerHandle + ? `${escapeHtml(data.ownerHandle)} + + + ` + : ""; + + return ` + + + ${brandingMark({ x: contentX, y: innerY + 32 })} + ${escapeHtml(wikiNameTruncated)} + ${langBadge} + ${descSvg} + + ${escapeHtml(noteLabel)} + ${ownerBlock} + `; +} + +export function buildHomeCardSvg(): string { + const PAD = 60; + const innerX = PAD; + const innerY = PAD; + const innerW = OG_CARD_WIDTH - PAD * 2; + const innerH = OG_CARD_HEIGHT - PAD * 2; + + const centerX = OG_CARD_WIDTH / 2; + const logoSize = 160; + const logoY = innerY + 100; + + return ` + + + ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} + Lichen + Shared knowledge, owned by you + lichen.wiki · built on ATProto + `; +} + +/** + * Fetch a Bluesky-CDN avatar and shape it into a circular PNG sized for the + * card footer. Returns null on any failure so the card still renders without + * the avatar (a placeholder circle is drawn into the SVG underneath). + */ +async function fetchAvatarCircle( + url: string, + diameter: number, +): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) return null; + const bytes = Buffer.from(await res.arrayBuffer()); + const resized = await sharp(bytes) + .resize(diameter, diameter, { fit: "cover" }) + .png() + .toBuffer(); + const mask = Buffer.from( + ``, + ); + return await sharp(resized) + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); + } catch { + return null; + } +} + +/** + * Render an OG card SVG to PNG, optionally compositing a circular avatar at + * the wiki card's owner-avatar slot. + */ +export async function renderCardPng( + svg: string, + avatar?: { url: string; diameter: number; left: number; top: number } | null, +): Promise { + const base = sharp(Buffer.from(svg)).png(); + if (!avatar) return await base.toBuffer(); + const avatarPng = await fetchAvatarCircle(avatar.url, avatar.diameter); + if (!avatarPng) return await base.toBuffer(); + return await base + .composite([{ input: avatarPng, top: avatar.top, left: avatar.left }]) + .toBuffer(); +} + +/** + * Pixel position of the wiki card avatar circle (matches the placeholder + * circle drawn in buildWikiCardSvg: g.translate(contentRight-72, footerY-36) + * + circle cx=36 cy=24 r=30 → top-left at (contentRight-66, footerY-42)). + */ +export function wikiCardAvatarSlot(): { + diameter: number; + left: number; + top: number; +} { + const PAD = 60; + const INNER_PAD = 64; + const contentRight = OG_CARD_WIDTH - PAD - INNER_PAD; + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; + return { + diameter: 60, + left: contentRight - 66, + top: footerY - 42, + }; +} diff --git a/src/server/app.ts b/src/server/app.ts index 9823b84..103f4ae 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -11,6 +11,7 @@ import { homeRoute } from "./routes/home.ts"; import { localeRoutes } from "./routes/locale.ts"; import { membershipRoutes } from "./routes/membership.ts"; import { noteRoutes } from "./routes/note.ts"; +import { ogRoutes } from "./routes/og.ts"; import { profileRoutes } from "./routes/profile.ts"; import { searchRoutes } from "./routes/search.ts"; import { themeRoutes } from "./routes/theme.ts"; @@ -46,6 +47,7 @@ export function buildApp() { .use(localeRoutes) .use(themeRoutes) .use(profileRoutes) + .use(ogRoutes) .use(homeRoute) .use(exploreRoutes) .use(searchRoutes) diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 264c88e..4d5c551 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -1,3 +1,8 @@ +// Mirror the CHECK constraints in src/server/db/schema.ts so callers can rely +// on these unions when wiring the wiki theme into views without re-narrowing. +export type WikiThemeMode = "reader" | "enforce"; +export type WikiThemeName = "light" | "dark"; + export interface WikiRow { slug: string; did: string; @@ -5,8 +10,8 @@ export interface WikiRow { visibility: string; language: string; description: string; - theme_mode: string; - theme: string; + theme_mode: WikiThemeMode; + theme: WikiThemeName; at_uri: string; created_at: string; updated_at: string; diff --git a/src/server/routes/home.ts b/src/server/routes/home.ts index 447a50d..783d0d2 100644 --- a/src/server/routes/home.ts +++ b/src/server/routes/home.ts @@ -1,4 +1,5 @@ import { Elysia } from "elysia"; +import { getAtprotoEnv } from "../../atproto/env.ts"; import { resolveRequestContext } from "../../lib/access.ts"; import { LIMITS } from "../../lib/limits.ts"; import { htmlResponse } from "../../lib/response.ts"; @@ -16,11 +17,14 @@ export const homeRoute = new Elysia().get("/", async ({ request }) => { offset: 0, }); const languages = getWikiLanguages(); + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; return htmlResponse( homePage(wikis, languages, { session: ctx.session, locale: ctx.locale, userTheme: ctx.userTheme, + ogUrl: `${publicUrl}/`, + ogImage: `${publicUrl}/og/home.png`, }), { edgeCacheSeconds: ctx.session ? 0 : 60 }, ); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 9304e53..37c2f44 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -145,6 +145,11 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, params.noteSlug)}`; const shareHtml = shareOnBlueskyButton(data.note.title, canonicalUrl, msg); + // Notes inherit the parent wiki's OG card — no per-note image rendered. + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogDescription = ctx.wiki.description + ? { ogDescription: ctx.wiki.description } + : {}; const cacheable = !ctx.session && ctx.wiki.visibility === "public" ? 60 : 0; @@ -167,6 +172,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) shareHtml, ogTitle: `${data.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, diff --git a/src/server/routes/og.ts b/src/server/routes/og.ts new file mode 100644 index 0000000..21564ba --- /dev/null +++ b/src/server/routes/og.ts @@ -0,0 +1,52 @@ +import { Elysia } from "elysia"; +import { + buildHomeCardSvg, + buildWikiCardSvg, + renderCardPng, + wikiCardAvatarSlot, +} from "../../lib/og-card.ts"; +import { resolveProfile } from "../../lib/profile.ts"; +import { getSidebarNotes, getWiki } from "../db/queries/index.ts"; + +const PNG_HEADERS = { + "Content-Type": "image/png", + // 1h server cache + Cloudflare CDN in front means most requests never hit + // this handler. Bluesky/Twitter cache the image themselves on top of that. + "Cache-Control": "public, max-age=3600", +} as const; + +export const ogRoutes = new Elysia({ prefix: "/og" }) + .get("/home.png", async () => { + const png = await renderCardPng(buildHomeCardSvg()); + return new Response(png, { headers: PNG_HEADERS }); + }) + // No `.png` suffix on the route param — Elysia would treat it as part of + // the param name. Content-Type header tells consumers what they got. + .get("/wiki/:wikiSlug", async ({ params }) => { + const wiki = getWiki(params.wikiSlug); + if (!wiki) { + return new Response("Wiki not found", { status: 404 }); + } + const profile = await resolveProfile(wiki.did); + const noteCount = getSidebarNotes(wiki.slug).length; + const svg = buildWikiCardSvg({ + name: wiki.name, + description: wiki.description, + language: wiki.language || null, + noteCount, + ownerHandle: profile.handle, + }); + const slot = wikiCardAvatarSlot(); + const png = await renderCardPng( + svg, + profile.avatar + ? { + url: profile.avatar, + diameter: slot.diameter, + left: slot.left, + top: slot.top, + } + : null, + ); + return new Response(png, { headers: PNG_HEADERS }); + }); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 9a21230..72afd24 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -186,8 +186,12 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const msg = t(ctx.locale); const formData = await request.formData(); - const themeMode = (formData.get("theme_mode") as string | null) ?? "reader"; - const theme = (formData.get("theme") as string | null) ?? "light"; + // Single dropdown sends "default" (= reader mode) or a preset name + // (= enforce that preset). Falls back to keeping the current theme key + // when in default so the value isn't lost on later toggles. + const choice = (formData.get("theme_choice") as string | null) ?? "default"; + const themeMode = choice === "default" ? "reader" : "enforce"; + const theme = choice === "default" ? ctx.wiki.theme : choice; try { await setWikiThemeAction(ctx, { themeMode, theme }, msg); @@ -291,13 +295,17 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const msg = t(ctx.locale); const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogDescription = ctx.wiki.description + ? { ogDescription: ctx.wiki.description } + : {}; + if (homeData) { const { html, hasViz, hasMath } = renderMarkdown( homeData.current.content, params.wikiSlug, ); - const publicUrl = - getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, "home")}`; const shareHtml = shareOnBlueskyButton( homeData.note.title, @@ -321,8 +329,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, - ogTitle: `${homeData.note.title} - ${ctx.wiki.name}`, + ogTitle: ctx.wiki.name, ogUrl: canonicalUrl, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, @@ -343,6 +353,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, + ogTitle: ctx.wiki.name, + ogUrl: `${publicUrl}${wikiUrl(ctx.wiki.slug)}`, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, diff --git a/src/views/icons.ts b/src/views/icons.ts index 29a69bf..cfedd1c 100644 --- a/src/views/icons.ts +++ b/src/views/icons.ts @@ -8,4 +8,7 @@ export const ICONS = { check: ``, download: ``, theme: ``, + // Inlined so fill="currentColor" lets the nav logo follow theme accent. + // Geometry kept identical to /public/logo.svg (favicon) so the brand mark stays consistent. + logo: ``, } as const; diff --git a/src/views/layout.ts b/src/views/layout.ts index 2f0d745..e49a50c 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -10,9 +10,12 @@ import { sidebarButtonClass, sidebarLinkClass, THEME, + type ThemeName, themeRootStyle, USER_THEMES, type UserTheme, + type WikiThemeMode, + wikiThemeStyleAttr, } from "./theme/index.ts"; const LOCALE_LABELS: Record = { @@ -23,7 +26,7 @@ const LOCALE_LABELS: Record = { export interface LayoutOptions { scripts?: string[]; stylesheets?: string[]; - session?: { did: string; handle: string } | null; + session?: { did: string; handle: string; avatar?: string | null } | null; wikiName?: string; wikiSlug?: string; sidebarNotes?: { slug: string; title: string }[]; @@ -36,9 +39,44 @@ export interface LayoutOptions { shareHtml?: string; ogTitle?: string; ogUrl?: string; + ogDescription?: string; + ogImage?: string; + wikiThemeMode?: WikiThemeMode; + wikiTheme?: ThemeName; enableSearchShortcut?: boolean; } +// Defaults so even pages without per-page overrides emit a usable Bluesky/Twitter card. +const OG_DEFAULT_DESCRIPTION = + "Build a knowledge base with friends — wikis owned by you, built on ATProto."; + +function renderOpenGraph( + options: LayoutOptions | undefined, + title: string, +): string { + // og:image and og:url need absolute URLs. We derive the origin from the + // per-page ogUrl when provided; otherwise we don't know the request origin + // here, so we emit a relative path and trust most consumers to resolve it. + const ogUrl = options?.ogUrl; + const origin = ogUrl ? new URL(ogUrl).origin : ""; + const ogImage = options?.ogImage ? options.ogImage : `${origin}/og/home.png`; + const ogTitle = options?.ogTitle ?? options?.wikiName ?? title; + const ogDescription = options?.ogDescription ?? OG_DEFAULT_DESCRIPTION; + const urlTag = ogUrl + ? `` + : ""; + return ` + + + + + ${urlTag} + + + + `; +} + function renderSearchButton(locale: Locale = "en"): string { const msg = t(locale); return ` `; + // Bluesky's `avatar` URL is already a CDN-hosted ~256px JPEG, so the + // browser scales it down for the 22px display — no extra resizing needed. + const avatarBadge = session?.avatar + ? `` + : ICONS.user; const profileOrLogin = session ? ` - ${ICONS.user} + ${avatarBadge} ${ICONS.user} ${msg.profile.myWikis} @@ -200,15 +243,7 @@ export function layout( : "Lichen", )} - ${ - options?.ogUrl && options.ogTitle - ? ` - - - - ` - : "" - } + ${renderOpenGraph(options, title)} ${extraStyles} @@ -219,18 +254,7 @@ ${extraScripts} - - - Lichen - + ${ICONS.logo}Lichen ${options?.wikiName ? `/${escapeHtml(options.wikiName)}` : ""} @@ -292,7 +316,7 @@ ${extraScripts} window._htmxToastTimeout = setTimeout(() => toast.classList.add('hidden'), 3000); }); - + ${ options?.sidebarNotes ? renderWithSidebar( @@ -305,7 +329,7 @@ ${extraScripts} diff --git a/src/views/note.ts b/src/views/note.ts index 2ff670d..ab85922 100644 --- a/src/views/note.ts +++ b/src/views/note.ts @@ -1,27 +1,19 @@ import { type LayoutOptions, layout } from "./layout.ts"; -import { wrapWikiContent } from "./theme/index.ts"; export function notePage( wikiName: string, wikiSlug: string, noteTitle: string, renderedHtml: string, - options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, + options?: LayoutOptions, wikiLanguage?: string, ): string { const langAttr = wikiLanguage ? ` lang="${wikiLanguage}"` : ""; - const content = wrapWikiContent( - ` + const content = ` ${renderedHtml} - `, - { - wikiThemeMode: - options?.wikiThemeMode === "enforce" ? "enforce" : "reader", - wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", - }, - ); + `; return layout(noteTitle, content, { ...options, wikiName, diff --git a/src/views/settings.ts b/src/views/settings.ts index 0a0be7d..3c6e965 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -17,8 +17,8 @@ import { themes } from "./theme/themes.ts"; interface SettingsPageOptions extends LayoutOptions { wikiDid: string; wikiDescription: string; - wikiThemeMode: string; - wikiTheme: string; + wikiThemeMode: NonNullable; + wikiTheme: NonNullable; error?: string; detailsSaved?: boolean; themeSaved?: boolean; @@ -263,96 +263,42 @@ function renderThemeSection( locale: string, ): string { const msg = t(locale as "en" | "fr"); - const isEnforce = wikiThemeMode === "enforce"; - const themeLabels: Record = { + + // Map (theme_mode, theme) → single dropdown value: + // "reader" → "default" (visitor's chrome theme wins) + // "enforce" → the preset key (light, dark, …) + const currentChoice = wikiThemeMode === "enforce" ? wikiTheme : "default"; + + const presetLabels: Record = { light: msg.nav.themeLight, dark: msg.nav.themeDark, }; - const themeOptions = (Object.keys(themes) as (keyof typeof themes)[]) - .map( + const themeNames = Object.keys(themes) as (keyof typeof themes)[]; + const options = [ + `${msg.settings.themeReaderDefault}`, + ...themeNames.map( (name) => - `${themeLabels[name] ?? name}`, - ) - .join(""); - - const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; - - const palettes = JSON.stringify( - Object.fromEntries( - (Object.keys(themes) as (keyof typeof themes)[]).map((name) => [ - name, - { - bg: themes[name].bg, - surface: themes[name].surface, - accent: themes[name].accent, - text: themes[name].text, - }, - ]), + `${presetLabels[name] ?? name}`, ), - ); + ].join(""); - const swatchClass = `inline-block w-6 h-6 rounded border ${THEME.borderInput}`; - const initialPalette = - themes[wikiTheme as keyof typeof themes] ?? themes.light; + const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; return ` ${msg.settings.theme} ${savedBanner} - - ${msg.settings.themeModeLabel} - - - - ${msg.settings.themeReader} - ${msg.settings.themeReaderHint} - - - - - - ${msg.settings.themeEnforce} - ${msg.settings.themeEnforceHint} - - - - ${msg.settings.themePresetLabel} - - ${themeOptions} + ${msg.settings.themeChoiceLabel} + + ${options} - - - - - - ${msg.settings.save} - `; } diff --git a/src/views/theme/apply.ts b/src/views/theme/apply.ts index a541bff..d0b9cb5 100644 --- a/src/views/theme/apply.ts +++ b/src/views/theme/apply.ts @@ -1,9 +1,4 @@ -import { - resolveTheme, - type ThemeName, - type UserTheme, - type WikiThemeMode, -} from "./resolve.ts"; +import type { ThemeName, UserTheme, WikiThemeMode } from "./resolve.ts"; import { type Theme, themes } from "./themes.ts"; const kebab = (s: string): string => @@ -99,16 +94,19 @@ function themeStyleAttr(theme: Theme): string { } /** - * Wraps wiki-content HTML in a div that overrides the chrome theme when the - * wiki enforces its own. In reader mode the content inherits via CSS cascade - * and no wrapper is emitted. Prose vars set on reference our base - * tokens, so they automatically follow the wiki's overrides in this scope. + * Inline style content for the wiki-content area when the wiki enforces a + * theme. Sets the theme tokens AND the wrapper's own background/color so the + * area visibly switches palette (otherwise inner text follows the wiki theme + * via CSS vars, but the wrapper background bleeds the user theme through). + * + * Returns "" in reader mode so the area inherits chrome theme via cascade. */ -export function wrapWikiContent( - html: string, - args: { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName } = {}, +export function wikiThemeStyleAttr( + wikiThemeMode: WikiThemeMode | undefined, + wikiTheme: ThemeName | undefined, ): string { - const theme = resolveTheme({ scope: "wikiContent", ...args }); - if (!theme) return html; - return `${html}`; + if (wikiThemeMode !== "enforce" || !wikiTheme) return ""; + const theme = themes[wikiTheme]; + if (!theme) return ""; + return `${themeStyleAttr(theme)}; background-color: var(--bg); color: var(--text); color-scheme: ${wikiTheme};`; } diff --git a/src/views/theme/index.ts b/src/views/theme/index.ts index c947c60..2e9ae91 100644 --- a/src/views/theme/index.ts +++ b/src/views/theme/index.ts @@ -1,5 +1,11 @@ -export { themeRootStyle, wrapWikiContent } from "./apply.ts"; -export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts"; +export { themeRootStyle, wikiThemeStyleAttr } from "./apply.ts"; +export { + resolveUserTheme, + type ThemeName, + USER_THEMES, + type UserTheme, + type WikiThemeMode, +} from "./resolve.ts"; export { dangerButtonClass, dangerSmallButtonClass, diff --git a/src/views/theme/resolve.ts b/src/views/theme/resolve.ts index 069dcf1..8204325 100644 --- a/src/views/theme/resolve.ts +++ b/src/views/theme/resolve.ts @@ -1,4 +1,4 @@ -import { type Theme, themes } from "./themes.ts"; +import type { themes } from "./themes.ts"; export const USER_THEMES = ["light", "dark", "system"] as const; export type UserTheme = (typeof USER_THEMES)[number]; @@ -13,31 +13,6 @@ export type WikiThemeMode = "reader" | "enforce"; /** Named theme keys present in `themes` (light/dark presets). */ export type ThemeName = keyof typeof themes; -type ThemeScope = "chrome" | "wikiContent"; - -type ResolveThemeArgs = { - scope: ThemeScope; - wikiThemeMode?: WikiThemeMode; - wikiTheme?: ThemeName; -}; - -/** - * Returns the palette an element should apply via inline style, or null when - * the element should inherit from its cascading parent. - * - * Chrome scope is always handled by the body