diff --git a/apps/api/src/ponds/ponds.e2e.db.test.ts b/apps/api/src/ponds/ponds.e2e.db.test.ts index 1fc8005..390b305 100644 --- a/apps/api/src/ponds/ponds.e2e.db.test.ts +++ b/apps/api/src/ponds/ponds.e2e.db.test.ts @@ -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') diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 60384cf..c379409 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -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 }, diff --git a/apps/web/e2e/pond-theme.spec.ts b/apps/web/e2e/pond-theme.spec.ts new file mode 100644 index 0000000..14864be --- /dev/null +++ b/apps/web/e2e/pond-theme.spec.ts @@ -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(); +}); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 85fde37..bbc3d76 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -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 ( - - {/* The page's actions render as icons in the TopBar (issue #101). */} - {actionsSlot.element && - createPortal( - 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, - )} -
-
- {/* The visible title is an input; give assistive tech the page - heading it expects on an article view (#166). */} -

{title || t('title.placeholder')}

- setTitle(event.target.value)} - onBlur={() => void saveTitle()} - /> -
- {/* Status line between the header and the article (#134): last update, - word count, reading time — reading mode only. */} - {mode === 'view' && page.data && ( - - )} -
- setShowAttachments(false)} - onWriteAccess={setCanWrite} - /> - {/* Side panels stack vertically in one column (M10 follow-up). */} - {(showLabels || showHistory) && ( -
- {showLabels && ( - setShowLabels(false)} - /> - )} - {showHistory && ( - setShowHistory(false)} /> - )} -
+ + + {/* The page's actions render as icons in the TopBar (issue #101). */} + {actionsSlot.element && + createPortal( + 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, )} -
- {/* "Linked from" appears below the content in read mode (issue #48); +
+
+ {/* The visible title is an input; give assistive tech the page + heading it expects on an article view (#166). */} +

{title || t('title.placeholder')}

+ setTitle(event.target.value)} + onBlur={() => void saveTitle()} + /> +
+ {/* Status line between the header and the article (#134): last update, + word count, reading time — reading mode only. */} + {mode === 'view' && page.data && ( + + )} +
+ setShowAttachments(false)} + onWriteAccess={setCanWrite} + /> + {/* Side panels stack vertically in one column (M10 follow-up). */} + {(showLabels || showHistory) && ( +
+ {showLabels && ( + setShowLabels(false)} + /> + )} + {showHistory && ( + setShowHistory(false)} /> + )} +
+ )} +
+ {/* "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' && } - {mode === 'view' && } - {mode === 'view' && ( - - )} -
- + {mode === 'view' && } + {mode === 'view' && } + {mode === 'view' && ( + + )} +
+
+ ); } diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 43f7c3f..91e6361 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -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} /> + )} {canModify && } diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 6a29ddb..229ce07 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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 ( - - - - - ); -} - /** 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 diff --git a/apps/web/src/theme/AccentSwatches.tsx b/apps/web/src/theme/AccentSwatches.tsx new file mode 100644 index 0000000..7371a22 --- /dev/null +++ b/apps/web/src/theme/AccentSwatches.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/web/src/theme/PondThemeScope.tsx b/apps/web/src/theme/PondThemeScope.tsx new file mode 100644 index 0000000..ebaacdb --- /dev/null +++ b/apps/web/src/theme/PondThemeScope.tsx @@ -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 `