feat(webapp): wire isParallelSafe in Companion chat + Mission runner

Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:

  isParallelSafe: (name) =>
    AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'

Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.

Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:11:24 +02:00
parent a64a7e39cf
commit 54a12ffd5c
59 changed files with 5629 additions and 218 deletions

View file

@ -0,0 +1,44 @@
{
"name": "@mana/website-blocks",
"version": "1.0.0",
"private": true,
"description": "Block-tree primitives for the Mana website builder — Svelte components + Zod schemas, mode-aware rendering for editor/preview/public.",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./registry": {
"svelte": "./src/registry.ts",
"types": "./src/registry.ts",
"default": "./src/registry.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
}
},
"files": [
"src"
],
"scripts": {
"lint": "eslint .",
"test": "vitest run"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.16.0",
"typescript": "^5.7.3",
"vitest": "^2.0.0"
},
"dependencies": {
"zod": "^3.23.8"
}
}

View file

@ -0,0 +1,105 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { HeroProps } from './schema';
let { block, mode }: BlockRenderProps<HeroProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-hero"
class:wb-hero--left={block.props.align === 'left'}
class:wb-hero--center={block.props.align === 'center'}
class:wb-hero--bg-subtle={block.props.background === 'subtle'}
class:wb-hero--bg-gradient={block.props.background === 'gradient'}
data-mode={mode}
>
<div class="wb-hero__inner">
{#if block.props.eyebrow}
<p class="wb-hero__eyebrow">{block.props.eyebrow}</p>
{:else if isEdit}
<p class="wb-hero__eyebrow wb-placeholder">Eyebrow (optional)</p>
{/if}
<h1 class="wb-hero__title">
{block.props.title || (isEdit ? 'Klick in den Inspector, um den Titel zu setzen' : '')}
</h1>
{#if block.props.subtitle}
<p class="wb-hero__subtitle">{block.props.subtitle}</p>
{:else if isEdit}
<p class="wb-hero__subtitle wb-placeholder">Untertitel (optional)</p>
{/if}
{#if block.props.ctaLabel && block.props.ctaHref}
<a class="wb-hero__cta" href={block.props.ctaHref}>{block.props.ctaLabel}</a>
{:else if isEdit && (block.props.ctaLabel || block.props.ctaHref)}
<span class="wb-hero__cta wb-placeholder" aria-disabled="true">
{block.props.ctaLabel || 'Call-to-Action Label fehlt'}
</span>
{/if}
</div>
</section>
<style>
.wb-hero {
padding: 4rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-hero--bg-subtle {
background: rgba(255, 255, 255, 0.04);
}
.wb-hero--bg-gradient {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.18), rgba(168, 85, 247, 0.18));
}
.wb-hero__inner {
max-width: 64rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-hero--center .wb-hero__inner {
align-items: center;
text-align: center;
}
.wb-hero--left .wb-hero__inner {
align-items: flex-start;
text-align: left;
}
.wb-hero__eyebrow {
font-size: 0.875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
margin: 0;
}
.wb-hero__title {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.1;
margin: 0;
}
.wb-hero__subtitle {
font-size: 1.125rem;
line-height: 1.5;
opacity: 0.8;
max-width: 48rem;
margin: 0;
}
.wb-hero__cta {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.9);
color: white;
text-decoration: none;
font-weight: 500;
margin-top: 0.5rem;
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { HeroProps } from './schema';
let { block, onChange }: BlockInspectorProps<HeroProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Eyebrow</span>
<input
type="text"
value={block.props.eyebrow}
oninput={(e) => onChange({ eyebrow: e.currentTarget.value })}
placeholder="Optional — kleiner Text über dem Titel"
/>
</label>
<label class="wb-field">
<span>Titel *</span>
<input
type="text"
value={block.props.title}
oninput={(e) => onChange({ title: e.currentTarget.value })}
required
/>
</label>
<label class="wb-field">
<span>Untertitel</span>
<textarea
rows="3"
value={block.props.subtitle}
oninput={(e) => onChange({ subtitle: e.currentTarget.value })}
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>CTA Label</span>
<input
type="text"
value={block.props.ctaLabel}
oninput={(e) => onChange({ ctaLabel: e.currentTarget.value })}
placeholder="z.B. Los geht's"
/>
</label>
<label class="wb-field">
<span>CTA Link</span>
<input
type="url"
value={block.props.ctaHref}
oninput={(e) => onChange({ ctaHref: e.currentTarget.value })}
placeholder="https://…"
/>
</label>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as HeroProps['align'] })}
>
<option value="center">Zentriert</option>
<option value="left">Linksbündig</option>
</select>
</label>
<label class="wb-field">
<span>Hintergrund</span>
<select
value={block.props.background}
onchange={(e) => onChange({ background: e.currentTarget.value as HeroProps['background'] })}
>
<option value="none">Kein</option>
<option value="subtle">Dezent</option>
<option value="gradient">Gradient</option>
</select>
</label>
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field input,
.wb-field textarea,
.wb-field select {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
.wb-field textarea {
resize: vertical;
min-height: 4.5rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Hero from './Hero.svelte';
import HeroInspector from './HeroInspector.svelte';
import { HeroSchema, HERO_DEFAULTS, type HeroProps } from './schema';
export const heroBlockSpec: BlockSpec<HeroProps> = {
type: 'hero',
label: 'Hero',
icon: 'heading',
category: 'content',
schema: HeroSchema,
schemaVersion: 1,
defaults: HERO_DEFAULTS,
Component: Hero,
Inspector: HeroInspector,
};
export type { HeroProps };
export { HeroSchema, HERO_DEFAULTS };

View file

@ -0,0 +1,23 @@
import { z } from 'zod';
export const HeroSchema = z.object({
eyebrow: z.string().max(120).default(''),
title: z.string().min(1).max(240),
subtitle: z.string().max(480).default(''),
ctaLabel: z.string().max(60).default(''),
ctaHref: z.string().max(512).default(''),
align: z.enum(['left', 'center']).default('center'),
background: z.enum(['none', 'subtle', 'gradient']).default('subtle'),
});
export type HeroProps = z.infer<typeof HeroSchema>;
export const HERO_DEFAULTS: HeroProps = {
eyebrow: '',
title: 'Dein Titel',
subtitle: 'Eine kurze Beschreibung — was macht diese Seite relevant?',
ctaLabel: '',
ctaHref: '',
align: 'center',
background: 'subtle',
};

View file

@ -0,0 +1,28 @@
export type {
Block,
BlockMode,
BlockCategory,
BlockRenderProps,
BlockInspectorProps,
BlockSpec,
PropsOf,
InferProps,
} from './types';
export {
BLOCK_SPECS,
getBlockSpec,
requireBlockSpec,
getAllBlockSpecs,
validateBlockProps,
safeValidateBlockProps,
} from './registry';
export { heroBlockSpec, HeroSchema, HERO_DEFAULTS, type HeroProps } from './hero';
export {
richTextBlockSpec,
RichTextSchema,
RICH_TEXT_DEFAULTS,
type RichTextProps,
} from './richText';
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';

View file

@ -0,0 +1,68 @@
import type { BlockSpec } from './types';
import { heroBlockSpec } from './hero';
import { richTextBlockSpec } from './richText';
import { spacerBlockSpec } from './spacer';
/**
* The block registry single source of truth for every block type the
* website builder knows about. Editor insert palette, renderer, inspector,
* schema validation, and future AI tools all consume this map.
*
* Adding a new block = create a folder under `src/{type}/`, export a
* `BlockSpec` from its index, and list it here.
*/
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
heroBlockSpec,
richTextBlockSpec,
spacerBlockSpec,
] as unknown as readonly BlockSpec<unknown>[];
const BY_TYPE: Record<string, BlockSpec<unknown>> = (() => {
const map: Record<string, BlockSpec<unknown>> = {};
for (const spec of BLOCK_SPECS) {
if (map[spec.type]) {
throw new Error(`[website-blocks] duplicate block type "${spec.type}"`);
}
map[spec.type] = spec as BlockSpec<unknown>;
}
return map;
})();
export function getBlockSpec(type: string): BlockSpec<unknown> | undefined {
return BY_TYPE[type];
}
export function requireBlockSpec(type: string): BlockSpec<unknown> {
const spec = BY_TYPE[type];
if (!spec) throw new Error(`[website-blocks] unknown block type "${type}"`);
return spec;
}
export function getAllBlockSpecs(): readonly BlockSpec<unknown>[] {
return BLOCK_SPECS;
}
/**
* Validate props against a block type's schema. Returns the parsed props
* (with defaults applied) on success, or throws with the Zod error.
*/
export function validateBlockProps(type: string, props: unknown): unknown {
const spec = requireBlockSpec(type);
return spec.schema.parse(props);
}
/**
* Safe-validate: returns `{ success, data, error }` without throwing.
* Used at boundaries (submit endpoint, snapshot builder) where we want
* to collect all errors rather than fail on the first one.
*/
export function safeValidateBlockProps(
type: string,
props: unknown
): { success: true; data: unknown } | { success: false; error: unknown } {
const spec = getBlockSpec(type);
if (!spec) return { success: false, error: new Error(`Unknown block type "${type}"`) };
const parsed = spec.schema.safeParse(props);
if (parsed.success) return { success: true, data: parsed.data };
return { success: false, error: parsed.error };
}

View file

@ -0,0 +1,73 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { RichTextProps } from './schema';
let { block, mode }: BlockRenderProps<RichTextProps> = $props();
const paragraphs = $derived(
block.props.content
.split(/\n{2,}/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
);
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-richtext"
class:wb-richtext--left={block.props.align === 'left'}
class:wb-richtext--center={block.props.align === 'center'}
class:wb-richtext--sm={block.props.size === 'sm'}
class:wb-richtext--md={block.props.size === 'md'}
class:wb-richtext--lg={block.props.size === 'lg'}
data-mode={mode}
>
<div class="wb-richtext__inner">
{#if paragraphs.length === 0 && isEdit}
<p class="wb-placeholder">Leerer Text — öffne den Inspector und fang an zu schreiben.</p>
{:else}
{#each paragraphs as paragraph, i (i)}
<p>{paragraph}</p>
{/each}
{/if}
</div>
</section>
<style>
.wb-richtext {
padding: 2rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-richtext__inner {
max-width: 48rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-richtext--left .wb-richtext__inner {
text-align: left;
}
.wb-richtext--center .wb-richtext__inner {
text-align: center;
}
.wb-richtext p {
margin: 0;
line-height: 1.65;
}
.wb-richtext--sm p {
font-size: 0.9375rem;
}
.wb-richtext--md p {
font-size: 1.0625rem;
}
.wb-richtext--lg p {
font-size: 1.25rem;
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { RichTextProps } from './schema';
let { block, onChange }: BlockInspectorProps<RichTextProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Text</span>
<textarea
rows="10"
value={block.props.content}
oninput={(e) => onChange({ content: e.currentTarget.value })}
placeholder="Leere Zeile = neuer Absatz. Markdown folgt in M3."
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as RichTextProps['align'] })}
>
<option value="left">Linksbündig</option>
<option value="center">Zentriert</option>
</select>
</label>
<label class="wb-field">
<span>Schriftgröße</span>
<select
value={block.props.size}
onchange={(e) => onChange({ size: e.currentTarget.value as RichTextProps['size'] })}
>
<option value="sm">Klein</option>
<option value="md">Normal</option>
<option value="lg">Groß</option>
</select>
</label>
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field textarea,
.wb-field select {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
.wb-field textarea {
resize: vertical;
min-height: 6rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import RichText from './RichText.svelte';
import RichTextInspector from './RichTextInspector.svelte';
import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema';
export const richTextBlockSpec: BlockSpec<RichTextProps> = {
type: 'richText',
label: 'Text',
icon: 'text',
category: 'content',
schema: RichTextSchema,
schemaVersion: 1,
defaults: RICH_TEXT_DEFAULTS,
Component: RichText,
Inspector: RichTextInspector,
};
export type { RichTextProps };
export { RichTextSchema, RICH_TEXT_DEFAULTS };

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
export const RichTextSchema = z.object({
content: z.string().max(10_000).default(''),
align: z.enum(['left', 'center']).default('left'),
size: z.enum(['sm', 'md', 'lg']).default('md'),
});
export type RichTextProps = z.infer<typeof RichTextSchema>;
export const RICH_TEXT_DEFAULTS: RichTextProps = {
content: '',
align: 'left',
size: 'md',
};

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { SpacerProps } from './schema';
let { block, mode }: BlockRenderProps<SpacerProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<div
class="wb-spacer"
class:wb-spacer--sm={block.props.size === 'sm'}
class:wb-spacer--md={block.props.size === 'md'}
class:wb-spacer--lg={block.props.size === 'lg'}
class:wb-spacer--xl={block.props.size === 'xl'}
data-mode={mode}
>
{#if isEdit}
<span class="wb-spacer__label">Spacer ({block.props.size})</span>
{/if}
</div>
<style>
.wb-spacer {
display: flex;
align-items: center;
justify-content: center;
}
.wb-spacer--sm {
height: 1.5rem;
}
.wb-spacer--md {
height: 3rem;
}
.wb-spacer--lg {
height: 6rem;
}
.wb-spacer--xl {
height: 9rem;
}
.wb-spacer[data-mode='edit'] {
border: 1px dashed rgba(255, 255, 255, 0.1);
}
.wb-spacer__label {
font-size: 0.75rem;
opacity: 0.35;
letter-spacing: 0.05em;
text-transform: uppercase;
}
</style>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { SpacerProps } from './schema';
let { block, onChange }: BlockInspectorProps<SpacerProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Größe</span>
<select
value={block.props.size}
onchange={(e) => onChange({ size: e.currentTarget.value as SpacerProps['size'] })}
>
<option value="sm">Klein (1.5rem)</option>
<option value="md">Mittel (3rem)</option>
<option value="lg">Groß (6rem)</option>
<option value="xl">Sehr groß (9rem)</option>
</select>
</label>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field select {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Spacer from './Spacer.svelte';
import SpacerInspector from './SpacerInspector.svelte';
import { SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './schema';
export const spacerBlockSpec: BlockSpec<SpacerProps> = {
type: 'spacer',
label: 'Abstand',
icon: 'separator',
category: 'layout',
schema: SpacerSchema,
schemaVersion: 1,
defaults: SPACER_DEFAULTS,
Component: Spacer,
Inspector: SpacerInspector,
};
export type { SpacerProps };
export { SpacerSchema, SPACER_DEFAULTS };

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
export const SpacerSchema = z.object({
size: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
});
export type SpacerProps = z.infer<typeof SpacerSchema>;
export const SPACER_DEFAULTS: SpacerProps = {
size: 'md',
};

View file

@ -0,0 +1,92 @@
import type { Component } from 'svelte';
import type { ZodTypeAny, z } from 'zod';
/**
* Render modes for every block component.
*
* - `edit` Inside the editor. Shows inline-edit affordances (e.g. click
* a Hero title to edit it), may render placeholder copy for
* empty required fields.
* - `preview` Editor preview pane. Same rendering as `public` but inside
* the editor chrome (responsive preview, breakpoint switcher).
* - `public` Served to real visitors via SvelteKit SSR. No edit chrome,
* no placeholders only real data. This is the mode the
* published_snapshots blob is serialized for.
*/
export type BlockMode = 'edit' | 'preview' | 'public';
/**
* A single block in the tree. Props are block-type-specific and validated
* against the registered Zod schema at write time (in stores) and at
* publish time (in the snapshot builder).
*/
export interface Block<Props = unknown> {
id: string;
type: string;
props: Props;
schemaVersion: number;
order: number;
parentBlockId: string | null;
slotKey: string | null;
}
/**
* Category for grouping blocks in the insert palette.
*/
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
/**
* Props passed to every block renderer. `onEdit` is only present in
* `edit` mode consumers must guard with `if (mode === 'edit' && onEdit)`.
*/
export interface BlockRenderProps<Props = unknown> {
block: Block<Props>;
mode: BlockMode;
children?: Block[];
onEdit?: (patch: Partial<Props>) => void;
}
/**
* Props passed to every block inspector (right pane of the editor).
*/
export interface BlockInspectorProps<Props = unknown> {
block: Block<Props>;
onChange: (patch: Partial<Props>) => void;
}
/**
* Registered spec for one block type. The schema, renderer, inspector,
* and metadata are bundled the editor and public renderer consume the
* same spec, so drift is structurally impossible.
*/
export interface BlockSpec<Props = unknown> {
/** Stable type id, used in DB (`blocks.type`) and in code. */
type: string;
/** Human label shown in the insert palette. */
label: string;
/** Lucide icon name (or any icon id the editor knows how to render). */
icon: string;
/** Category for palette grouping. */
category: BlockCategory;
/** Zod schema defining valid props. Enforced at write + publish time. */
schema: ZodTypeAny;
/** Current schema version. Bump when the schema shape changes. */
schemaVersion: number;
/** Default prop values when a new block of this type is inserted. */
defaults: Props;
/** Svelte 5 component rendering the block in all three modes. */
Component: Component<BlockRenderProps<Props>>;
/** Svelte 5 component rendering the inspector form for this block. */
Inspector: Component<BlockInspectorProps<Props>>;
/**
* Optional upgraders: version N version N+1 prop transformer.
* Keyed by the SOURCE version (v1 v2 upgrader lives under key `1`).
*/
upgraders?: Record<number, (oldProps: unknown) => Props>;
}
/** Helper to infer props type from a spec's schema. */
export type PropsOf<Spec extends BlockSpec<unknown>> = Spec extends BlockSpec<infer P> ? P : never;
/** Helper to infer props type from a Zod schema. */
export type InferProps<S extends ZodTypeAny> = z.infer<S>;

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}