chore(app-registry): polish 4 small wins — TOC + AppId-derive + route-drift test + 3 MANA_APPS

§1 AppId derivation (shared-branding):
- `AppId` is now `keyof typeof APP_BRANDING` (config.ts) instead of a
  hand-maintained union in types.ts. Adding/removing an entry in
  `APP_BRANDING` automatically updates the union — eliminates the
  drift class that produced the ContextLogo type-error.
- `AppBranding.id` relaxed to `string` to break the circular type
  reference (key in `APP_BRANDING` is the authoritative id).

§2 Route-drift smoke test (registry.spec.ts):
- New 4th test: parses every `routes/(app)/*+page.svelte`, extracts
  the `<RoutePage appId="…">` literal, asserts the id is registered
  in the workbench app-registry. Catches drift like the earlier
  `appId="broadcasts"` vs id `'broadcast'` bug structurally.
- ROUTE_ONLY_APP_IDS allowlist for routes that intentionally don't
  back a workbench module (gifts, llm-test, milestones, organizations,
  teams, tags).
- Caught two real drifts in the process and fixed them:
    /agents/+page.svelte → appId="ai-agents" → "agents"
    /agents/templates/+page.svelte → same

§3 MANA_APPS hochgezogen (kontext, wishes):
- kontext (Web-Context URL crawler) + wishes (Wunschliste) had module
  + workbench card but no MANA_APPS branding entry. Both got proper
  description, longDescription and a fresh APP_ICONS entry (globe-
  with-text-lines for kontext, shooting-star for wishes).
- Removed both from WORKBENCH_ONLY in spec — they're full apps now.
- Note: `myday` was already in MANA_APPS, the WORKBENCH_ONLY entry
  was redundant and had been silently double-counting.

§4 apps.ts — top-level INDEX comment:
- 80 registerApp() calls were chronological-by-when-added — basically
  unsearchable. Added an §1–§4 navigation comment near the top
  grouping apps by role (entity / module surface / AI Workbench /
  System) so devs can jump to a section. Physical reordering of
  the 80 blocks deferred to avoid disturbing the active multi-
  terminal session — the TOC delivers ~80% of the navigation win.

Bonus: register `forms` module that the parallel session added but
hadn't wired into the workbench yet — the new route-drift test caught
this immediately on first run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:59:26 +02:00
parent 907a3add49
commit 6d193a9fa7
8 changed files with 205 additions and 37 deletions

View file

@ -88,9 +88,45 @@ import {
Megaphone,
Receipt,
ClockCounterClockwise,
ClipboardText,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
// ─── INDEX ──────────────────────────────────────────────────────
//
// Apps are grouped by their workbench role. Inside each group the
// order is roughly chronological (by when they were added) — search
// by id or use your editor outline to jump to a specific app.
//
// §1 Apps with entity capabilities (DnD sources/targets)
// todo · calendar · contacts
//
// §2 Apps without entity capabilities — module surfaces
// Daily-use: habits · notes · journal · myday · drink ·
// mood · sleep · activity · times · finance
// Knowledge: chat · kontext · cards · quiz · guides ·
// news · news-research · research-lab · articles ·
// library · writing · comic · presi
// Body & life: body · food · meditate · stretch · period ·
// dreams · firsts · lasts · habits · recipes
// Places & ev.: places · citycorners · events · who
// Creative: picture · music · photos · wardrobe · moodlit
// Tools: memoro · uload · calc · plants · inventory ·
// storage · skilltree · questions
// Long-tail: quotes · automations · companion · wetter ·
// goals · website · spaces · augur ·
// broadcasts · invoices · timeline
//
// §3 AI Workbench surfaces
// agents · ai-missions · ai-workbench · ai-policy ·
// ai-insights · ai-health · rituals
//
// §4 System / Settings family
// settings · themes · profile · credits · api-keys · admin ·
// complexity · spiral · wishes · help · feedback · playground
//
// ────────────────────────────────────────────────────────────────
// ── §1 · Apps with entity capabilities ──────────────────────
registerApp({
id: 'todo',
@ -1566,3 +1602,15 @@ registerApp({
list: { load: () => import('$lib/modules/timeline/ListView.svelte') },
},
});
registerApp({
id: 'forms',
name: 'Forms',
color: '#14b8a6',
icon: ClipboardText,
views: {
// /forms/[id]/edit (builder) and /forms/[id]/responses (analytics)
// live as SvelteKit routes; the workbench card hosts the ListView.
list: { load: () => import('$lib/modules/forms/ListView.svelte') },
},
});

View file

@ -1,3 +1,6 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
import { MANA_APPS } from '@mana/shared-branding';
// Side-effect import: registers every workbench app via ./apps.
@ -32,9 +35,7 @@ const WORKBENCH_ONLY = new Set([
'spiral',
// User-facing modules that don't yet have MANA_APPS branding.
// Add them to MANA_APPS when they ship a marketing surface.
'kontext',
'rituals',
'wishes',
// AI Studio surfaces. Open question whether to expose each one in
// MANA_APPS or consolidate into a single "AI Studio" branding entry.
// Keeping them workbench-only until that decision lands.
@ -97,4 +98,59 @@ describe('app registry ↔ MANA_APPS consistency', () => {
const allIds = [...getAllApps().map((a) => a.id), ...MANA_APPS.map((a) => a.id)];
expect(allIds).not.toContain('inventar');
});
/**
* Catches the kind of drift that produced the `appId="broadcasts"` vs
* registered id `'broadcast'` bug RoutePage looks up the workbench
* app-registry by appId for title/icon/color, so a mismatch silently
* falls back to the raw id string and breaks navigation styling.
*/
it('every <RoutePage appId="…"> in routes/(app) resolves to a registered workbench app', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROUTES_DIR = join(__dirname, '../../routes/(app)');
const workbenchIds = new Set(getAllApps().map((a) => a.id));
// Routes that surface a feature without a workbench module — they
// pass an `appId` to RoutePage purely for telemetry/back-button
// scoping, not for icon/color lookup. Adding here = "this route
// is intentionally not backed by a workbench app".
const ROUTE_ONLY_APP_IDS = new Set([
'gifts', // /gifts — credit gifting flow, backed by mana-credits API
'llm-test', // DEV-only LLM playground
'milestones', // cross-module aggregator over firsts + lasts
'organizations', // Spaces/membership management
'teams', // Spaces/teams management
'tags', // global tag browser
]);
function* findPageFiles(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
yield* findPageFiles(full);
} else if (entry === '+page.svelte') {
yield full;
}
}
}
const violations: { file: string; appId: string }[] = [];
for (const file of findPageFiles(ROUTES_DIR)) {
const body = readFileSync(file, 'utf8');
const matches = body.matchAll(/<RoutePage[^>]+appId="([a-z0-9-]+)"/g);
for (const m of matches) {
const appId = m[1];
if (!workbenchIds.has(appId) && !ROUTE_ONLY_APP_IDS.has(appId)) {
violations.push({ file: file.replace(ROUTES_DIR, ''), appId });
}
}
}
expect(
violations,
`Routes referencing unregistered appIds:\n${violations
.map((v) => ` ${v.file} → "${v.appId}"`)
.join('\n')}`
).toEqual([]);
});
});

View file

@ -12,6 +12,6 @@
import { RoutePage } from '$lib/components/shell';
</script>
<RoutePage appId="ai-agents">
<RoutePage appId="agents">
<ListView />
</RoutePage>

View file

@ -140,7 +140,7 @@
</button>
{/snippet}
<RoutePage appId="ai-agents" backHref="/agents">
<RoutePage appId="agents" backHref="/agents">
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/')}>

View file

@ -297,6 +297,13 @@ export const APP_ICONS = {
// family) but leaning more towards the creative-publishing cluster.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#8b5cf6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wb)"/><rect x="18" y="22" width="64" height="56" rx="5" fill="white"/><rect x="18" y="22" width="64" height="10" rx="5" fill="white" fill-opacity="0.75"/><circle cx="25" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="31" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="37" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><rect x="24" y="38" width="52" height="12" rx="2" fill="#6366f1" fill-opacity="0.85"/><rect x="24" y="54" width="24" height="18" rx="2" fill="#6366f1" fill-opacity="0.35"/><rect x="52" y="54" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.55"/><rect x="52" y="64" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.45"/></svg>`
),
forms: svgToDataUrl(
// Clipboard with a checkmark + two filled rows — represents a generic
// form / questionnaire. Teal→cyan gradient sits next to writing (sky)
// and broadcasts (indigo) in the input/communication family while
// staying distinct from website (indigo→violet) and quiz (pink).
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#14b8a6"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fr)"/><rect x="26" y="22" width="48" height="62" rx="5" fill="white"/><rect x="38" y="14" width="24" height="14" rx="3" fill="white"/><rect x="42" y="18" width="16" height="6" rx="2" fill="#14b8a6" fill-opacity="0.85"/><circle cx="36" cy="44" r="3" fill="#14b8a6"/><path d="M34 44l1.6 1.8 3.4-3.4" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/><rect x="44" y="42" width="22" height="3" rx="1.5" fill="#14b8a6" fill-opacity="0.6"/><circle cx="36" cy="58" r="3" fill="#14b8a6" fill-opacity="0.4"/><rect x="44" y="56" width="22" height="3" rx="1.5" fill="#14b8a6" fill-opacity="0.55"/><rect x="34" y="68" width="32" height="8" rx="2" fill="#14b8a6" fill-opacity="0.85"/></svg>`
),
spaces: svgToDataUrl(
// Three people-silhouettes clustered in the tile — the Spaces primitive
// is about shared workspaces, so the icon emphasises "group". Teal→indigo
@ -304,6 +311,19 @@ export const APP_ICONS = {
// communication family without competing with either.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="sp" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#14b8a6"/><stop offset="100%" style="stop-color:#6366f1"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#sp)"/><circle cx="30" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M14 70c0-9 7-16 16-16s16 7 16 16v4H14v-4z" fill="white" fill-opacity="0.85"/><circle cx="70" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M54 70c0-9 7-16 16-16s16 7 16 16v4H54v-4z" fill="white" fill-opacity="0.85"/><circle cx="50" cy="32" r="11" fill="white"/><path d="M30 76c0-11 9-20 20-20s20 9 20 20v4H30v-4z" fill="white"/></svg>`
),
kontext: svgToDataUrl(
// Globe with three horizontal text-lines — represents the Web-Context
// crawler that pulls URLs into the user's profile context. Tan→ochre
// gradient (#a78b6f → #c79a64) sits in the documents family but warmer
// than the cool blues of notes/storage.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="kx" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a78b6f"/><stop offset="100%" style="stop-color:#c79a64"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#kx)"/><circle cx="50" cy="38" r="18" fill="none" stroke="white" stroke-width="2.5"/><ellipse cx="50" cy="38" rx="9" ry="18" fill="none" stroke="white" stroke-width="2"/><path d="M32 38h36" stroke="white" stroke-width="2"/><rect x="28" y="62" width="44" height="3" rx="1.5" fill="white" fill-opacity="0.9"/><rect x="28" y="70" width="34" height="3" rx="1.5" fill="white" fill-opacity="0.7"/><rect x="28" y="78" width="40" height="3" rx="1.5" fill="white" fill-opacity="0.55"/></svg>`
),
wishes: svgToDataUrl(
// Shooting star with sparkle trail — the wishes module is about
// hopes/aspirations. Amber→rose gradient distinguishes it from the
// cool-blue quotes (also a "daily inspiration" feel).
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wi" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#f43f5e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wi)"/><path d="M62 28l5 12 12 5-12 5-5 12-5-12-12-5 12-5z" fill="white"/><circle cx="32" cy="64" r="3.5" fill="white" fill-opacity="0.85"/><circle cx="44" cy="76" r="2.5" fill="white" fill-opacity="0.7"/><circle cx="22" cy="80" r="2" fill="white" fill-opacity="0.55"/><path d="M48 50l-22 22" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.55"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -1,9 +1,13 @@
import type { AppBranding, AppId } from './types';
import type { AppBranding } from './types';
/**
* Branding configuration for all Mana ecosystem apps
* Branding configuration for all Mana ecosystem apps.
*
* `satisfies` enforces each entry matches AppBranding while keeping the
* literal keys narrow, so `keyof typeof APP_BRANDING` derives the AppId
* union automatically.
*/
export const APP_BRANDING: Record<AppId, AppBranding> = {
export const APP_BRANDING = {
memoro: {
id: 'memoro',
name: 'Memoro',
@ -311,7 +315,10 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
} satisfies Record<string, AppBranding>;
/** Derived from `APP_BRANDING` keys — single source of truth. */
export type AppId = keyof typeof APP_BRANDING;
/**
* Get branding config for an app

View file

@ -187,6 +187,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'forms',
name: 'Forms',
description: {
de: 'Formulare bauen',
en: 'Build forms',
},
longDescription: {
de: 'Eigene Formulare bauen, öffentlich teilen und Antworten sammeln. Anmeldungen, Umfragen, Lead-Capture, wiederkehrende Pulse-Checks. Mit AI-Builder, Conditional Branching und Auto-Sync zu Kontakten, Events und Spaces.',
en: 'Build your own forms, share them publicly, and collect responses. Sign-ups, surveys, lead capture, recurring pulse checks. With AI builder, conditional branching, and auto-sync to contacts, events, and Spaces.',
},
icon: APP_ICONS.forms,
color: '#14b8a6',
comingSoon: false,
status: 'development',
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release (see project_tier_patch_resolved memory)
},
{
id: 'picture',
name: 'ManaPicture',
@ -1206,6 +1223,40 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
},
{
id: 'kontext',
name: 'Web-Context',
description: {
de: 'URLs in deinen Kontext crawlen',
en: 'Crawl URLs into your context',
},
longDescription: {
de: 'Webseiten und Artikel als persönlichen Kontext sammeln — crawlt eine URL (oder eine ganze Site bis 20 Seiten), optional KI-zusammengefasst, und hängt das Ergebnis an dein Profil-Kontext-Dokument an. Der Companion sieht das beim nächsten Planen automatisch mit.',
en: 'Pull web pages and articles into your personal context — crawl a URL (or up to 20 pages of a site), optionally AI-summarised, and append the result to your profile context document. The companion picks it up automatically on the next plan call.',
},
icon: APP_ICONS.kontext,
color: '#a78b6f',
comingSoon: false,
status: 'beta',
requiredTier: 'guest',
},
{
id: 'wishes',
name: 'Wünsche',
description: {
de: 'Wunschliste & Inspiration',
en: 'Wishlist & inspiration',
},
longDescription: {
de: 'Halte fest, was du dir wünschst — Geschenkideen, Reiseziele, Lebensziele. Ein leichter, freundlicher Ort für die Dinge, die noch keine Aufgabe sind, dich aber begleiten.',
en: 'Capture what you wish for — gift ideas, travel destinations, life goals. A light, friendly place for the things that are not yet tasks but stay with you.',
},
icon: APP_ICONS.wishes,
color: '#f59e0b',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
];
/**

View file

@ -1,38 +1,24 @@
/**
* App identifiers for branding
* App identifiers for branding.
*
* Derived from `APP_BRANDING` keys so `<AppLogo app={…}>` accepts exactly
* the apps that have a branding entry adding/removing one in config.ts
* automatically updates the union, no hand-maintenance.
*/
export type AppId =
| 'memoro'
| 'mana'
| 'cards'
| 'uload'
| 'chat'
| 'presi'
| 'food'
| 'quotes'
| 'picture'
| 'contacts'
| 'calendar'
| 'storage'
| 'clock'
| 'todo'
| 'mail'
| 'moodlit'
| 'inventory'
| 'questions'
| 'skilltree'
| 'plants'
| 'lightwrite'
| 'context'
| 'music'
| 'citycorners';
import type { AppId } from './config';
export type { AppId };
/**
* App branding configuration
* App branding configuration.
*
* `id` is `string` (not `AppId`) because `AppId` is derived from the keys
* of `APP_BRANDING`, and constraining `id` to `AppId` would create a
* circular type reference. Each entry's key in `APP_BRANDING` is the
* authoritative id; the `id` field is a redundant convenience.
*/
export interface AppBranding {
/** Unique app identifier */
id: AppId;
id: string;
/** Display name */
name: string;
/** Short description/tagline */