Base webui and backend additions
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+168
-9
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user