dorfteich/apps/web/src/theme/PondThemeSection.tsx
Claude Fable 5 b5d2a436e0
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
#186: pond accent theming — scoped derivation, cascade pond > user > default
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
2026-07-29 09:09:36 +02:00

123 lines
4.1 KiB
TypeScript

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>
);
}