BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: light theme

+696 -114
+137 -20
src/App.css
··· 21 21 --color-error-surface: var(--error-surface); 22 22 } 23 23 24 - :root { 25 - color-scheme: dark light; 24 + [data-theme="dark"] { 25 + color-scheme: dark; 26 26 --surface-container-lowest: #000000; 27 27 --surface: #0e0e0e; 28 28 --surface-container: #191919; 29 29 --surface-container-high: #1f1f1f; 30 - --surface-container-highest: rgb(36, 36, 36); 30 + --surface-container-highest: rgb(36 36 36 / 96%); 31 31 --surface-bright: rgba(255, 255, 255, 0.05); 32 32 --primary: #7dafff; 33 33 --primary-dim: #0073de; ··· 37 37 --on-secondary-container: #c9d1dd; 38 38 --error: #ff8080; 39 39 --error-surface: rgba(138, 31, 31, 0.72); 40 + --outline-subtle: rgba(255, 255, 255, 0.1); 41 + --outline-strong: rgba(255, 255, 255, 0.2); 42 + --control-bg: rgba(255, 255, 255, 0.04); 43 + --control-bg-hover: rgba(255, 255, 255, 0.08); 44 + --panel-muted: rgba(255, 255, 255, 0.03); 45 + --panel-muted-hover: rgba(255, 255, 255, 0.05); 46 + --input-bg: rgba(0, 0, 0, 0.4); 47 + --input-bg-strong: rgba(0, 0, 0, 0.5); 48 + --inset-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); 49 + --overlay-shadow: 0 24px 40px rgba(0, 0, 0, 0.28); 50 + --focus-ring: rgba(125, 175, 255, 0.45); 40 51 --font-stack: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 41 52 } 42 53 43 - @media (prefers-color-scheme: light) { 44 - :root { 45 - --surface-container-lowest: #f2f5ff; 46 - --surface: #ffffff; 47 - --surface-container: #ebeffb; 48 - --surface-container-high: #e1e8fa; 49 - --surface-container-highest: rgb(234, 241, 255); 50 - --surface-bright: rgba(24, 37, 66, 0.07); 51 - --on-surface: #0f1523; 52 - --on-surface-variant: #4e5e7e; 53 - --on-secondary-container: #334368; 54 - --error: #8f1f1f; 55 - --error-surface: rgba(255, 179, 179, 0.75); 56 - } 54 + [data-theme="light"] { 55 + color-scheme: light; 56 + --surface-container-lowest: #eef1f5; 57 + --surface: #ffffff; 58 + --surface-container: #f4f6f9; 59 + --surface-container-high: #eceff4; 60 + --surface-container-highest: rgb(246 248 252 / 96%); 61 + --surface-bright: rgba(17, 24, 39, 0.06); 62 + --primary: #0b63d1; 63 + --primary-dim: #0953af; 64 + --on-primary-fixed: #ffffff; 65 + --on-surface: #101418; 66 + --on-surface-variant: #45505e; 67 + --on-secondary-container: #263140; 68 + --error: #b42318; 69 + --error-surface: rgba(254, 226, 226, 0.95); 70 + --outline-subtle: rgba(17, 24, 39, 0.14); 71 + --outline-strong: rgba(17, 24, 39, 0.24); 72 + --control-bg: rgba(17, 24, 39, 0.06); 73 + --control-bg-hover: rgba(17, 24, 39, 0.11); 74 + --panel-muted: rgba(17, 24, 39, 0.04); 75 + --panel-muted-hover: rgba(17, 24, 39, 0.07); 76 + --input-bg: rgba(255, 255, 255, 0.95); 77 + --input-bg-strong: rgba(244, 246, 250, 0.96); 78 + --inset-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.1); 79 + --overlay-shadow: 0 24px 40px rgba(15, 23, 42, 0.18); 80 + --focus-ring: rgba(11, 99, 209, 0.45); 81 + } 82 + 83 + :root { 84 + color-scheme: dark; 85 + } 86 + 87 + html:not([data-theme]) { 88 + color-scheme: dark; 89 + --surface-container-lowest: #000000; 90 + --surface: #0e0e0e; 91 + --surface-container: #191919; 92 + --surface-container-high: #1f1f1f; 93 + --surface-container-highest: rgb(36 36 36 / 96%); 94 + --surface-bright: rgba(255, 255, 255, 0.05); 95 + --primary: #7dafff; 96 + --primary-dim: #0073de; 97 + --on-primary-fixed: #05080f; 98 + --on-surface: #f4f6fb; 99 + --on-surface-variant: #ababab; 100 + --on-secondary-container: #c9d1dd; 101 + --error: #ff8080; 102 + --error-surface: rgba(138, 31, 31, 0.72); 103 + --outline-subtle: rgba(255, 255, 255, 0.1); 104 + --outline-strong: rgba(255, 255, 255, 0.2); 105 + --control-bg: rgba(255, 255, 255, 0.04); 106 + --control-bg-hover: rgba(255, 255, 255, 0.08); 107 + --panel-muted: rgba(255, 255, 255, 0.03); 108 + --panel-muted-hover: rgba(255, 255, 255, 0.05); 109 + --input-bg: rgba(0, 0, 0, 0.4); 110 + --input-bg-strong: rgba(0, 0, 0, 0.5); 111 + --inset-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); 112 + --overlay-shadow: 0 24px 40px rgba(0, 0, 0, 0.28); 113 + --focus-ring: rgba(125, 175, 255, 0.45); 114 + --font-stack: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 57 115 } 58 116 59 117 * { ··· 74 132 } 75 133 76 134 @utility panel-surface { 77 - @apply rounded-2xl bg-white/3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]; 135 + @apply rounded-2xl bg-surface-container; 136 + box-shadow: var(--inset-shadow); 78 137 } 79 138 80 139 @utility pill-action { 81 140 @apply inline-flex min-h-12 items-center justify-center gap-2 rounded-full px-4 transition-transform duration-150 ease-out hover:-translate-y-px disabled:translate-y-0 disabled:cursor-wait disabled:opacity-70; 82 141 } 83 142 143 + @utility tone-muted { 144 + background: var(--panel-muted); 145 + } 146 + 147 + @utility tone-muted-hover { 148 + background: var(--panel-muted-hover); 149 + } 150 + 151 + @utility ui-control { 152 + @apply border-0 text-on-surface-variant transition duration-150 ease-out; 153 + background: var(--control-bg); 154 + box-shadow: var(--inset-shadow); 155 + } 156 + 157 + @utility ui-control-hoverable { 158 + @apply hover:-translate-y-px hover:text-on-surface; 159 + } 160 + 161 + .ui-control-hoverable:hover { 162 + background: var(--control-bg-hover); 163 + } 164 + 165 + @utility ui-outline-subtle { 166 + border-color: var(--outline-subtle); 167 + } 168 + 169 + @utility ui-outline-strong { 170 + border-color: var(--outline-strong); 171 + } 172 + 173 + @utility ui-input { 174 + @apply rounded-lg border px-3 py-2 text-sm text-on-surface outline-none transition; 175 + background: var(--input-bg); 176 + border-color: var(--outline-subtle); 177 + } 178 + 179 + .ui-input:focus { 180 + border-color: var(--focus-ring); 181 + } 182 + 183 + @utility ui-input-strong { 184 + background: var(--input-bg-strong); 185 + } 186 + 187 + @utility ui-button-secondary { 188 + @apply rounded-lg border px-4 py-2 text-sm font-medium text-on-surface transition; 189 + border-color: var(--outline-strong); 190 + } 191 + 192 + .ui-button-secondary:hover { 193 + background: var(--control-bg-hover); 194 + } 195 + 196 + @utility ui-overlay-card { 197 + background: var(--surface-container-highest); 198 + box-shadow: var(--overlay-shadow); 199 + } 200 + 84 201 .skeleton-block { 85 202 position: relative; 86 203 overflow: hidden; 87 - background: rgba(255, 255, 255, 0.05); 204 + background: var(--surface-bright); 88 205 } 89 206 90 207 .skeleton-block::after { ··· 92 209 position: absolute; 93 210 inset: 0; 94 211 transform: translateX(-100%); 95 - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.22), transparent); 212 + background: linear-gradient(90deg, transparent, var(--control-bg-hover), transparent); 96 213 animation: shimmer 1.5s linear infinite; 97 214 } 98 215
+2
src/App.tsx
··· 19 19 import { SessionSpotlight } from "./components/Session"; 20 20 import { ErrorToast } from "./components/shared/ErrorToast"; 21 21 import { AppPreferencesProvider } from "./contexts/app-preferences"; 22 + import { ThemeController } from "./components/theme/ThemeController"; 22 23 import { AppSessionProvider, useAppSession } from "./contexts/app-session"; 23 24 import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; 24 25 import { AppRouter } from "./router"; ··· 128 129 return ( 129 130 <AppSessionProvider> 130 131 <AppPreferencesProvider> 132 + <ThemeController /> 131 133 <AppShellUiProvider> 132 134 <AppContent /> 133 135 </AppShellUiProvider>
+2 -1
src/components/Wordmark.tsx
··· 14 14 class="flex items-center gap-3" 15 15 classList={{ "flex-col gap-2 text-center": !!props.compact, [props.class ?? ""]: !!props.class }}> 16 16 <span 17 - class="grid shrink-0 place-items-center rounded-xl bg-white/4 p-3 text-primary shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" 17 + class="grid shrink-0 place-items-center rounded-xl p-3 text-primary" 18 + style={{ background: "var(--control-bg)", "box-shadow": "var(--inset-shadow)" }} 18 19 classList={{ [props.iconClass ?? ""]: !!props.iconClass }} 19 20 aria-hidden="true"> 20 21 <LazuriteLogo class="h-9 w-9" />
+4 -4
src/components/account/AccountSwitcher.tsx
··· 73 73 classList={{ 74 74 "z-40": shell.showSwitcher, 75 75 "w-auto": compact(), 76 - "max-[1180px]:col-start-4 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 76 + "max-[1180px]:col-start-5 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 77 77 "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !shell.narrowViewport, 78 78 }} 79 79 ref={(element) => { 80 80 container = element; 81 81 }}> 82 82 <button 83 - class="relative w-full min-w-0 cursor-pointer border-0 bg-white/4 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 83 + class="ui-control ui-control-hoverable relative w-full min-w-0 cursor-pointer text-on-surface" 84 84 classList={{ 85 85 "rounded-xl py-[0.95rem] pr-10 pl-4": !compact(), 86 86 "grid h-14 w-14 place-items-center overflow-visible rounded-full p-0": compact(), ··· 95 95 class="absolute flex items-center justify-center text-on-surface-variant" 96 96 classList={{ 97 97 "right-[0.95rem] top-1/2 -translate-y-1/2": !compact(), 98 - "bottom-0 right-0 h-5 w-5 translate-x-[8%] translate-y-[8%] rounded-full bg-surface-container text-[0.7rem] leading-none shadow-[0_0_0_2px_rgba(8,8,8,0.9),inset_0_0_0_1px_rgba(255,255,255,0.05)]": 98 + "bottom-0 right-0 h-5 w-5 translate-x-[8%] translate-y-[8%] rounded-full bg-surface-container text-[0.7rem] leading-none shadow-[0_0_0_2px_var(--surface-container-lowest),inset_0_0_0_1px_var(--outline-subtle)]": 99 99 compact(), 100 100 }} 101 101 aria-hidden="true"> ··· 107 107 108 108 <Show when={shell.showSwitcher}> 109 109 <div 110 - class="absolute z-50 rounded-2xl bg-surface-container-highest p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 110 + class="ui-overlay-card absolute z-50 rounded-2xl border ui-outline-subtle bg-surface-container-highest p-4 backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 111 111 classList={{ 112 112 "inset-x-0 bottom-[calc(100%+0.75rem)]": !compact(), 113 113 "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": compact() && !shell.narrowViewport,
+44 -1
src/components/rail/AppRail.test.tsx
··· 5 5 import { AppRail } from "./AppRail"; 6 6 7 7 const openUrlMock = vi.hoisted(() => vi.fn()); 8 + const updateSettingMock = vi.hoisted(() => vi.fn()); 8 9 9 10 vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: openUrlMock })); 10 11 11 - function renderRail() { 12 + function renderRail(options: { preferences?: Record<string, unknown>; shell?: Record<string, unknown> } = {}) { 12 13 globalThis.location.hash = "#/timeline"; 13 14 14 15 return render(() => ( 15 16 <AppTestProviders 17 + preferences={{ updateSetting: updateSettingMock, ...options.preferences }} 18 + shell={options.shell} 16 19 session={{ 17 20 activeDid: "did:plc:alice", 18 21 activeHandle: "alice.test", ··· 29 32 describe("AppRail", () => { 30 33 beforeEach(() => { 31 34 openUrlMock.mockReset(); 35 + updateSettingMock.mockReset(); 32 36 }); 33 37 34 38 it("renders the saved navigation link", async () => { ··· 64 68 65 69 await waitFor(() => expect(openUrlMock).toHaveBeenCalledWith("https://github.com/sponsors/desertthunder")); 66 70 expect(screen.queryByRole("link", { name: "Support" })).not.toBeInTheDocument(); 71 + }); 72 + 73 + it("shows the theme menu trigger when enabled", async () => { 74 + renderRail(); 75 + 76 + expect(await screen.findByRole("button", { name: "Theme menu" })).toBeInTheDocument(); 77 + }); 78 + 79 + it("hides the theme menu trigger when disabled in shell preferences", async () => { 80 + renderRail({ shell: { showThemeRailControl: false } }); 81 + 82 + await screen.findByRole("link", { name: "Timeline" }); 83 + expect(screen.queryByRole("button", { name: "Theme menu" })).not.toBeInTheDocument(); 84 + }); 85 + 86 + it("keeps the theme menu trigger visible on narrow viewports", async () => { 87 + renderRail({ shell: { narrowViewport: true, railCondensed: true } }); 88 + 89 + expect(await screen.findByRole("button", { name: "Theme menu" })).toBeInTheDocument(); 90 + expect(await screen.findByRole("button", { name: "More navigation" })).toBeInTheDocument(); 91 + }); 92 + 93 + it("uses overflow navigation when desktop rail is collapsed", async () => { 94 + renderRail({ shell: { railCollapsed: true, railCondensed: true } }); 95 + 96 + expect(await screen.findByRole("button", { name: "More navigation" })).toBeInTheDocument(); 97 + expect(screen.queryByRole("link", { name: "Saved" })).not.toBeInTheDocument(); 98 + expect(screen.queryByRole("button", { name: "Support" })).not.toBeInTheDocument(); 99 + }); 100 + 101 + it("updates the persisted theme from the rail theme menu", async () => { 102 + renderRail({ preferences: { settings: { theme: "auto" } } }); 103 + 104 + fireEvent.click(await screen.findByRole("button", { name: "Theme menu" })); 105 + const darkThemeOption = await screen.findByRole("menuitemradio", { name: "Dark" }); 106 + fireEvent.mouseDown(darkThemeOption); 107 + fireEvent.click(darkThemeOption); 108 + 109 + await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("theme", "dark")); 67 110 }); 68 111 });
+162 -25
src/components/rail/AppRail.tsx
··· 1 + import { useAppPreferences } from "$/contexts/app-preferences"; 1 2 import { useAppSession } from "$/contexts/app-session"; 2 3 import { useAppShellUi } from "$/contexts/app-shell-ui"; 4 + import { normalizeThemeSetting } from "$/lib/theme"; 5 + import type { Theme } from "$/lib/types"; 3 6 import { useLocation, useNavigate } from "@solidjs/router"; 4 7 import { openUrl } from "@tauri-apps/plugin-opener"; 5 - import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; 8 + import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 6 9 import { AccountSwitcher } from "../account/AccountSwitcher"; 7 - import { ArrowIcon, RailFoldIcon } from "../shared/Icon"; 10 + import { ArrowIcon, Icon, RailFoldIcon } from "../shared/Icon"; 8 11 import { Wordmark } from "../Wordmark"; 9 12 import { RailActionButton, RailButton } from "./AppRailButton"; 10 13 ··· 18 21 19 22 <div class="max-[1180px]:hidden"> 20 23 <button 21 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface" 24 + class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full" 22 25 type="button" 23 26 aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 24 27 aria-pressed={props.collapsed} ··· 90 93 return { canGoBack, canGoForward, goBack, goForward }; 91 94 } 92 95 93 - function OverflowMenuButton(props: { hasSession: boolean; unreadNotifications: number }) { 96 + function OverflowMenuButton(props: { hasSession: boolean }) { 94 97 const [open, setOpen] = createSignal(false); 95 - const [menuPos, setMenuPos] = createSignal({ top: 0, right: 0 }); 98 + const [menuPos, setMenuPos] = createSignal({ top: 0, left: 0 }); 96 99 const location = useLocation(); 100 + let containerRef: HTMLDivElement | undefined; 97 101 let buttonRef: HTMLButtonElement | undefined; 98 102 99 103 const isOverflowActive = createMemo(() => ··· 106 110 }); 107 111 108 112 function onOutsideClick(e: MouseEvent) { 109 - if (buttonRef && !buttonRef.contains(e.target as Node)) { 113 + if (containerRef && !containerRef.contains(e.target as Node)) { 110 114 setOpen(false); 111 115 } 112 116 } ··· 126 130 function handleToggle() { 127 131 if (!open() && buttonRef) { 128 132 const rect = buttonRef.getBoundingClientRect(); 129 - setMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right }); 133 + const preferredLeft = rect.left; 134 + const maxLeft = window.innerWidth - 208; 135 + setMenuPos({ top: rect.bottom + 8, left: Math.max(8, Math.min(preferredLeft, maxLeft)) }); 130 136 } 131 137 setOpen((v) => !v); 132 138 } 133 139 134 140 return ( 135 - <div> 141 + <div ref={el => (containerRef = el)}> 136 142 <button 137 143 ref={el => (buttonRef = el)} 138 144 type="button" ··· 149 155 <Show when={open()}> 150 156 <div 151 157 role="menu" 152 - style={{ position: "fixed", top: `${menuPos().top}px`, right: `${menuPos().right}px` }} 153 - class="z-50 min-w-48 rounded-xl border border-white/8 bg-surface-container p-1.5 shadow-2xl"> 158 + style={{ position: "fixed", top: `${menuPos().top}px`, left: `${menuPos().left}px` }} 159 + class="ui-overlay-card z-50 min-w-48 rounded-xl border ui-outline-subtle bg-surface-container p-1.5 backdrop-blur-[20px]"> 154 160 <Show when={props.hasSession}> 155 161 <RailButton end compact={false} href="/saved" label="Saved" icon="bookmark" /> 156 162 <RailButton end compact={false} href="/deck" label="Deck" icon="deck" /> 157 163 <RailButton end compact={false} href="/explorer" label="AT Explorer" icon="explorer" /> 158 164 <RailButton end compact={false} href="/settings" label="Settings" icon="settings" /> 159 - <hr class="my-1 border-white/8" /> 165 + <hr class="my-1 border ui-outline-subtle" /> 160 166 <RailActionButton 161 - compact={false} 167 + compact 162 168 icon="heart" 163 169 label="Support" 164 170 onClick={() => void openUrl("https://github.com/sponsors/desertthunder")} /> ··· 172 178 function RailNavigation( 173 179 props: { collapsed: boolean; hasSession: boolean; narrow: boolean; unreadNotifications: number }, 174 180 ) { 181 + const useOverflowMenu = () => props.narrow || props.collapsed; 182 + 175 183 return ( 176 184 <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 177 185 <Show ··· 180 188 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 181 189 <RailButton compact={props.collapsed} href="/profile" label="Profile" icon="profile" /> 182 190 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 183 - <Show when={!props.narrow}> 191 + <Show when={!useOverflowMenu()}> 184 192 <RailButton end compact={props.collapsed} href="/saved" label="Saved" icon="bookmark" /> 185 193 </Show> 186 194 <RailButton ··· 191 199 label="Notifications" 192 200 icon="notifications" /> 193 201 <RailButton end compact={props.collapsed} href="/messages" label="Messages" icon="messages" /> 194 - <Show when={!props.narrow}> 195 - <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 196 - <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 197 - <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 202 + <Show 203 + when={useOverflowMenu()} 204 + fallback={ 205 + <> 206 + <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 207 + <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 208 + <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 209 + </> 210 + }> 211 + <OverflowMenuButton hasSession={props.hasSession} /> 198 212 </Show> 199 - <Show when={props.narrow}> 200 - <OverflowMenuButton hasSession={props.hasSession} unreadNotifications={props.unreadNotifications} /> 213 + </Show> 214 + </div> 215 + ); 216 + } 217 + 218 + const RAIL_THEME_OPTIONS: Array<{ value: Theme; label: string; iconClass: string }> = [ 219 + { value: "auto", label: "System", iconClass: "i-ri-computer-line" }, 220 + { value: "light", label: "Light", iconClass: "i-ri-sun-line" }, 221 + { value: "dark", label: "Dark", iconClass: "i-ri-moon-clear-line" }, 222 + ]; 223 + 224 + function iconClassForTheme(theme: Theme) { 225 + return RAIL_THEME_OPTIONS.find((option) => option.value === theme)?.iconClass ?? "i-ri-computer-line"; 226 + } 227 + 228 + function RailThemeMenu( 229 + props: { collapsed: boolean; currentTheme: Theme; onChangeTheme: (theme: Theme) => Promise<void> }, 230 + ) { 231 + const [open, setOpen] = createSignal(false); 232 + const [menuPos, setMenuPos] = createSignal({ top: 0, left: 0 }); 233 + const compact = () => props.collapsed; 234 + let containerRef: HTMLDivElement | undefined; 235 + let buttonRef: HTMLButtonElement | undefined; 236 + 237 + function closeMenu() { 238 + setOpen(false); 239 + } 240 + 241 + function onOutsideClick(event: MouseEvent) { 242 + if (containerRef && !containerRef.contains(event.target as Node)) { 243 + closeMenu(); 244 + } 245 + } 246 + 247 + function onResize() { 248 + closeMenu(); 249 + } 250 + 251 + onMount(() => { 252 + document.addEventListener("mousedown", onOutsideClick); 253 + window.addEventListener("resize", onResize); 254 + 255 + onCleanup(() => { 256 + document.removeEventListener("mousedown", onOutsideClick); 257 + window.removeEventListener("resize", onResize); 258 + }); 259 + }); 260 + 261 + function handleToggle() { 262 + if (!open() && buttonRef) { 263 + const rect = buttonRef.getBoundingClientRect(); 264 + const preferredLeft = rect.left; 265 + const maxLeft = window.innerWidth - 176; 266 + setMenuPos({ top: rect.bottom + 8, left: Math.max(8, Math.min(preferredLeft, maxLeft)) }); 267 + } 268 + 269 + setOpen((value) => !value); 270 + } 271 + 272 + async function handleSelect(theme: Theme) { 273 + await props.onChangeTheme(theme); 274 + closeMenu(); 275 + } 276 + 277 + return ( 278 + <div 279 + ref={el => (containerRef = el)} 280 + class="flex relative max-[1180px]:col-start-4 max-[1180px]:row-start-1" 281 + classList={{ "justify-center": compact() }}> 282 + <button 283 + ref={el => (buttonRef = el)} 284 + type="button" 285 + aria-label="Theme menu" 286 + aria-expanded={open()} 287 + aria-haspopup="menu" 288 + onClick={handleToggle} 289 + class="relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 290 + classList={{ 291 + "w-[2.75rem] justify-center": compact(), 292 + "px-3": !compact(), 293 + "bg-surface-container text-primary": open(), 294 + }}> 295 + <Icon iconClass={iconClassForTheme(props.currentTheme)} class="shrink-0 text-[1.25rem]" /> 296 + <Show when={!compact()}> 297 + <span class="text-sm font-medium leading-none">Theme</span> 201 298 </Show> 299 + </button> 300 + 301 + <Show when={open()}> 302 + <div 303 + role="menu" 304 + style={{ position: "fixed", top: `${menuPos().top}px`, left: `${menuPos().left}px` }} 305 + class="ui-overlay-card z-50 min-w-40 rounded-xl border ui-outline-subtle bg-surface-container p-1.5 backdrop-blur-[20px]"> 306 + <For each={RAIL_THEME_OPTIONS}> 307 + {(option) => ( 308 + <button 309 + type="button" 310 + role="menuitemradio" 311 + aria-checked={props.currentTheme === option.value} 312 + class="flex w-full items-center gap-2 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-sm transition duration-150" 313 + classList={{ 314 + "bg-surface-bright text-primary": props.currentTheme === option.value, 315 + "text-on-surface-variant hover:bg-surface-bright hover:text-on-surface": 316 + props.currentTheme !== option.value, 317 + }} 318 + onClick={() => void handleSelect(option.value)}> 319 + <Icon iconClass={option.iconClass} /> 320 + <span>{option.label}</span> 321 + </button> 322 + )} 323 + </For> 324 + </div> 202 325 </Show> 203 326 </div> 204 327 ); ··· 230 353 class="flex items-center gap-1 max-[1180px]:col-start-3 max-[1180px]:row-start-1" 231 354 classList={{ "justify-self-center": props.collapsed }}> 232 355 <button 233 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 356 + class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45" 234 357 type="button" 235 358 aria-label="Back" 236 359 disabled={!props.canGoBack} ··· 239 362 </button> 240 363 241 364 <button 242 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 365 + class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45" 243 366 type="button" 244 367 aria-label="Forward" 245 368 disabled={!props.canGoForward} ··· 251 374 } 252 375 253 376 export function AppRail() { 377 + const preferences = useAppPreferences(); 254 378 const session = useAppSession(); 255 379 const shell = useAppShellUi(); 256 380 const history = useShellHistoryTracker(); 381 + const currentTheme = createMemo(() => normalizeThemeSetting(preferences.settings?.theme)); 382 + 383 + async function handleChangeTheme(theme: Theme) { 384 + await preferences.updateSetting("theme", theme); 385 + } 257 386 258 387 return ( 259 388 <aside 260 - class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 389 + class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto_auto_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 261 390 classList={{ 262 391 "items-center px-4": shell.railCondensed && !shell.narrowViewport, 263 392 "gap-5": shell.railCondensed && !shell.narrowViewport, ··· 269 398 hasSession={session.hasSession} 270 399 narrow={shell.narrowViewport} 271 400 unreadNotifications={session.unreadNotifications} /> 272 - <div class="mt-auto grid gap-3 max-[1180px]:contents"> 273 - <RailSecondaryActions collapsed={shell.railCondensed} /> 274 - <AccountSwitcher /> 401 + <div class="mt-auto grid gap-2 max-[1180px]:contents"> 402 + <Show when={!shell.railCondensed}> 403 + <RailSecondaryActions collapsed={shell.railCondensed} /> 404 + </Show> 405 + <Show when={shell.showThemeRailControl}> 406 + <RailThemeMenu 407 + collapsed={shell.railCondensed} 408 + currentTheme={currentTheme()} 409 + onChangeTheme={handleChangeTheme} /> 410 + </Show> 275 411 <RailHistoryControls 276 412 canGoBack={history.canGoBack()} 277 413 canGoForward={history.canGoForward()} 278 414 collapsed={shell.railCondensed} 279 415 onGoBack={history.goBack} 280 416 onGoForward={history.goForward} /> 417 + <AccountSwitcher /> 281 418 </div> 282 419 </aside> 283 420 );
+2 -5
src/components/rail/AppRailButton.tsx
··· 34 34 ); 35 35 } 36 36 37 - const railButtonClass = 38 - "relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"; 39 - 40 37 export function RailButton(props: RailButtonProps) { 41 38 return ( 42 39 <A 43 40 href={props.href} 44 41 end={props.end} 45 - class={railButtonClass} 42 + class={"relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"} 46 43 activeClass="bg-surface-container text-primary" 47 44 inactiveClass="" 48 45 classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} ··· 57 54 return ( 58 55 <button 59 56 type="button" 60 - class={railButtonClass} 57 + class={"relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"} 61 58 classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 62 59 aria-label={props.label} 63 60 title={props.label}
+2 -2
src/components/settings/SettingsAbout.tsx
··· 26 26 <button 27 27 type="button" 28 28 onClick={() => void openUrl("https://github.com/stormlightlabs/lazurite/blob/main/LICENSE")} 29 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 29 + class="ui-button-secondary"> 30 30 View license 31 31 </button> 32 32 </div> ··· 38 38 <button 39 39 type="button" 40 40 onClick={() => void openUrl("https://github.com/stormlightlabs/lazurite")} 41 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 41 + class="ui-button-secondary"> 42 42 Open 43 43 </button> 44 44 </div>
+3 -3
src/components/settings/SettingsAccount.tsx
··· 7 7 8 8 function AccountItem(props: { account: AccountSummary; active: boolean; onRemove: () => void; onSwitch: () => void }) { 9 9 return ( 10 - <div class="flex items-center justify-between rounded-xl bg-white/3 p-3 transition-colors hover:bg-white/5"> 10 + <div class="tone-muted flex items-center justify-between rounded-xl p-3 transition-colors hover:bg-[var(--panel-muted-hover)]"> 11 11 <div class="flex items-center gap-3"> 12 12 <div class="relative"> 13 13 <div class="h-10 w-10 overflow-hidden rounded-full"> ··· 37 37 <button 38 38 type="button" 39 39 onClick={() => props.onSwitch()} 40 - class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 40 + class="ui-button-secondary px-3 py-1.5 text-xs"> 41 41 Switch 42 42 </button> 43 43 }> ··· 46 46 <button 47 47 type="button" 48 48 onClick={() => props.onRemove()} 49 - class="rounded-lg p-2 text-on-surface-variant transition hover:bg-white/5 hover:text-on-surface" 49 + class="rounded-lg p-2 text-on-surface-variant transition hover:bg-surface-bright hover:text-on-surface" 50 50 title="Remove account"> 51 51 <span class="flex items-center"> 52 52 <Icon kind="close" class="text-sm" />
+1 -1
src/components/settings/SettingsDangerZone.tsx
··· 23 23 This removes every local account, cache entry, saved setting, and synced record, then restarts Lazurite. 24 24 </p> 25 25 </div> 26 - <div class="flex items-center justify-between gap-4 rounded-2xl bg-black/30 p-4"> 26 + <div class="ui-input-strong flex items-center justify-between gap-4 rounded-2xl border p-4 ui-outline-subtle"> 27 27 <div> 28 28 <p class="text-sm font-medium text-red-300">Reset application</p> 29 29 <p class="text-xs text-on-surface-variant">Return Lazurite to a clean install state.</p>
+7 -7
src/components/settings/SettingsData.tsx
··· 93 93 94 94 function CacheTile(props: { label: string; value: string }) { 95 95 return ( 96 - <div class="rounded-xl bg-black/30 p-4 text-center"> 96 + <div class="ui-input-strong rounded-xl border p-4 text-center ui-outline-subtle"> 97 97 <p class="text-lg font-medium text-on-surface">{props.value}</p> 98 98 <p class="text-xs text-on-surface-variant">{props.label}</p> 99 99 </div> ··· 114 114 type="button" 115 115 disabled={props.busy} 116 116 onClick={() => void props.onClear("feeds")} 117 - class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 117 + class="ui-button-secondary flex-1 disabled:cursor-wait disabled:opacity-60"> 118 118 {props.pending("feeds") ? "Clearing..." : "Clear feeds"} 119 119 </button> 120 120 <button 121 121 type="button" 122 122 disabled={props.busy} 123 123 onClick={() => void props.onClear("embeddings")} 124 - class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 124 + class="ui-button-secondary flex-1 disabled:cursor-wait disabled:opacity-60"> 125 125 {props.pending("embeddings") ? "Clearing..." : "Clear embeddings"} 126 126 </button> 127 127 <button 128 128 type="button" 129 129 disabled={props.busy} 130 130 onClick={() => void props.onClear("fts")} 131 - class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 131 + class="ui-button-secondary flex-1 disabled:cursor-wait disabled:opacity-60"> 132 132 {props.pending("fts") ? "Clearing..." : "Clear search index"} 133 133 </button> 134 134 <button ··· 153 153 props: { busy: boolean; pending: PendingCheck; onExport: (format: "json" | "csv") => Promise<void> }, 154 154 ) { 155 155 return ( 156 - <div class="border-t border-white/10 pt-4"> 156 + <div class="border-t pt-4 ui-outline-subtle"> 157 157 <div class="flex items-center justify-between"> 158 158 <ExportDescription /> 159 159 <div class="flex gap-2"> ··· 161 161 type="button" 162 162 disabled={props.busy} 163 163 onClick={() => void props.onExport("json")} 164 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 164 + class="ui-button-secondary disabled:cursor-wait disabled:opacity-60"> 165 165 {props.pending("json") ? "Exporting..." : "JSON"} 166 166 </button> 167 167 <button 168 168 type="button" 169 169 disabled={props.busy} 170 170 onClick={() => void props.onExport("csv")} 171 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 171 + class="ui-button-secondary disabled:cursor-wait disabled:opacity-60"> 172 172 {props.pending("csv") ? "Exporting..." : "CSV"} 173 173 </button> 174 174 </div>
+2 -2
src/components/settings/SettingsDownloads.tsx
··· 94 94 readOnly 95 95 value={directory()} 96 96 placeholder="Loading download folder..." 97 - class="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none" /> 97 + class="ui-input min-w-0 flex-1" /> 98 98 <button 99 99 type="button" 100 100 disabled={pending()} 101 101 onClick={() => void browseForDirectory()} 102 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 102 + class="ui-button-secondary disabled:cursor-wait disabled:opacity-60"> 103 103 {pending() ? "Saving..." : "Browse"} 104 104 </button> 105 105 </div>
+5 -5
src/components/settings/SettingsLogs.tsx
··· 27 27 <button 28 28 type="button" 29 29 onClick={() => props.onClick()} 30 - class="flex items-center justify-between rounded-lg bg-black/40 px-4 py-2 text-sm text-on-surface transition hover:bg-black/50"> 30 + class="ui-input-strong flex items-center justify-between rounded-lg border px-4 py-2 text-sm text-on-surface transition hover:bg-surface-bright ui-outline-subtle"> 31 31 <Show 32 32 when={props.expanded} 33 33 fallback={ ··· 53 53 animate={{ height: "auto" }} 54 54 exit={{ height: 0 }} 55 55 transition={{ duration: 0.2 }}> 56 - <div class="max-h-64 overflow-y-auto rounded-xl bg-black/50 p-4 font-mono text-xs"> 56 + <div class="ui-input-strong max-h-64 overflow-y-auto rounded-xl border p-4 font-mono text-xs ui-outline-subtle"> 57 57 <For each={props.logs} fallback={<p class="text-on-surface-variant">No log entries found</p>}> 58 58 {(log) => ( 59 - <div class="mb-2 grid gap-2 rounded-xl bg-white/3 px-3 py-2 md:grid-cols-[auto_auto_auto_minmax(0,1fr)] md:items-start"> 59 + <div class="tone-muted mb-2 grid gap-2 rounded-xl px-3 py-2 md:grid-cols-[auto_auto_auto_minmax(0,1fr)] md:items-start"> 60 60 <span class="text-on-surface-variant">{formatLogTimestamp(log.timestamp)}</span> 61 61 <span 62 62 class="font-semibold" ··· 94 94 onClick={() => { 95 95 void navigator.clipboard.writeText(output()); 96 96 }} 97 - class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 97 + class="ui-button-secondary px-3 py-1.5 text-xs"> 98 98 Copy all 99 99 </button> 100 100 <button 101 101 type="button" 102 102 onClick={() => void props.loadLogs()} 103 - class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 103 + class="ui-button-secondary px-3 py-1.5 text-xs"> 104 104 Refresh 105 105 </button> 106 106 </div>
+10 -10
src/components/settings/SettingsModeration.tsx
··· 44 44 <div class="flex flex-wrap items-center gap-2"> 45 45 <select 46 46 value={props.visibility} 47 - class="rounded-lg border border-white/10 bg-black/35 px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50" 47 + class="rounded-lg border ui-outline-subtle ui-input-strong px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50" 48 48 onInput={(event) => props.onVisibilityChange(event.currentTarget.value as ModerationLabelVisibility)}> 49 49 <VisibilityOptions /> 50 50 </select> ··· 323 323 324 324 <For each={effectiveLabelers()}> 325 325 {(did) => ( 326 - <div class="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-black/25 px-3 py-2"> 326 + <div class="flex flex-wrap items-center justify-between gap-2 rounded-xl ui-input-strong px-3 py-2"> 327 327 <div class="grid gap-0.5"> 328 328 <span class="text-xs font-medium text-on-surface">{getLabelerTitle(did)}</span> 329 329 <Show when={getLabelerSubtitle(did)}> ··· 334 334 <button 335 335 type="button" 336 336 disabled={busyLabelerDid() === did} 337 - class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5 disabled:opacity-70" 337 + class="rounded-lg border ui-outline-strong px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-bright disabled:opacity-70" 338 338 onClick={() => void removeLabeler(did)}> 339 339 {busyLabelerDid() === did ? "Removing..." : "Remove"} 340 340 </button> ··· 343 343 )} 344 344 </For> 345 345 346 - <div class="grid gap-2 rounded-xl bg-black/20 p-3"> 346 + <div class="grid gap-2 rounded-xl tone-muted p-3"> 347 347 <label class="grid gap-1"> 348 348 <span class="text-xs text-on-surface-variant">Add labeler DID</span> 349 349 <input 350 350 type="text" 351 351 value={draft.addLabelerDid} 352 352 placeholder="did:plc:..." 353 - class="rounded-lg border border-white/10 bg-black/35 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" 353 + class="rounded-lg border ui-outline-subtle ui-input-strong px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" 354 354 onInput={(event) => setDraft("addLabelerDid", event.currentTarget.value)} /> 355 355 </label> 356 356 <button ··· 376 376 const entries = () => labelEntries(did); 377 377 378 378 return ( 379 - <details class="rounded-xl bg-black/25 px-3 py-2" open> 379 + <details class="rounded-xl ui-input-strong px-3 py-2" open> 380 380 <summary class="cursor-pointer select-none text-xs font-medium text-on-surface">{did}</summary> 381 381 <div class="mt-3 grid gap-2"> 382 382 <Show ··· 388 388 const gated = !current().adultContentEnabled && isAdultOnlyLabel(did, label); 389 389 const displayName = getLabelDisplayName(did, label); 390 390 return ( 391 - <div class="grid gap-1 rounded-lg bg-black/30 px-3 py-2"> 391 + <div class="grid gap-1 rounded-lg ui-input-strong px-3 py-2"> 392 392 <span class="text-xs text-on-surface">{displayName}</span> 393 393 <Show when={displayName !== label}> 394 394 <span class="text-[0.7rem] text-on-surface-variant">Identifier: {label}</span> ··· 407 407 <select 408 408 value={visibility} 409 409 disabled={gated} 410 - class="rounded-lg border border-white/10 bg-black/35 px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50 disabled:opacity-60" 410 + class="rounded-lg border ui-outline-subtle ui-input-strong px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50 disabled:opacity-60" 411 411 onInput={(event) => 412 412 void updateLabelPreference( 413 413 did, ··· 428 428 </For> 429 429 </Show> 430 430 431 - <div class="grid gap-2 rounded-lg bg-black/30 p-2"> 431 + <div class="grid gap-2 rounded-lg ui-input-strong p-2"> 432 432 <input 433 433 type="text" 434 434 value={draft.addLabelNameByDid[did] ?? ""} 435 435 placeholder="label identifier (for example: sexual)" 436 - class="rounded-lg border border-white/10 bg-black/35 px-3 py-1.5 text-xs text-on-surface outline-none transition focus:border-primary/50" 436 + class="rounded-lg border ui-outline-subtle ui-input-strong px-3 py-1.5 text-xs text-on-surface outline-none transition focus:border-primary/50" 437 437 onInput={(event) => setDraft("addLabelNameByDid", did, event.currentTarget.value)} /> 438 438 <LabelOverrideDraftEditor 439 439 canAdd={!!(draft.addLabelNameByDid[did] ?? "").trim()}
+15 -2
src/components/settings/SettingsPanel.test.tsx
··· 111 111 } 112 112 113 113 function renderSettingsPanel( 114 - options: { preferences?: Record<string, unknown>; session?: Record<string, unknown> } = {}, 114 + options: { preferences?: Record<string, unknown>; session?: Record<string, unknown>; shell?: Record<string, unknown> } = {}, 115 115 ) { 116 116 render(() => ( 117 117 <AppTestProviders ··· 121 121 updateSetting: updateSettingMock, 122 122 ...options.preferences, 123 123 }} 124 - session={options.session}> 124 + session={options.session} 125 + shell={options.shell}> 125 126 <SettingsPanel /> 126 127 </AppTestProviders> 127 128 )); ··· 225 226 226 227 fireEvent.click(darkButton); 227 228 await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("theme", "dark")); 229 + }); 230 + 231 + it("allows toggling rail theme control visibility", async () => { 232 + const setShowThemeRailControl = vi.fn(); 233 + 234 + renderSettingsPanel({ shell: { showThemeRailControl: true, setShowThemeRailControl } }); 235 + 236 + await screen.findByText("Settings"); 237 + const toggle = await screen.findByRole("switch", { name: /show theme control in app rail/i }); 238 + 239 + fireEvent.click(toggle); 240 + expect(setShowThemeRailControl).toHaveBeenCalledWith(false); 228 241 }); 229 242 230 243 it("allows changing refresh interval", async () => {
+17 -10
src/components/settings/SettingsPanel.tsx
··· 1 1 import { EmbeddingsSettings } from "$/components/search/EmbeddingsSettings"; 2 2 import { useAppPreferences } from "$/contexts/app-preferences"; 3 + import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 4 import { SettingsController } from "$/lib/api/settings"; 4 5 import type { 5 6 AppSettings, ··· 69 70 exit={{ opacity: 0 }} 70 71 transition={{ duration: 0.2 }}> 71 72 <Motion.div 72 - class="w-full max-w-md rounded-2xl bg-surface-container p-6 shadow-2xl" 73 + class="w-full max-w-md rounded-2xl bg-surface-container p-6" 74 + style={{ "box-shadow": "var(--overlay-shadow)" }} 73 75 initial={{ scale: 0.95, opacity: 0 }} 74 76 animate={{ scale: 1, opacity: 1 }} 75 77 exit={{ scale: 0.95, opacity: 0 }} ··· 104 106 value={props.value} 105 107 onInput={(e) => props.handleInput(e.currentTarget.value)} 106 108 placeholder={`Type "${props.confirmText}" to confirm`} 107 - class="mb-4 w-full rounded-lg border border-white/10 bg-black/40 px-4 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 109 + class="ui-input mb-4 w-full px-4 py-2" /> 108 110 </Show> 109 111 ); 110 112 } ··· 117 119 <button 118 120 type="button" 119 121 onClick={() => props.onCancel()} 120 - class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 122 + class="ui-button-secondary"> 121 123 Cancel 122 124 </button> 123 125 <button ··· 142 144 {() => ( 143 145 <div class="panel-surface animate-pulse p-5"> 144 146 <div class="mb-4 flex items-center gap-3"> 145 - <div class="h-6 w-6 rounded-full bg-white/5" /> 146 - <div class="h-5 w-24 rounded-full bg-white/5" /> 147 + <div class="h-6 w-6 rounded-full tone-muted" /> 148 + <div class="h-5 w-24 rounded-full tone-muted" /> 147 149 </div> 148 150 <div class="grid gap-3"> 149 - <div class="h-10 rounded-lg bg-white/5" /> 150 - <div class="h-10 rounded-lg bg-white/5" /> 151 + <div class="h-10 rounded-lg tone-muted" /> 152 + <div class="h-10 rounded-lg tone-muted" /> 151 153 </div> 152 154 </div> 153 155 )} ··· 166 168 167 169 export function SettingsPanel() { 168 170 const preferences = useAppPreferences(); 171 + const shell = useAppShellUi(); 169 172 const navigate = useNavigate(); 170 173 const [panel, setPanel] = createStore<SettingsPanelState>({ 171 174 cacheSize: null, ··· 253 256 }); 254 257 255 258 return ( 256 - <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 259 + <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[var(--inset-shadow)]"> 257 260 <header class="grid gap-5 px-6 pb-4 pt-6"> 258 261 <div class="flex items-center justify-between gap-4"> 259 262 <div class="grid gap-1"> ··· 263 266 <button 264 267 type="button" 265 268 onClick={() => navigate(-1)} 266 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface" 269 + class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full" 267 270 title="Close settings"> 268 271 <Icon kind="close" aria-hidden="true" class="text-lg" /> 269 272 </button> ··· 281 284 initial={{ opacity: 0, y: 20 }} 282 285 animate={{ opacity: 1, y: 0 }} 283 286 transition={{ duration: 0.3 }}> 284 - <AppearanceControl currentTheme={currentTheme()} handleUpdateSetting={handleUpdateSetting} /> 287 + <AppearanceControl 288 + currentTheme={currentTheme()} 289 + handleUpdateSetting={handleUpdateSetting} 290 + showThemeRailControl={shell.showThemeRailControl} 291 + setShowThemeRailControl={shell.setShowThemeRailControl} /> 285 292 <TimelineControl currentRefresh={currentRefresh()} handleUpdateSetting={handleUpdateSetting} /> 286 293 <NotificationsControl settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 287 294 <SettingsModeration />
+2 -2
src/components/settings/SettingsService.tsx
··· 22 22 type="text" 23 23 value={constellationUrl()} 24 24 onChange={(e) => void props.handleUpdateSetting("constellationUrl", e.currentTarget.value)} 25 - class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 25 + class="ui-input flex-1" /> 26 26 </div> 27 27 </div> 28 28 <div> ··· 32 32 type="text" 33 33 value={spacedustUrl()} 34 34 onChange={(e) => void props.handleUpdateSetting("spacedustUrl", e.currentTarget.value)} 35 - class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 35 + class="ui-input flex-1" /> 36 36 </div> 37 37 </div> 38 38 <ToggleRow
+22 -9
src/components/settings/SettingsTheme.tsx
··· 1 1 import type { AppSettings, Theme } from "$/lib/types"; 2 2 import { SegmentedControl } from "../shared/SegmentedControl"; 3 3 import { SettingsCard } from "./SettingsCard"; 4 + import { ToggleRow } from "./SettingsToggleRow"; 4 5 5 6 const THEME_OPTIONS: { value: Theme; label: string }[] = [{ value: "light", label: "Light" }, { 6 7 value: "dark", ··· 8 9 }, { value: "auto", label: "Auto" }]; 9 10 10 11 export function AppearanceControl( 11 - props: { currentTheme: Theme; handleUpdateSetting: (key: keyof AppSettings, value: string) => void }, 12 + props: { 13 + currentTheme: Theme; 14 + handleUpdateSetting: (key: keyof AppSettings, value: string) => void; 15 + setShowThemeRailControl: (enabled: boolean) => void; 16 + showThemeRailControl: boolean; 17 + }, 12 18 ) { 13 19 return ( 14 20 <SettingsCard icon="theme" title="Appearance"> 15 - <div class="flex items-center justify-between"> 16 - <div> 17 - <p class="text-sm font-medium text-on-surface">Theme</p> 18 - <p class="text-xs text-on-surface-variant">Choose your preferred color scheme</p> 21 + <div class="grid gap-4"> 22 + <div class="flex items-center justify-between"> 23 + <div> 24 + <p class="text-sm font-medium text-on-surface">Theme</p> 25 + <p class="text-xs text-on-surface-variant">Choose your preferred color scheme</p> 26 + </div> 27 + <SegmentedControl 28 + options={THEME_OPTIONS} 29 + value={props.currentTheme} 30 + onChange={(v) => void props.handleUpdateSetting("theme", v)} /> 19 31 </div> 20 - <SegmentedControl 21 - options={THEME_OPTIONS} 22 - value={props.currentTheme} 23 - onChange={(v) => void props.handleUpdateSetting("theme", v)} /> 32 + <ToggleRow 33 + label="Show theme control in app rail" 34 + description="Keep the System, Light, and Dark menu visible in the rail." 35 + checked={props.showThemeRailControl} 36 + onChange={() => props.setShowThemeRailControl(!props.showThemeRailControl)} /> 24 37 </div> 25 38 </SettingsCard> 26 39 );
+1 -1
src/components/settings/SettingsToggleRow.tsx
··· 17 17 disabled={props.disabled} 18 18 onClick={() => props.onChange()} 19 19 class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50" 20 - classList={{ "bg-primary": props.checked, "bg-white/20": !props.checked }}> 20 + classList={{ "bg-primary": props.checked, "bg-[var(--control-bg)]": !props.checked }}> 21 21 <Motion.span 22 22 class="inline-block h-4 w-4 rounded-full bg-on-primary-fixed shadow-lg" 23 23 animate={{ x: props.checked ? 20 : 2 }}
+2 -2
src/components/shared/SegmentedControl.tsx
··· 4 4 props: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }, 5 5 ) { 6 6 return ( 7 - <div class="flex rounded-xl bg-black/40 p-1"> 7 + <div class="flex rounded-xl border p-1 ui-outline-subtle ui-input-strong"> 8 8 <For each={props.options}> 9 9 {(option) => ( 10 10 <button ··· 13 13 class="flex-1 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors" 14 14 classList={{ 15 15 "bg-primary/20 text-primary": props.value === option.value, 16 - "text-on-surface-variant hover:text-on-surface": props.value !== option.value, 16 + "text-on-surface-variant hover:bg-surface-bright hover:text-on-surface": props.value !== option.value, 17 17 }}> 18 18 {option.label} 19 19 </button>
+112
src/components/theme/ThemeController.test.tsx
··· 1 + import { ThemeController } from "$/components/theme/ThemeController"; 2 + import { AppTestProviders } from "$/test/providers"; 3 + import type { AppSettings } from "$/lib/types"; 4 + import { render, waitFor } from "@solidjs/testing-library"; 5 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 6 + 7 + type ThemeChangeHandler = (event: { payload: "light" | "dark" }) => void; 8 + const noopUnlisten = () => {}; 9 + 10 + const tauriThemeMock = vi.hoisted(() => vi.fn(async () => null as "light" | "dark" | null)); 11 + const onThemeChangedMock = vi.hoisted(() => vi.fn(async (_handler: ThemeChangeHandler) => noopUnlisten)); 12 + 13 + vi.mock("@tauri-apps/api/window", () => ({ 14 + getCurrentWindow: () => ({ 15 + label: "main", 16 + onThemeChanged: onThemeChangedMock, 17 + theme: tauriThemeMock, 18 + }), 19 + })); 20 + 21 + function installMatchMedia(initialDark: boolean) { 22 + const listeners = new Set<(event: MediaQueryListEvent) => void>(); 23 + const media = { 24 + matches: initialDark, 25 + media: "(prefers-color-scheme: dark)", 26 + onchange: null, 27 + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { 28 + listeners.add(listener); 29 + }, 30 + removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { 31 + listeners.delete(listener); 32 + }, 33 + addListener: (listener: (event: MediaQueryListEvent) => void) => { 34 + listeners.add(listener); 35 + }, 36 + removeListener: (listener: (event: MediaQueryListEvent) => void) => { 37 + listeners.delete(listener); 38 + }, 39 + dispatch(nextDark: boolean) { 40 + media.matches = nextDark; 41 + const event = { matches: nextDark } as MediaQueryListEvent; 42 + for (const listener of listeners) { 43 + listener(event); 44 + } 45 + }, 46 + }; 47 + 48 + Object.defineProperty(globalThis, "matchMedia", { 49 + configurable: true, 50 + writable: true, 51 + value: vi.fn(() => media), 52 + }); 53 + 54 + return media; 55 + } 56 + 57 + describe("ThemeController", () => { 58 + const baseSettings: AppSettings = { 59 + theme: "auto", 60 + timelineRefreshSecs: 60, 61 + notificationsDesktop: true, 62 + notificationsBadge: true, 63 + notificationsSound: false, 64 + embeddingsEnabled: false, 65 + constellationUrl: "https://constellation.microcosm.blue", 66 + spacedustUrl: "https://spacedust.microcosm.blue", 67 + spacedustInstant: false, 68 + spacedustEnabled: false, 69 + globalShortcut: "Ctrl+Shift+N", 70 + downloadDirectory: "/Users/test/Downloads", 71 + }; 72 + 73 + beforeEach(() => { 74 + tauriThemeMock.mockReset(); 75 + tauriThemeMock.mockResolvedValue(null); 76 + onThemeChangedMock.mockReset(); 77 + onThemeChangedMock.mockResolvedValue(noopUnlisten); 78 + }); 79 + 80 + afterEach(() => { 81 + delete document.documentElement.dataset.theme; 82 + document.documentElement.style.colorScheme = ""; 83 + }); 84 + 85 + it("applies explicit light theme", async () => { 86 + installMatchMedia(true); 87 + 88 + render(() => ( 89 + <AppTestProviders preferences={{ settings: { ...baseSettings, theme: "light" } }}> 90 + <ThemeController /> 91 + </AppTestProviders> 92 + )); 93 + 94 + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light")); 95 + expect(document.documentElement.style.colorScheme).toBe("light"); 96 + }); 97 + 98 + it("follows system theme changes when setting is auto", async () => { 99 + const media = installMatchMedia(true); 100 + 101 + render(() => ( 102 + <AppTestProviders preferences={{ settings: { ...baseSettings, theme: "auto" } }}> 103 + <ThemeController /> 104 + </AppTestProviders> 105 + )); 106 + 107 + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark")); 108 + 109 + media.dispatch(false); 110 + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light")); 111 + }); 112 + });
+83
src/components/theme/ThemeController.tsx
··· 1 + import { useAppPreferences } from "$/contexts/app-preferences"; 2 + import { 3 + applyThemeToDocument, 4 + normalizeThemeSetting, 5 + resolveEffectiveTheme, 6 + toEffectiveTheme, 7 + type EffectiveTheme, 8 + } from "$/lib/theme"; 9 + import { getCurrentWindow } from "@tauri-apps/api/window"; 10 + import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; 11 + 12 + function toSystemTheme(matches: boolean): EffectiveTheme { 13 + return matches ? "dark" : "light"; 14 + } 15 + 16 + export function ThemeController() { 17 + const preferences = useAppPreferences(); 18 + const [systemTheme, setSystemTheme] = createSignal<EffectiveTheme>("dark"); 19 + 20 + const selectedTheme = createMemo(() => normalizeThemeSetting(preferences.settings?.theme)); 21 + 22 + const effectiveTheme = createMemo(() => resolveEffectiveTheme(selectedTheme(), systemTheme())); 23 + 24 + onMount(() => { 25 + const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)"); 26 + 27 + if (media) { 28 + setSystemTheme(toSystemTheme(media.matches)); 29 + const handleChange = (event: MediaQueryListEvent) => { 30 + setSystemTheme(toSystemTheme(event.matches)); 31 + }; 32 + media.addEventListener("change", handleChange); 33 + onCleanup(() => media.removeEventListener("change", handleChange)); 34 + } 35 + 36 + let cancelled = false; 37 + let unlistenThemeChange: (() => void) | null = null; 38 + 39 + try { 40 + const currentWindow = getCurrentWindow(); 41 + void currentWindow 42 + .theme() 43 + .then((theme) => { 44 + if (cancelled) { 45 + return; 46 + } 47 + 48 + setSystemTheme((current) => toEffectiveTheme(theme, current)); 49 + }) 50 + .catch(() => { 51 + // Browser test environments may not provide native window theme APIs. 52 + }); 53 + 54 + void currentWindow 55 + .onThemeChanged(({ payload }) => { 56 + setSystemTheme((current) => toEffectiveTheme(payload, current)); 57 + }) 58 + .then((unlisten) => { 59 + if (cancelled) { 60 + unlisten(); 61 + return; 62 + } 63 + unlistenThemeChange = unlisten; 64 + }) 65 + .catch(() => { 66 + // Browser test environments may not provide native window theme APIs. 67 + }); 68 + } catch { 69 + // Browser test environments may not provide native window APIs. 70 + } 71 + 72 + onCleanup(() => { 73 + cancelled = true; 74 + unlistenThemeChange?.(); 75 + }); 76 + }); 77 + 78 + createEffect(() => { 79 + applyThemeToDocument(effectiveTheme()); 80 + }); 81 + 82 + return null; 83 + }
+29 -2
src/contexts/app-shell-ui.tsx
··· 12 12 import { createStore } from "solid-js/store"; 13 13 14 14 const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 15 + const RAIL_THEME_CONTROL_STORAGE_KEY = "lazurite:rail-theme-control"; 15 16 16 - type AppShellUiState = { narrowViewport: boolean; railCollapsed: boolean; showSwitcher: boolean }; 17 + type AppShellUiState = { 18 + narrowViewport: boolean; 19 + railCollapsed: boolean; 20 + showSwitcher: boolean; 21 + showThemeRailControl: boolean; 22 + }; 17 23 18 24 export type AppShellUiContextValue = { 19 25 readonly narrowViewport: boolean; 20 26 readonly railCollapsed: boolean; 21 27 readonly railColumns: string; 22 28 readonly railCondensed: boolean; 29 + readonly showThemeRailControl: boolean; 23 30 readonly showSwitcher: boolean; 24 31 closeSwitcher: () => void; 32 + setShowThemeRailControl: (enabled: boolean) => void; 25 33 toggleRailCollapsed: () => void; 26 34 toggleSwitcher: () => void; 27 35 }; ··· 29 37 const AppShellUiContext = createContext<AppShellUiContextValue>(); 30 38 31 39 function createInitialAppShellUiState(): AppShellUiState { 32 - return { narrowViewport: false, railCollapsed: false, showSwitcher: false }; 40 + return { narrowViewport: false, railCollapsed: false, showSwitcher: false, showThemeRailControl: true }; 33 41 } 34 42 35 43 function createAppShellUiValue(): AppShellUiContextValue { ··· 51 59 52 60 function toggleSwitcher() { 53 61 setShell("showSwitcher", (open) => !open); 62 + } 63 + 64 + function setShowThemeRailControl(enabled: boolean) { 65 + setShell("showThemeRailControl", enabled); 54 66 } 55 67 56 68 onMount(() => { ··· 61 73 if (stored === "true") { 62 74 setShell("railCollapsed", true); 63 75 } 76 + const storedThemeControl = globalThis.localStorage.getItem(RAIL_THEME_CONTROL_STORAGE_KEY); 77 + if (storedThemeControl === "false") { 78 + setShell("showThemeRailControl", false); 79 + } 64 80 65 81 syncViewport(); 66 82 media.addEventListener("change", syncViewport); ··· 74 90 globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, shell.railCollapsed ? "true" : "false"); 75 91 }); 76 92 93 + createEffect(() => { 94 + globalThis.localStorage.setItem( 95 + RAIL_THEME_CONTROL_STORAGE_KEY, 96 + shell.showThemeRailControl ? "true" : "false", 97 + ); 98 + }); 99 + 77 100 return { 78 101 get narrowViewport() { 79 102 return shell.narrowViewport; ··· 87 110 get railCondensed() { 88 111 return railCondensed(); 89 112 }, 113 + get showThemeRailControl() { 114 + return shell.showThemeRailControl; 115 + }, 90 116 get showSwitcher() { 91 117 return shell.showSwitcher; 92 118 }, 93 119 closeSwitcher, 120 + setShowThemeRailControl, 94 121 toggleRailCollapsed, 95 122 toggleSwitcher, 96 123 };
+28
src/lib/theme.ts
··· 1 + import type { Theme } from "$/lib/types"; 2 + 3 + export type EffectiveTheme = "light" | "dark"; 4 + 5 + export function normalizeThemeSetting(value: string | null | undefined): Theme { 6 + return value === "light" || value === "dark" || value === "auto" ? value : "auto"; 7 + } 8 + 9 + export function resolveEffectiveTheme(setting: Theme, systemTheme: EffectiveTheme): EffectiveTheme { 10 + if (setting === "light") { 11 + return "light"; 12 + } 13 + 14 + if (setting === "dark") { 15 + return "dark"; 16 + } 17 + 18 + return systemTheme; 19 + } 20 + 21 + export function toEffectiveTheme(value: string | null | undefined, fallback: EffectiveTheme): EffectiveTheme { 22 + return value === "light" || value === "dark" ? value : fallback; 23 + } 24 + 25 + export function applyThemeToDocument(theme: EffectiveTheme, doc: Document = document) { 26 + doc.documentElement.dataset.theme = theme; 27 + doc.documentElement.style.colorScheme = theme; 28 + }
+2
src/test/providers.tsx
··· 86 86 railCollapsed: overrides.railCollapsed ?? false, 87 87 railColumns: overrides.railColumns ?? "16rem minmax(0,1fr)", 88 88 railCondensed: overrides.railCondensed ?? false, 89 + showThemeRailControl: overrides.showThemeRailControl ?? true, 89 90 showSwitcher: overrides.showSwitcher ?? false, 90 91 closeSwitcher: overrides.closeSwitcher ?? noop, 92 + setShowThemeRailControl: overrides.setShowThemeRailControl ?? noop, 91 93 toggleRailCollapsed: overrides.toggleRailCollapsed ?? noop, 92 94 toggleSwitcher: overrides.toggleSwitcher ?? noop, 93 95 };