#186: pond accent theming — scoped derivation, cascade pond > user > default
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m44s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 10m50s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 4m47s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 10m7s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 1m0s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m44s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 10m50s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 4m47s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 10m7s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 1m0s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
pondSettingsSchema gains theme = { accent: '#rrggbb' | null } (null =
inherit the viewer's theme), exposed as a top-level key of the flat
updatePondInputSchema and included in the PondsService settings merge
(the known silent-no-op pitfall). The server validates only the hex;
conformance arises at render time: PondThemeScope (mounted around the
page content next to PondFontScope) derives the accent pair for the
EFFECTIVE mode via useEffectiveTheme and sets it as inline custom
properties — inline beats both tokens.css and the user-theme <style>,
which IS the cascade precedence pond > user > default.
Pond settings get a PondThemeSection (inherit | presets | custom color
with per-mode preview swatches, explicit save like the font manager);
AccentSwatches extracted for reuse; i18n de+en. The no-JS public shell
stays deliberately un-themed (ADR 0018 amendment).
Tests: pond DB test (theme merge keeps fonts, invalid hex 400), e2e
pond-theme.spec (scope boundary content vs. chrome, per-mode
re-derivation, axe on the pond settings page; resets the fixture pond).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
This commit is contained in:
parent
83a2fe470e
commit
b5d2a436e0
@ -146,6 +146,44 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
||||
expect(fetched.body.name).toBe(`Neuer Name ${suffix}`);
|
||||
});
|
||||
|
||||
it('saves the pond accent theme without losing other settings (issue #186)', async () => {
|
||||
const created = await api()
|
||||
.post('/api/v1/ponds')
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ name: `Akzent ${suffix}` })
|
||||
.expect(201);
|
||||
|
||||
// First persist a non-default font, then the theme — the settings
|
||||
// merge must keep both (the jsonb stores only deviations).
|
||||
await api()
|
||||
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({
|
||||
fonts: { ...created.body.settings.fonts, heading: { family: 'Roboto', weight: 700 } },
|
||||
})
|
||||
.expect(200);
|
||||
const themed = await api()
|
||||
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ theme: { accent: '#2b5f8f' } })
|
||||
.expect(200);
|
||||
expect(themed.body.settings.theme.accent).toBe('#2b5f8f');
|
||||
expect(themed.body.settings.fonts.heading.weight).toBe(700);
|
||||
|
||||
// Back to inherit; malformed hexes never reach the settings.
|
||||
const inherit = await api()
|
||||
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ theme: { accent: null } })
|
||||
.expect(200);
|
||||
expect(inherit.body.settings.theme.accent).toBeNull();
|
||||
await api()
|
||||
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ theme: { accent: 'lila' } })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('hides foreign ponds (list and slug lookup)', async () => {
|
||||
const created = await api()
|
||||
.post('/api/v1/ponds')
|
||||
|
||||
@ -154,7 +154,8 @@ export class PondsService {
|
||||
input.fonts !== undefined ||
|
||||
input.commentPolicy !== undefined ||
|
||||
input.apiEnabled !== undefined ||
|
||||
input.mcpEnabled !== undefined;
|
||||
input.mcpEnabled !== undefined ||
|
||||
input.theme !== undefined;
|
||||
const settings = !settingsChanged
|
||||
? undefined
|
||||
: {
|
||||
@ -165,6 +166,7 @@ export class PondsService {
|
||||
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
|
||||
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
||||
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
||||
...(input.theme !== undefined ? { theme: input.theme } : {}),
|
||||
};
|
||||
const updated = await this.prisma.pond.update({
|
||||
where: { id },
|
||||
|
||||
72
apps/web/e2e/pond-theme.spec.ts
Normal file
72
apps/web/e2e/pond-theme.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
/**
|
||||
* Teich-Akzent (issue #186, ADR 0018 Stufe C): der Akzent eines Teichs
|
||||
* färbt NUR die Teich-Inhalte (PondThemeScope), das App-Chrome behält den
|
||||
* Akzent der Betrachtenden; je Modus wird abgeleitet. Der Spec setzt das
|
||||
* Theme des content-fixtures-Teichs per API und RÄUMT ES AM ENDE WIEDER AB
|
||||
* (Fixtures sind pack-übergreifend geteilt).
|
||||
*/
|
||||
|
||||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
const POND = 'content-fixtures';
|
||||
const ACCENT = '#6d4fa0'; // iris-violet: besteht hell verbatim, dunkel wird abgeleitet
|
||||
|
||||
const accentAt = (page: Page, selector: string) =>
|
||||
page.evaluate(
|
||||
(sel) =>
|
||||
getComputedStyle(document.querySelector(sel)!).getPropertyValue('--color-accent').trim(),
|
||||
selector,
|
||||
);
|
||||
|
||||
test('pond accent colors the pond content but not the app chrome', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
|
||||
const pond = await page.request.get(`${BASE}/api/v1/ponds/${POND}`);
|
||||
expect(pond.ok()).toBe(true);
|
||||
const pondId = ((await pond.json()) as { id: string }).id;
|
||||
|
||||
try {
|
||||
const patch = await page.request.patch(`${BASE}/api/v1/ponds/${pondId}`, {
|
||||
data: { theme: { accent: ACCENT } },
|
||||
});
|
||||
expect(patch.ok()).toBe(true);
|
||||
|
||||
await page.goto(`/p/${POND}/every-element`);
|
||||
await page.locator('.pond-theme-scope').waitFor();
|
||||
|
||||
// Scope-Grenze: Inhalt trägt den Teich-Akzent, die TopBar den Default.
|
||||
expect(await accentAt(page, '.pond-theme-scope')).toBe(ACCENT);
|
||||
expect(await accentAt(page, '.topbar')).toBe('#2f6f4f');
|
||||
|
||||
// Moduswechsel leitet neu ab: dunkel bekommt einen helleren Wert.
|
||||
await page.locator('.topbar__theme').click(); // system -> light
|
||||
await page.locator('.topbar__theme').click(); // light -> dark
|
||||
const darkAccent = await accentAt(page, '.pond-theme-scope');
|
||||
expect(darkAccent).not.toBe(ACCENT);
|
||||
expect(darkAccent).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(await accentAt(page, '.topbar')).not.toBe(ACCENT);
|
||||
|
||||
// Die Teich-Einstellungen (mit neuer Akzent-Sektion) bleiben axe-sauber.
|
||||
await page.goto(`/p/${POND}/settings`);
|
||||
await page.locator('input[name="pond-theme-accent"]').first().waitFor();
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'])
|
||||
.analyze();
|
||||
expect(
|
||||
results.violations.map((v) => ({ rule: v.id, help: v.help })),
|
||||
'axe auf den Teich-Einstellungen',
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await page.request.patch(`${BASE}/api/v1/ponds/${pondId}`, {
|
||||
data: { theme: { accent: null } },
|
||||
});
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
@ -23,6 +23,7 @@ import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||
import { documentExtensions } from '../editor/document-extensions';
|
||||
import { PondFontScope } from '../fonts/PondFontScope';
|
||||
import { PondThemeScope } from '../theme/PondThemeScope';
|
||||
import { ImageUpload } from '../editor/image-upload';
|
||||
import { PresenceStrip } from '../editor/PresenceStrip';
|
||||
import { Toolbar } from '../editor/Toolbar';
|
||||
@ -538,6 +539,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<PondThemeScope theme={pond.data?.settings.theme ?? { accent: null }}>
|
||||
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
|
||||
{/* The page's actions render as icons in the TopBar (issue #101). */}
|
||||
{actionsSlot.element &&
|
||||
@ -618,5 +620,6 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</PondFontScope>
|
||||
</PondThemeScope>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||
import { MemberManager } from '../members/MemberManager';
|
||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||
import { PondThemeSection } from '../theme/PondThemeSection';
|
||||
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
/**
|
||||
@ -106,6 +107,11 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
pondSlug={pondSlug}
|
||||
fonts={pond.data.settings.fonts}
|
||||
/>
|
||||
<PondThemeSection
|
||||
pondId={pond.data.id}
|
||||
pondSlug={pondSlug}
|
||||
theme={pond.data.settings.theme}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
BASE_PALETTE,
|
||||
changePasswordInputSchema,
|
||||
DEFAULT_THEME_PRESET_ID,
|
||||
deriveAccentTokens,
|
||||
@ -24,6 +23,7 @@ import { WatchesSection } from '../watches/WatchesSection';
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
import { AccentSwatches } from '../theme/AccentSwatches';
|
||||
import { useAccentChoice } from '../theme/apply-theme';
|
||||
import { useThemeMode, type ThemeMode } from '../theme/theme';
|
||||
interface SessionView {
|
||||
@ -91,24 +91,6 @@ function AppearanceSection(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-mode preview of a derived accent. Decorative only — the visible
|
||||
* preset/custom NAME carries the meaning, color is never the only cue
|
||||
* (ADR 0017). The rings show each mode's canonical background. */
|
||||
function AccentSwatches({ light, dark }: { light: string; dark: string }): React.JSX.Element {
|
||||
return (
|
||||
<span className="accent-swatches" aria-hidden>
|
||||
<span
|
||||
className="accent-swatch"
|
||||
style={{ background: light, boxShadow: `0 0 0 3px ${BASE_PALETTE.light.bg}` }}
|
||||
/>
|
||||
<span
|
||||
className="accent-swatch"
|
||||
style={{ background: dark, boxShadow: `0 0 0 3px ${BASE_PALETTE.dark.bg}` }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Akzentfarbe (issue #184, ADR 0018 stage B): presets and a free color as
|
||||
* ONE mechanism — both run through deriveAccentTokens, so any pick stays
|
||||
* readable by construction. Same section as the mode radios (the jump-nav
|
||||
|
||||
25
apps/web/src/theme/AccentSwatches.tsx
Normal file
25
apps/web/src/theme/AccentSwatches.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { BASE_PALETTE } from '@dorfteich/shared';
|
||||
|
||||
/** Per-mode preview of a derived accent (issues #184/#186). Decorative only
|
||||
* — the visible preset/custom NAME carries the meaning, color is never the
|
||||
* only cue (ADR 0017). The rings show each mode's canonical background. */
|
||||
export function AccentSwatches({
|
||||
light,
|
||||
dark,
|
||||
}: {
|
||||
light: string;
|
||||
dark: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span className="accent-swatches" aria-hidden>
|
||||
<span
|
||||
className="accent-swatch"
|
||||
style={{ background: light, boxShadow: `0 0 0 3px ${BASE_PALETTE.light.bg}` }}
|
||||
/>
|
||||
<span
|
||||
className="accent-swatch"
|
||||
style={{ background: dark, boxShadow: `0 0 0 3px ${BASE_PALETTE.dark.bg}` }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/theme/PondThemeScope.tsx
Normal file
40
apps/web/src/theme/PondThemeScope.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { deriveAccentTokens, type PondTheme } from '@dorfteich/shared';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { useEffectiveTheme, type EffectiveTheme } from './theme';
|
||||
|
||||
/**
|
||||
* The custom properties a pond's accent sets on its content root (issue
|
||||
* #186, ADR 0018 stage C) — derived per EFFECTIVE mode with the same engine
|
||||
* as the user accent (#184). Empty when the pond inherits (`accent: null`).
|
||||
*/
|
||||
export function pondThemeVariables(theme: PondTheme, mode: EffectiveTheme): CSSProperties {
|
||||
if (theme.accent === null) return {};
|
||||
const tokens = deriveAccentTokens(theme.accent, mode);
|
||||
return {
|
||||
'--color-accent': tokens.accent,
|
||||
'--color-accent-contrast': tokens.accentContrast,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a pond's accent to its content, analog PondFontScope (ADR 0016):
|
||||
* the INLINE custom properties win over both the tokens.css palette and the
|
||||
* user's `<style id="user-theme">` override inside this subtree — that IS
|
||||
* the cascade precedence pond > user > default. Re-renders on mode flips
|
||||
* via useEffectiveTheme (data-theme observer).
|
||||
*/
|
||||
export function PondThemeScope({
|
||||
theme,
|
||||
children,
|
||||
}: {
|
||||
theme: PondTheme;
|
||||
children: ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const mode = useEffectiveTheme();
|
||||
return (
|
||||
<div className="pond-theme-scope" style={pondThemeVariables(theme, mode)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/theme/PondThemeSection.tsx
Normal file
122
apps/web/src/theme/PondThemeSection.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { deriveAccentTokens, THEME_PRESETS, type PondTheme } from '@dorfteich/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiPatch } from '../lib/api';
|
||||
import { AccentSwatches } from './AccentSwatches';
|
||||
|
||||
/**
|
||||
* Pond accent setting (issue #186, ADR 0018 stage C): inherit | preset |
|
||||
* custom color, applied to the pond's content via PondThemeScope. Explicit
|
||||
* save like the font manager (AppearanceManager); Pond-Admin-gated by the
|
||||
* api's PATCH guard. Presets here include the default green — unlike the
|
||||
* user setting, picking it PINS the pond to green for every viewer.
|
||||
*/
|
||||
export function PondThemeSection({
|
||||
pondId,
|
||||
pondSlug,
|
||||
theme,
|
||||
}: {
|
||||
pondId: string;
|
||||
pondSlug: string;
|
||||
theme: PondTheme;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
const { t: tSettings } = useTranslation('settings');
|
||||
const queryClient = useQueryClient();
|
||||
const [accent, setAccent] = useState<string | null>(theme.accent);
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
const presetHexes = THEME_PRESETS.map((preset) => preset.accent);
|
||||
const isCustom = accent !== null && !presetHexes.includes(accent);
|
||||
const [customHex, setCustomHex] = useState(isCustom ? accent : '#2f6f4f');
|
||||
|
||||
const choose = (value: string | null): void => {
|
||||
setAccent(value);
|
||||
setStatus('idle');
|
||||
};
|
||||
|
||||
const derivedPair = (hex: string): { light: string; dark: string } => ({
|
||||
light: deriveAccentTokens(hex, 'light').accent,
|
||||
dark: deriveAccentTokens(hex, 'dark').accent,
|
||||
});
|
||||
|
||||
async function save(): Promise<void> {
|
||||
setStatus('saving');
|
||||
setError(null);
|
||||
try {
|
||||
await apiPatch(`/ponds/${pondId}`, { theme: { accent } });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
||||
setStatus('saved');
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
setStatus('idle');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pond-theme">
|
||||
<FormError error={error} />
|
||||
<fieldset className="settings-fieldset">
|
||||
<legend>{t('pondTheme.legend')}</legend>
|
||||
<label className="settings-checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="pond-theme-accent"
|
||||
value=""
|
||||
checked={accent === null}
|
||||
onChange={() => choose(null)}
|
||||
/>
|
||||
{t('pondTheme.inherit')}
|
||||
</label>
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<label key={preset.id} className="settings-checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="pond-theme-accent"
|
||||
value={preset.accent}
|
||||
checked={accent === preset.accent}
|
||||
onChange={() => choose(preset.accent)}
|
||||
/>
|
||||
{tSettings(`appearance.presets.${preset.id}`)}
|
||||
<AccentSwatches {...derivedPair(preset.accent)} />
|
||||
</label>
|
||||
))}
|
||||
<div className="settings-checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
id="pond-theme-custom"
|
||||
name="pond-theme-accent"
|
||||
value="custom"
|
||||
checked={isCustom}
|
||||
onChange={() => choose(customHex)}
|
||||
/>
|
||||
<label htmlFor="pond-theme-custom">{tSettings('appearance.custom')}</label>
|
||||
<input
|
||||
type="color"
|
||||
className="accent-color-input"
|
||||
value={isCustom ? accent : customHex}
|
||||
aria-label={tSettings('appearance.customPick')}
|
||||
onChange={(event) => {
|
||||
setCustomHex(event.target.value);
|
||||
choose(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{isCustom && <AccentSwatches {...derivedPair(accent)} />}
|
||||
</div>
|
||||
</fieldset>
|
||||
<p className="field__hint">{t('pondTheme.hint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => void save()}
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
{status === 'saved' ? t('pondTheme.saved') : t('pondTheme.save')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -103,3 +103,20 @@ The derivation from decision 5 is implemented as dependency-free
|
||||
the pre-derived CSS cache `ui.theme.css`, which `theme-init.js`
|
||||
injects before first paint; `main.tsx` re-derives from the CHOICE at
|
||||
startup so app updates heal stale caches.
|
||||
|
||||
## Amendment: stage C — pond accent shipped (issue #186)
|
||||
|
||||
- `pondSettingsSchema.theme = { accent: '#rrggbb' | null }` (`null` =
|
||||
inherit the viewer's theme), also a top-level key of the flat
|
||||
`updatePondInputSchema` and of the PondsService settings merge. The
|
||||
server validates only the hex — conformance arises at render time via
|
||||
the same `deriveAccentTokens`, per mode.
|
||||
- `PondThemeScope` (mounted around the page content next to
|
||||
`PondFontScope`) sets the accent pair as INLINE custom properties
|
||||
derived for the effective mode (`useEffectiveTheme`). Inline wins over
|
||||
both tokens.css and the user's `<style id="user-theme">` — that IS the
|
||||
cascade precedence pond > user > default; no extra machinery.
|
||||
- Only the accent family is pond-themable (decision 5's boundary), and
|
||||
the no-JS public shell stays deliberately un-themed in v1 — the shared
|
||||
engine leaves that door open (server-side derivation would be a small
|
||||
amendment, not a new decision).
|
||||
|
||||
@ -24,5 +24,12 @@
|
||||
"sans-serif": "Serifenlos",
|
||||
"serif": "Serif",
|
||||
"monospace": "Dicktengleich"
|
||||
},
|
||||
"pondTheme": {
|
||||
"legend": "Akzentfarbe des Teichs",
|
||||
"inherit": "Eigene Einstellung der Betrachtenden (Standard)",
|
||||
"hint": "Färbt Links, Buttons und Hervorhebungen der Teich-Inhalte für alle Betrachtenden. Die Farbe wird je Hell-/Dunkelmodus automatisch angepasst, damit Texte lesbar bleiben.",
|
||||
"save": "Akzentfarbe speichern",
|
||||
"saved": "Gespeichert"
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,5 +24,12 @@
|
||||
"sans-serif": "Sans-serif",
|
||||
"serif": "Serif",
|
||||
"monospace": "Monospace"
|
||||
},
|
||||
"pondTheme": {
|
||||
"legend": "Pond accent color",
|
||||
"inherit": "Each viewer's own setting (default)",
|
||||
"hint": "Colors links, buttons, and highlights of this pond's content for every viewer. The color is automatically adjusted per light/dark mode so text stays readable.",
|
||||
"save": "Save accent color",
|
||||
"saved": "Saved"
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,21 @@ export type PondFonts = z.infer<typeof pondFontsSchema>;
|
||||
* ADR 0016) — persisted settings therefore only need to store what the
|
||||
* pond actually changed.
|
||||
*/
|
||||
/**
|
||||
* Pond accent theme (issue #186, ADR 0018 stage C): `null` inherits the
|
||||
* viewer's own theme. Only the accent family is pond-themable; the hex is
|
||||
* validated here, WCAG conformance arises at render time via
|
||||
* `deriveAccentTokens` — never as a server-side check.
|
||||
*/
|
||||
export const pondThemeSchema = z.object({
|
||||
accent: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-f]{6}$/i, 'validation.invalid')
|
||||
.nullable()
|
||||
.default(null),
|
||||
});
|
||||
export type PondTheme = z.infer<typeof pondThemeSchema>;
|
||||
|
||||
export const pondSettingsSchema = z.object({
|
||||
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
||||
/** The pond default for the sidebar's page presentation (issue #108);
|
||||
@ -54,6 +69,7 @@ export const pondSettingsSchema = z.object({
|
||||
/** Per-pond opt-in to the built-in MCP endpoint (issue #105, default
|
||||
* off) — independent of the REST opt-in. */
|
||||
mcpEnabled: z.boolean().default(false),
|
||||
theme: pondThemeSchema.default({}),
|
||||
});
|
||||
export type PondSettings = z.infer<typeof pondSettingsSchema>;
|
||||
|
||||
@ -86,6 +102,7 @@ export const updatePondInputSchema = z
|
||||
commentPolicy: z.enum(COMMENT_POLICIES),
|
||||
apiEnabled: z.boolean(),
|
||||
mcpEnabled: z.boolean(),
|
||||
theme: pondThemeSchema,
|
||||
})
|
||||
.partial();
|
||||
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user