feat(themes): Omarchy catalog, 12 light themes, Dark/Light Appearance picker

Expand the catalog from 11 themes (10 dark, 1 light) to 37 (24/13):
all 18 remaining Omarchy defaults (palettes from each theme's
colors.toml) plus 8 canonical light themes (Tokyo Night Day, Solarized
Light, One Light, Ayu Light, GitHub Light, Gruvbox Light, Everforest
Light, Kanagawa Lotus). Lupine leads the light list as the default
light theme; the app default stays tokyo-night.

Appearance panel: Dark/Light segmented filter (SSR-initialized from the
user's current theme), scrollable theme list, Bookshelf section pinned;
active row revealed on open. Light themes carry color-scheme: light and
the soft-shadow token set. Public-page theme selects get Dark/Light
optgroups. New moon/sun icons.

tailwind.config: safelist the new theme ids — Tailwind v3 purges custom
@layer base class selectors that appear in no scanned content file, so
an unsafelisted id silently ships without its CSS (found the hard way).

theme.ts: applyTheme now swaps only theme-* classes (changing theme
from the login select no longer wipes min-h-screen) and keeps the class
in sync on <html> for the new ThemeFlashGuard — a pre-paint inline
script on the four public pages that applies the saved theme before
first paint.

Kanagawa's UI accent uses crystalBlue (#7e9cd8) instead of Omarchy's
foreground-as-accent so links stay distinguishable from text.
This commit is contained in:
John O'Keefe committed 2026-10-18 12:08:07 -04:00
1 parent 4f913da0b1
commit d5bb874651
21 files changed
+1120 -289

No files matched your search

+42 -14
View File
@@ -84,7 +84,7 @@ templ Header(user User, currentPath string) {
</nav>
<!-- Bottom: theme picker + user -->
<div
x-data="{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }"
x-data={ "{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null, themeMode: '" + string(ThemeModeOf(user.Theme)) + "' }" }
class="shrink-0 px-3 py-3 space-y-2 border-t"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
@@ -97,7 +97,7 @@ templ Header(user User, currentPath string) {
<div class="sidebar-panel">
<button
type="button"
@click="openPanel = openPanel === 'appearance' ? null : 'appearance'"
@click={ "openPanel = openPanel === 'appearance' ? null : 'appearance'; if (openPanel === 'appearance') $nextTick(() => revealCurrentTheme(themeMode))" }
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
>
@Icon("palette", "h-5 w-5 shrink-0")
@@ -106,22 +106,50 @@ templ Header(user User, currentPath string) {
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
for _, opt := range ThemeOptions {
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 pl-1">
<!-- Dark / Light filter -->
<div class="grid grid-cols-2 gap-1 px-2 pt-1">
<button
type="button"
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
data-theme={ opt.Name }
style="color: var(--text-secondary);"
@click="themeMode = 'dark'; $nextTick(() => revealCurrentTheme('dark'))"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
<span class="flex-1 text-left">{ opt.Label }</span>
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
@Icon("check", "h-4 w-4")
</span>
@Icon("moon", "h-3.5 w-3.5 shrink-0")
<span>Dark</span>
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeDark)) }</span>
</button>
}
<button
type="button"
@click="themeMode = 'light'; $nextTick(() => revealCurrentTheme('light'))"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
@Icon("sun", "h-3.5 w-3.5 shrink-0")
<span>Light</span>
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeLight)) }</span>
</button>
</div>
<!-- Scrollable theme list (filtered by the mode above) -->
<div class="mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5">
for _, opt := range ThemeOptions {
<button
type="button"
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
x-show={ "themeMode === '" + string(opt.Mode) + "'" }
data-theme={ opt.Name }
data-mode={ string(opt.Mode) }
style="color: var(--text-secondary);"
>
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
<span class="flex-1 text-left">{ opt.Label }</span>
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
@Icon("check", "h-4 w-4")
</span>
</button>
}
</div>
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
for _, w := range WoodOptions {