#186: pond accent theming (ADR 0018 stage C) #187

Merged
stwaidele merged 1 commits from feat/186-pond-accent into main 2026-07-29 10:11:45 +02:00
13 changed files with 435 additions and 97 deletions
Showing only changes of commit b5d2a436e0 - Show all commits

View File

@ -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')

View File

@ -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 },

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

View File

@ -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,85 +539,87 @@ export function PageEditorPage(): React.JSX.Element {
}
return (
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
{/* The page's actions render as icons in the TopBar (issue #101). */}
{actionsSlot.element &&
createPortal(
<PageActions
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
showAttachments={showAttachments}
onToggleAttachments={() => setShowAttachments((open) => !open)}
hasTools={hasPageTools(pagePlugins.data)}
showPageTools={showPageTools}
onTogglePageTools={() => setShowPageTools((open) => !open)}
showLabels={showLabels}
onToggleLabels={() => setShowLabels((open) => !open)}
showHistory={showHistory}
onToggleHistory={() => setShowHistory((open) => !open)}
/>,
actionsSlot.element,
)}
<div className="editor-page">
<div className="editor-page__header">
{/* The visible title is an input; give assistive tech the page
heading it expects on an article view (#166). */}
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
aria-label={t('title.label')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
</div>
{/* Status line between the header and the article (#134): last update,
word count, reading time reading mode only. */}
{mode === 'view' && page.data && (
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
)}
<div className="editor-page__body">
<PageEditor
page={resolved}
mode={mode}
pondSlug={pondSlug}
showAttachments={showAttachments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onWriteAccess={setCanWrite}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
<div className="editor-page__panels">
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && (
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
<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 &&
createPortal(
<PageActions
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
showAttachments={showAttachments}
onToggleAttachments={() => setShowAttachments((open) => !open)}
hasTools={hasPageTools(pagePlugins.data)}
showPageTools={showPageTools}
onTogglePageTools={() => setShowPageTools((open) => !open)}
showLabels={showLabels}
onToggleLabels={() => setShowLabels((open) => !open)}
showHistory={showHistory}
onToggleHistory={() => setShowHistory((open) => !open)}
/>,
actionsSlot.element,
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48);
<div className="editor-page">
<div className="editor-page__header">
{/* The visible title is an input; give assistive tech the page
heading it expects on an article view (#166). */}
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
aria-label={t('title.label')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
</div>
{/* Status line between the header and the article (#134): last update,
word count, reading time reading mode only. */}
{mode === 'view' && page.data && (
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
)}
<div className="editor-page__body">
<PageEditor
page={resolved}
mode={mode}
pondSlug={pondSlug}
showAttachments={showAttachments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onWriteAccess={setCanWrite}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
<div className="editor-page__panels">
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && (
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48);
the inline discussion (issue #133) and the local neighborhood graph
(issue #113) follow it, in that order. */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && (
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
)}
</div>
</PondFontScope>
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && (
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
)}
</div>
</PondFontScope>
</PondThemeScope>
);
}

View File

@ -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} />}

View File

@ -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

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

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

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

View File

@ -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).

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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>;