🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Improve color palette for accessibility

juprodh 927cba8c a0cc4dae

+121 -11
+2
src/lib/i18n/en.ts
··· 40 40 sourceCode: "Source code", 41 41 recentlyUpdated: "Recently updated", 42 42 recentlyCreated: "Recently created", 43 + sortBy: "Sort by", 44 + filterByLanguage: "Filter by language", 43 45 exploreMore: "Explore more", 44 46 step1: "Log in with your ATProto account", 45 47 step2: "Create a wiki or get invited to collaborate",
+2
src/lib/i18n/fr.ts
··· 41 41 sourceCode: "Code source", 42 42 recentlyUpdated: "Mis à jour récemment", 43 43 recentlyCreated: "Créés récemment", 44 + sortBy: "Trier par", 45 + filterByLanguage: "Filtrer par langue", 44 46 exploreMore: "Explorer plus", 45 47 step1: "Connectez-vous avec votre compte ATProto", 46 48 step2: "Créez un wiki ou faites-vous inviter pour collaborer",
+2
src/lib/i18n/index.ts
··· 42 42 sourceCode: string; 43 43 recentlyUpdated: string; 44 44 recentlyCreated: string; 45 + sortBy: string; 46 + filterByLanguage: string; 45 47 exploreMore: string; 46 48 step1: string; 47 49 step2: string;
+42 -2
src/views/settings.ts
··· 87 87 <select name="role" aria-label="${msg.access.role}" data-original="${m.role}" onchange="this.nextElementSibling.classList.toggle('hidden', this.value === this.dataset.original)" class="text-xs border ${THEME.border} rounded px-1 py-0.5"> 88 88 ${renderRoleOptions(m.role, roleLabel)} 89 89 </select> 90 - <button type="submit" class="hidden p-1 bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] cursor-pointer" title="${msg.access.saveRole}">${ICONS.check}</button> 90 + <button type="submit" class="hidden p-1 bg-[var(--accent)] text-[var(--accent-foreground)] rounded hover:bg-[var(--accent-hover)] cursor-pointer" title="${msg.access.saveRole}">${ICONS.check}</button> 91 91 </form>`; 92 92 const removeButton = isOwner 93 93 ? "" ··· 277 277 278 278 const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; 279 279 280 + const palettes = JSON.stringify( 281 + Object.fromEntries( 282 + (Object.keys(themes) as (keyof typeof themes)[]).map((name) => [ 283 + name, 284 + { 285 + bg: themes[name].bg, 286 + surface: themes[name].surface, 287 + accent: themes[name].accent, 288 + text: themes[name].text, 289 + }, 290 + ]), 291 + ), 292 + ); 293 + 294 + const swatchClass = `inline-block w-6 h-6 rounded border ${THEME.borderInput}`; 295 + const initialPalette = 296 + themes[wikiTheme as keyof typeof themes] ?? themes.light; 297 + 280 298 return `<section class="mb-10"> 281 299 <h2 class="text-lg font-semibold mb-4">${msg.settings.theme}</h2> 282 300 ${savedBanner} ··· 305 323 <div class="flex flex-col gap-1"> 306 324 <label for="theme-preset-select" class="text-xs font-medium ${THEME.textMuted}">${msg.settings.themePresetLabel}</label> 307 325 <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"> 326 + 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" 327 + onchange="window.__updateThemePreview&&window.__updateThemePreview(this.value)"> 309 328 ${themeOptions} 310 329 </select> 330 + <div id="theme-preview" class="flex items-center gap-2 mt-2" aria-hidden="true"> 331 + <span data-swatch="bg" class="${swatchClass}" style="background-color:${initialPalette.bg}" title="bg"></span> 332 + <span data-swatch="surface" class="${swatchClass}" style="background-color:${initialPalette.surface}" title="surface"></span> 333 + <span data-swatch="accent" class="${swatchClass}" style="background-color:${initialPalette.accent}" title="accent"></span> 334 + <span data-swatch="text" class="${swatchClass}" style="background-color:${initialPalette.text}" title="text"></span> 335 + </div> 311 336 </div> 312 337 <div> 313 338 <button type="submit" class="${primarySmallButtonClass}">${msg.settings.save}</button> 314 339 </div> 315 340 </form> 341 + <script> 342 + (function(){ 343 + var palettes = ${palettes}; 344 + window.__updateThemePreview = function(name){ 345 + var p = palettes[name]; 346 + if (!p) return; 347 + var preview = document.getElementById('theme-preview'); 348 + if (!preview) return; 349 + for (var key in p) { 350 + var swatch = preview.querySelector('[data-swatch="' + key + '"]'); 351 + if (swatch) swatch.style.backgroundColor = p[key]; 352 + } 353 + }; 354 + })(); 355 + </script> 316 356 </section>`; 317 357 } 318 358
+65 -4
src/views/theme/apply.ts
··· 18 18 .join(" "); 19 19 } 20 20 21 + function themeVarsImportant(theme: Theme): string { 22 + return Object.entries(theme) 23 + .map(([k, v]) => { 24 + const value = Array.isArray(v) ? v.join(", ") : (v as string); 25 + return `--${kebab(k)}: ${value} !important;`; 26 + }) 27 + .join(" "); 28 + } 29 + 30 + // Tailwind typography (`prose`) ships its own --tw-prose-* palette baked into 31 + // dist.css. The plugin re-declares those vars *inside* `.prose { ... }`, which 32 + // shadows anything set further up the cascade — so we must match `.prose` to 33 + // redirect them at our theme tokens. 34 + const PROSE_OVERRIDES = [ 35 + "--tw-prose-body: var(--text-secondary)", 36 + "--tw-prose-headings: var(--text)", 37 + "--tw-prose-lead: var(--text-secondary)", 38 + "--tw-prose-links: var(--accent)", 39 + "--tw-prose-bold: var(--text)", 40 + "--tw-prose-counters: var(--text-secondary)", 41 + "--tw-prose-bullets: var(--text-secondary)", 42 + "--tw-prose-hr: var(--border)", 43 + "--tw-prose-quotes: var(--text-secondary)", 44 + "--tw-prose-quote-borders: var(--accent-soft-border)", 45 + "--tw-prose-captions: var(--text-muted)", 46 + "--tw-prose-kbd: var(--text)", 47 + "--tw-prose-code: var(--accent)", 48 + "--tw-prose-pre-code: var(--text)", 49 + "--tw-prose-pre-bg: var(--placeholder)", 50 + "--tw-prose-th-borders: var(--border)", 51 + "--tw-prose-td-borders: var(--border-subtle)", 52 + ].join("; "); 53 + 54 + const PROSE_RULE = `.prose { ${PROSE_OVERRIDES}; }`; 55 + 56 + const SELECTION_RULE = `::selection { background-color: var(--accent-soft); color: var(--text); }`; 57 + 58 + const REDUCED_MOTION_RULE = `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }`; 59 + 60 + // `body, body *` with !important overrides any inline CSS vars (wiki-content 61 + // wrappers set their own when a wiki enforces a theme), forcing the printed 62 + // page to use the light palette regardless of user/wiki settings. Prose vars 63 + // reference our theme tokens, so resetting those is enough — no need to 64 + // re-declare prose vars here. 65 + const PRINT_RULE = `@media print { body, body * { ${themeVarsImportant(themes.light)}; color-scheme: light !important; } }`; 66 + 67 + function bodyBlock(theme: Theme, colorScheme: string): string { 68 + return `body { ${themeVars(theme)} color-scheme: ${colorScheme}; }`; 69 + } 70 + 21 71 /** 22 72 * Returns the contents of a <style> block that sets CSS theme vars on <body>. 23 73 * For "system", emits both light defaults and a prefers-color-scheme: dark 24 74 * override so the browser resolves the active theme natively (no FOUC, no JS). 75 + * 76 + * Also injects prose-typography overrides (so markdown headings/links/code 77 + * follow the active theme), `::selection` styling, a reduced-motion guard, 78 + * and a print stylesheet that always falls back to the light theme. 25 79 */ 26 80 export function themeRootStyle(userTheme: UserTheme): string { 27 - if (userTheme === "light") return `body { ${themeVars(themes.light)} }`; 28 - if (userTheme === "dark") return `body { ${themeVars(themes.dark)} }`; 29 - return `body { ${themeVars(themes.light)} } @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`; 81 + let block: string; 82 + if (userTheme === "light") { 83 + block = bodyBlock(themes.light, "light"); 84 + } else if (userTheme === "dark") { 85 + block = bodyBlock(themes.dark, "dark"); 86 + } else { 87 + block = `${bodyBlock(themes.light, "light dark")} @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`; 88 + } 89 + return `${block} ${PROSE_RULE} ${SELECTION_RULE} ${REDUCED_MOTION_RULE} ${PRINT_RULE}`; 30 90 } 31 91 32 92 function themeStyleAttr(theme: Theme): string { ··· 41 101 /** 42 102 * Wraps wiki-content HTML in a div that overrides the chrome theme when the 43 103 * wiki enforces its own. In reader mode the content inherits via CSS cascade 44 - * and no wrapper is emitted. 104 + * and no wrapper is emitted. Prose vars set on <body> reference our base 105 + * tokens, so they automatically follow the wiki's overrides in this scope. 45 106 */ 46 107 export function wrapWikiContent( 47 108 html: string,
+3
src/views/theme/themes.ts
··· 10 10 borderInput: string; 11 11 accent: string; 12 12 accentHover: string; 13 + accentForeground: string; 13 14 accentSoft: string; 14 15 accentSoftBorder: string; 15 16 accentFocus: string; ··· 35 36 borderInput: "#d6d3d1", 36 37 accent: "#0f766e", 37 38 accentHover: "#115e59", 39 + accentForeground: "#ffffff", 38 40 accentSoft: "#f0fdfa", 39 41 accentSoftBorder: "#99f6e4", 40 42 accentFocus: "#0d9488", ··· 67 69 borderInput: "#57534e", 68 70 accent: "#5eead4", 69 71 accentHover: "#2dd4bf", 72 + accentForeground: "#042f2e", 70 73 accentSoft: "#134e4a", 71 74 accentSoftBorder: "#115e59", 72 75 accentFocus: "#2dd4bf",
+2 -2
src/views/theme/tokens.ts
··· 31 31 32 32 export const inputClass = `w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent-focus)]`; 33 33 34 - export const primaryButtonClass = `px-4 py-2 bg-[var(--accent)] text-white text-sm font-medium rounded hover:bg-[var(--accent-hover)]`; 34 + export const primaryButtonClass = `px-4 py-2 bg-[var(--accent)] text-[var(--accent-foreground)] text-sm font-medium rounded hover:bg-[var(--accent-hover)]`; 35 35 36 - export const primarySmallButtonClass = `px-3 py-1.5 bg-[var(--accent)] text-white text-sm font-medium rounded hover:bg-[var(--accent-hover)]`; 36 + export const primarySmallButtonClass = `px-3 py-1.5 bg-[var(--accent)] text-[var(--accent-foreground)] text-sm font-medium rounded hover:bg-[var(--accent-hover)]`; 37 37 38 38 export const outlineSmallButtonClass = `px-3 py-1.5 border border-[var(--accent)] ${THEME.accentText} text-sm font-medium rounded ${THEME.accentSoft}`; 39 39
+1 -1
src/views/wiki-card.ts
··· 7 7 const msg = t(locale); 8 8 9 9 const languageBadge = wiki.language 10 - ? `<span class="text-xs px-2 py-0.5 rounded-full ${THEME.bgPlaceholder} ${THEME.textMuted}">${escapeHtml(wiki.language.toUpperCase())}</span>` 10 + ? `<span class="text-xs px-2 py-0.5 rounded-full ${THEME.bgPlaceholder} ${THEME.textSecondary}">${escapeHtml(wiki.language.toUpperCase())}</span>` 11 11 : ""; 12 12 13 13 const description = wiki.description
+2 -2
src/views/wiki-list.ts
··· 21 21 const msg = t(locale); 22 22 const { sort, gridTargetId, limit } = options; 23 23 24 - const sortDropdown = `<select id="sort-select" name="sort" 24 + const sortDropdown = `<select id="sort-select" name="sort" aria-label="${msg.home.sortBy}" 25 25 class="px-2 py-1.5 text-sm border ${THEME.border} rounded-lg outline-none focus:border-[var(--accent-focus-input)] focus:ring-1 focus:ring-[var(--accent-soft-border)] ${THEME.bgSurface} shrink-0" 26 26 onchange="htmx.trigger(document.getElementById('wiki-search'), 'filterChange')"> 27 27 <option value="updated"${sort === "updated" ? " selected" : ""}>${msg.home.recentlyUpdated}</option> ··· 63 63 ) 64 64 .join("\n"); 65 65 66 - return `<select id="lang-select" name="lang" 66 + return `<select id="lang-select" name="lang" aria-label="${msg.home.filterByLanguage}" 67 67 class="px-2 py-1.5 text-sm border ${THEME.border} rounded-lg outline-none focus:border-[var(--accent-focus-input)] focus:ring-1 focus:ring-[var(--accent-soft-border)] ${THEME.bgSurface} shrink-0" 68 68 onchange="htmx.trigger(document.getElementById('wiki-search'), 'filterChange')"> 69 69 <option value="">${msg.home.allLanguages}</option>