Base webui and backend additions

This commit is contained in:
2026-07-16 21:35:38 +02:00
parent d2b92401f1
commit 324367abd7
18 changed files with 1231 additions and 43 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
+168 -9
View File
@@ -1,7 +1,7 @@
.app-shell {
display: flex;
min-height: 100vh;
color: #12263a;
color: var(--ctp-text);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
}
@@ -10,13 +10,16 @@
flex-direction: column;
width: 240px;
flex-shrink: 0;
background: #f7fafc;
border-right: 1px solid #c5d4e0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.sidebar-header {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid #c5d4e0;
border-bottom: 1px solid var(--ctp-surface0);
}
.brand {
@@ -25,13 +28,14 @@
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--ctp-mauve);
}
.version-line {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: #3a5166;
color: var(--ctp-subtext0);
}
.version-line + .version-line {
@@ -49,7 +53,7 @@
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid #c5d4e0;
border-top: 1px solid var(--ctp-surface0);
}
.nav-item {
@@ -67,14 +71,21 @@
font-size: 0.95rem;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
}
.nav-item:hover {
background: #e4eef5;
background: var(--ctp-mauve-hover);
}
.nav-item:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
.nav-item-active {
background: #d7e7f2;
background: var(--ctp-mauve-muted);
color: var(--ctp-mauve);
font-weight: 600;
}
@@ -96,12 +107,160 @@
margin: 0 0 0.5rem;
font-size: 1.75rem;
letter-spacing: -0.02em;
color: var(--ctp-text);
}
.content p {
margin: 0;
line-height: 1.5;
color: #3a5166;
color: var(--ctp-subtext1);
}
.config-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 40rem;
}
.config-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.25rem;
border-bottom: 1px solid var(--ctp-surface1);
}
.config-tab {
appearance: none;
margin: 0;
padding: 0.55rem 0.85rem;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: var(--ctp-subtext1);
font: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
}
.config-tab:hover {
color: var(--ctp-text);
}
.config-tab-active {
color: var(--ctp-mauve);
border-bottom-color: var(--ctp-mauve);
}
.config-tab:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
border-radius: 0.2rem;
}
.config-tab-panel {
min-width: 0;
}
.config-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-width: 28rem;
}
.config-field label {
font-size: 0.9rem;
font-weight: 600;
color: var(--ctp-text);
}
.theme-select {
appearance: none;
width: 100%;
max-width: 16rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-mantle);
color: var(--ctp-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
}
.theme-select:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
.config-hint {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
color: var(--ctp-subtext0);
}
.config-empty {
margin: 0;
max-width: 36rem;
line-height: 1.5;
color: var(--ctp-subtext1);
}
.config-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 1rem;
border-top: 1px solid var(--ctp-surface1);
}
.config-action-revert,
.config-action-save {
appearance: none;
margin: 0;
padding: 0.5rem 1rem;
border-radius: 0.35rem;
font: inherit;
font-size: 0.9rem;
font-weight: 550;
cursor: pointer;
}
.config-action-revert {
border: 1px solid var(--ctp-surface1);
background: transparent;
color: var(--ctp-text);
}
.config-action-revert:hover:not(:disabled) {
background: var(--ctp-surface0);
}
.config-action-save {
border: 1px solid var(--ctp-mauve);
background: var(--ctp-mauve);
color: var(--ctp-base);
}
.config-action-save:hover:not(:disabled) {
filter: brightness(1.05);
}
.config-action-revert:disabled,
.config-action-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.config-action-revert:focus-visible,
.config-action-save:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
}
@media (max-width: 640px) {
+165 -22
View File
@@ -1,32 +1,63 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { THEME_STORAGE_KEY } from './theme'
function stubFetchOk() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
}),
)
}
function stubMatchMedia(prefersDark: boolean) {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: query.includes('dark') ? prefersDark : !prefersDark,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
)
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
stubMatchMedia(false)
})
afterEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('renders the dashboard shell with brand and nav', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
}),
)
stubFetchOk()
render(<App />)
expect(
screen.getByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`)).toBeInTheDocument()
expect(
screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`),
).toBeInTheDocument()
expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
@@ -46,16 +77,7 @@ describe('App', () => {
it('switches the content panel when a nav item is selected', async () => {
const user = userEvent.setup()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
}),
)
stubFetchOk()
render(<App />)
@@ -68,6 +90,49 @@ describe('App', () => {
expect(
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByLabelText('Theme')).toBeInTheDocument()
})
it('switches Configuration tabs and shows empty states', async () => {
const user = userEvent.setup()
stubFetchOk()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
for (const label of [
'Overview',
'Users',
'Groups',
'Security',
'Integrations',
'Network',
'Advanced',
]) {
expect(screen.getByRole('tab', { name: label })).toBeInTheDocument()
}
await user.click(screen.getByRole('tab', { name: 'Security' }))
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(
screen.getByText(/Service SSH keys and related credentials/i),
).toBeInTheDocument()
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Integrations' }))
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByText('Coming soon')).toBeInTheDocument()
})
it('shows unavailable when backend status fetch fails', async () => {
@@ -85,4 +150,82 @@ describe('App', () => {
await screen.findByText('Backend Version unavailable'),
).toBeInTheDocument()
})
it('defaults to latte when preference is auto and browser is light', () => {
stubFetchOk()
stubMatchMedia(false)
render(<App />)
expect(document.documentElement.dataset.theme).toBe('latte')
})
it('does not apply theme until Save is pressed', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(false)
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
await user.selectOptions(screen.getByLabelText('Theme'), 'dark')
expect(document.documentElement.dataset.theme).toBe('latte')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull()
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
})
it('reverts draft theme changes without applying them', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(true)
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
render(<App />)
expect(document.documentElement.dataset.theme).toBe('mocha')
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
expect(screen.getByLabelText('Theme')).toHaveValue('light')
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
await user.click(screen.getByRole('button', { name: 'Revert' }))
expect(screen.getByLabelText('Theme')).toHaveValue('dark')
expect(document.documentElement.dataset.theme).toBe('mocha')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
})
it('applies latte when the user saves Light in Configuration', async () => {
const user = userEvent.setup()
stubFetchOk()
stubMatchMedia(true)
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(document.documentElement.dataset.theme).toBe('latte')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light')
})
})
+204 -1
View File
@@ -1,5 +1,12 @@
import { useEffect, useState } from 'react'
import { fetchStatus } from './api/client'
import {
applyThemeFromPreference,
getSystemPrefersDark,
readThemePreference,
writeThemePreference,
type ThemePreference,
} from './theme'
import './App.css'
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
@@ -17,6 +24,25 @@ const SECTION_TITLES: Record<SectionId, string> = {
configuration: 'Configuration',
}
type ConfigTabId =
| 'overview'
| 'users'
| 'groups'
| 'security'
| 'integrations'
| 'network'
| 'advanced'
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'groups', label: 'Groups' },
{ id: 'security', label: 'Security' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'network', label: 'Network' },
{ id: 'advanced', label: 'Advanced' },
]
function CogIcon() {
return (
<svg
@@ -35,9 +61,179 @@ function CogIcon() {
)
}
function useThemePreference() {
const [themePreference, setThemePreference] =
useState<ThemePreference>(readThemePreference)
useEffect(() => {
applyThemeFromPreference(themePreference)
if (themePreference !== 'auto') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleSystemThemeChange() {
applyThemeFromPreference('auto', getSystemPrefersDark(mediaQuery))
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
}, [themePreference])
function updateThemePreference(nextPreference: ThemePreference) {
writeThemePreference(nextPreference)
setThemePreference(nextPreference)
}
return { themePreference, updateThemePreference }
}
function OverviewConfigTab({
draftThemePreference,
onDraftThemePreferenceChange,
}: {
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
return (
<div className="config-field">
<label htmlFor="theme-preference">Theme</label>
<select
id="theme-preference"
className="theme-select"
value={draftThemePreference}
onChange={(event) => {
onDraftThemePreferenceChange(event.target.value as ThemePreference)
}}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<p className="config-hint">
Auto follows the browser preference. Light and Dark always use
Catppuccin Latte or Mocha.
</p>
</div>
)
}
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
onDraftThemePreferenceChange,
}: {
activeConfigTab: ConfigTabId
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
if (activeConfigTab === 'overview') {
return (
<OverviewConfigTab
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={onDraftThemePreferenceChange}
/>
)
}
if (activeConfigTab === 'security') {
return (
<p className="config-empty">
Service SSH keys and related credentials will live here add keys with
a description and an expiry time limit or non-expiry.
</p>
)
}
return <p className="config-empty">Coming soon</p>
}
function ConfigurationPanel({
themePreference,
onThemePreferenceChange,
}: {
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
}) {
const [activeConfigTab, setActiveConfigTab] =
useState<ConfigTabId>('overview')
const [draftThemePreference, setDraftThemePreference] =
useState<ThemePreference>(themePreference)
useEffect(() => {
setDraftThemePreference(themePreference)
}, [themePreference])
const isDirty = draftThemePreference !== themePreference
return (
<div className="config-panel">
<div className="config-tabs" role="tablist" aria-label="Configuration">
{CONFIG_TABS.map((tab) => {
const isSelected = activeConfigTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
id={`config-tab-${tab.id}`}
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
aria-controls="config-tab-panel"
tabIndex={isSelected ? 0 : -1}
onClick={() => setActiveConfigTab(tab.id)}
>
{tab.label}
</button>
)
})}
</div>
<div
className="config-tab-panel"
role="tabpanel"
id="config-tab-panel"
aria-labelledby={`config-tab-${activeConfigTab}`}
>
<ConfigTabPanelContent
activeConfigTab={activeConfigTab}
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={setDraftThemePreference}
/>
</div>
<div className="config-actions">
<button
type="button"
className="config-action-revert"
disabled={!isDirty}
onClick={() => setDraftThemePreference(themePreference)}
>
Revert
</button>
<button
type="button"
className="config-action-save"
disabled={!isDirty}
onClick={() => onThemePreferenceChange(draftThemePreference)}
>
Save
</button>
</div>
</div>
)
}
function App() {
const [activeSection, setActiveSection] = useState<SectionId>('overview')
const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference()
useEffect(() => {
let isCancelled = false
@@ -110,7 +306,14 @@ function App() {
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
<h2>{SECTION_TITLES[activeSection]}</h2>
<p>Coming soon</p>
{activeSection === 'configuration' ? (
<ConfigurationPanel
themePreference={themePreference}
onThemePreferenceChange={updateThemePreference}
/>
) : (
<p>Coming soon</p>
)}
</main>
</div>
)
+48 -3
View File
@@ -1,13 +1,57 @@
/* Catppuccin Latte (light) — default until JS sets data-theme */
:root,
[data-theme='latte'] {
--ctp-base: #eff1f5;
--ctp-mantle: #e6e9ef;
--ctp-crust: #dce0e8;
--ctp-surface0: #ccd0da;
--ctp-surface1: #bcc0cc;
--ctp-overlay0: #9ca0b0;
--ctp-overlay1: #8c8fa1;
--ctp-text: #4c4f69;
--ctp-subtext0: #6c6f85;
--ctp-subtext1: #5c5f77;
--ctp-mauve: #8839ef;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
color-scheme: light;
}
/* Catppuccin Mocha (dark) */
[data-theme='mocha'] {
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
--ctp-surface0: #313244;
--ctp-surface1: #45475a;
--ctp-overlay0: #6c7086;
--ctp-overlay1: #7f849c;
--ctp-text: #cdd6f4;
--ctp-subtext0: #a6adc8;
--ctp-subtext1: #bac2de;
--ctp-mauve: #cba6f7;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
color-scheme: dark;
}
:root {
color: #12263a;
color: var(--ctp-text);
background:
radial-gradient(circle at top left, #d7e7f2 0%, transparent 45%),
linear-gradient(180deg, #f4f8fb 0%, #e8eef3 100%);
radial-gradient(
circle at top left,
color-mix(in srgb, var(--ctp-mauve) 22%, transparent) 0%,
transparent 45%
),
linear-gradient(180deg, var(--ctp-base) 0%, var(--ctp-mantle) 100%);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
color 160ms ease,
background-color 160ms ease;
}
* {
@@ -17,6 +61,7 @@
body {
margin: 0;
min-height: 100vh;
background: transparent;
}
#root {
+3
View File
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { applyThemeFromPreference } from './theme.ts'
applyThemeFromPreference()
createRoot(document.getElementById('root')!).render(
<StrictMode>
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
THEME_STORAGE_KEY,
applyResolvedTheme,
applyThemeFromPreference,
isThemePreference,
readThemePreference,
resolveTheme,
writeThemePreference,
} from './theme'
function createMemoryStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial))
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null
},
setItem(key: string, value: string) {
store.set(key, value)
},
}
}
describe('isThemePreference', () => {
it('accepts light, dark, and auto', () => {
expect(isThemePreference('light')).toBe(true)
expect(isThemePreference('dark')).toBe(true)
expect(isThemePreference('auto')).toBe(true)
})
it('rejects invalid values', () => {
expect(isThemePreference('mocha')).toBe(false)
expect(isThemePreference('')).toBe(false)
})
})
describe('readThemePreference', () => {
it('defaults to auto when nothing is stored', () => {
expect(readThemePreference(createMemoryStorage())).toBe('auto')
})
it('returns a stored preference', () => {
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'dark' })
expect(readThemePreference(storage)).toBe('dark')
})
it('falls back to auto for invalid stored values', () => {
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'neon' })
expect(readThemePreference(storage)).toBe('auto')
})
})
describe('writeThemePreference', () => {
it('persists the preference', () => {
const storage = createMemoryStorage()
writeThemePreference('light', storage)
expect(storage.getItem(THEME_STORAGE_KEY)).toBe('light')
})
})
describe('resolveTheme', () => {
it('maps light to latte and dark to mocha', () => {
expect(resolveTheme('light', true)).toBe('latte')
expect(resolveTheme('dark', false)).toBe('mocha')
})
it('maps auto from the browser preference', () => {
expect(resolveTheme('auto', true)).toBe('mocha')
expect(resolveTheme('auto', false)).toBe('latte')
})
})
describe('applyResolvedTheme', () => {
afterEach(() => {
delete document.documentElement.dataset.theme
})
it('sets data-theme on the root element', () => {
applyResolvedTheme('mocha')
expect(document.documentElement.dataset.theme).toBe('mocha')
})
})
describe('applyThemeFromPreference', () => {
afterEach(() => {
delete document.documentElement.dataset.theme
vi.restoreAllMocks()
})
it('applies the resolved theme from preference and system preference', () => {
const resolved = applyThemeFromPreference('auto', true)
expect(resolved).toBe('mocha')
expect(document.documentElement.dataset.theme).toBe('mocha')
})
})
+69
View File
@@ -0,0 +1,69 @@
export type ThemePreference = 'light' | 'dark' | 'auto'
export type ResolvedTheme = 'latte' | 'mocha'
export const THEME_STORAGE_KEY = 'clustercanvas.theme'
const VALID_PREFERENCES: ReadonlySet<string> = new Set([
'light',
'dark',
'auto',
])
export function isThemePreference(value: string): value is ThemePreference {
return VALID_PREFERENCES.has(value)
}
export function readThemePreference(
storage: Pick<Storage, 'getItem'> = localStorage,
): ThemePreference {
const storedValue = storage.getItem(THEME_STORAGE_KEY)
if (storedValue !== null && isThemePreference(storedValue)) {
return storedValue
}
return 'auto'
}
export function writeThemePreference(
preference: ThemePreference,
storage: Pick<Storage, 'setItem'> = localStorage,
): void {
storage.setItem(THEME_STORAGE_KEY, preference)
}
export function resolveTheme(
preference: ThemePreference,
prefersDark: boolean,
): ResolvedTheme {
if (preference === 'light') {
return 'latte'
}
if (preference === 'dark') {
return 'mocha'
}
return prefersDark ? 'mocha' : 'latte'
}
export function getSystemPrefersDark(
mediaQueryList: Pick<MediaQueryList, 'matches'> = window.matchMedia(
'(prefers-color-scheme: dark)',
),
): boolean {
return mediaQueryList.matches
}
export function applyResolvedTheme(
resolvedTheme: ResolvedTheme,
rootElement: HTMLElement = document.documentElement,
): void {
rootElement.dataset.theme = resolvedTheme
}
export function applyThemeFromPreference(
preference: ThemePreference = readThemePreference(),
prefersDark: boolean = getSystemPrefersDark(),
rootElement: HTMLElement = document.documentElement,
): ResolvedTheme {
const resolvedTheme = resolveTheme(preference, prefersDark)
applyResolvedTheme(resolvedTheme, rootElement)
return resolvedTheme
}