dorfteich/packages/shared/src/ponds.ts
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

150 lines
5.3 KiB
TypeScript

import { z } from 'zod';
import { COMMENT_POLICIES } from './comments';
/**
* Pond schemas and views shared between api and web (issue #21).
* Ponds are the top-level content container; every self-registered
* person owns exactly one personal pond (data-model.md §ponds).
*/
export const POND_TYPES = ['personal', 'shared'] as const;
export type PondType = (typeof POND_TYPES)[number];
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
/** How the sidebar presents a pond's pages (issue #108): as the page tree
* (`folders`) or grouped under the label tree (`labels`). */
export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const;
export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number];
/** One font slot per ADR 0016; values reference the curated catalog. */
const fontSlotSchema = z.object({
family: z.string().min(1).max(80),
weight: z.number().int().min(100).max(900),
});
/** The three font slots, each defaulted to the vision fonts (ADR 0016). */
export const pondFontsSchema = z.object({
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
});
export type PondFonts = z.infer<typeof pondFontsSchema>;
/**
* Pond `settings` jsonb. Parsing `{}` yields the documented defaults
* (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code,
* 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);
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
fonts: pondFontsSchema.default({}),
/** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off):
* without it the pond and its content answer 404 through the API even
* for a token whose user could see them in the app. */
apiEnabled: z.boolean().default(false),
/** 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>;
/** The machine-access surfaces a pond can opt into (issues #104/#105). */
export type PondExposureFeature = 'api' | 'mcp';
export function pondFeatureEnabled(settings: PondSettings, feature: PondExposureFeature): boolean {
return feature === 'api' ? settings.apiEnabled : settings.mcpEnabled;
}
export const pondNameSchema = z
.string()
.trim()
.min(1, 'validation.required')
.max(80, 'validation.tooLong');
export const createPondInputSchema = z.object({
name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong').default(''),
});
export type CreatePondInput = z.infer<typeof createPondInputSchema>;
export const updatePondInputSchema = z
.object({
name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong'),
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
sidebarView: z.enum(SIDEBAR_VIEW_MODES),
fonts: pondFontsSchema,
commentPolicy: z.enum(COMMENT_POLICIES),
apiEnabled: z.boolean(),
mcpEnabled: z.boolean(),
theme: pondThemeSchema,
})
.partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
/** What the api returns for a pond; settings come fully defaulted. */
export interface PondView {
id: string;
slug: string;
name: string;
description: string;
type: PondType;
ownerId: string;
settings: PondSettings;
createdAt: string;
deletedAt: string | null;
}
const MAX_SLUG_LENGTH = 60;
/**
* Derives a URL-safe slug: German transliteration (ä→ae …), diacritics
* stripped, everything else collapsed to single hyphens. Returns '' when
* nothing survives — callers pick their fallback (e.g. the username).
* Uniqueness (numeric suffixes) is the caller's job, not slugify's.
*/
export function slugify(input: string): string {
return (
input
// Precompose first (#127): macOS file names arrive NFD-decomposed, and
// the umlaut digraphs below only match the precomposed forms.
.normalize('NFC')
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG_LENGTH)
.replace(/-+$/, '')
);
}