Prima Get an API key

Module kit / UI design system

App shell and navigation

ui-design-system/ui/appshell-navigationlive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton
Renders no backend module: a design-system row
Entry alchemy_labs/frontend/src/components/AppShell.tsx
Reference implementation
  • alchemy_labs/frontend/src/components/AppShell.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/RailTooltip.tsx
  • alchemy_labs/frontend/src/components/__tests__/nav.test.ts
  • alchemy_labs/frontend/src/index.css
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-11
Version 1

Purpose

Member on /forms: the shell around an empty module page member server rendered, hydrated in view

Meine Formulare

Erstelle und verwalte deine Lead-Capture-Formulare

Noch keine Formulare

Erstelle dein erstes Formular, um Leads zu erfassen und mit deinen Kunden in Kontakt zu treten.

One frame around every private page: an icon rail of modules, a contextual column of the active module's views, the page, and a command palette that jumps anywhere. The invariant that makes it trustworthy is that a single data structure, DOMAINS in nav.tsx, is the information architecture: the rail, the column, the palette, the active state and the Home tiles all derive from it, so a route cannot exist in one and be missing from another.

It makes the identity foundation visible (the signed-in user, the workspace list and switcher, logout) through S2's AuthContext and WorkspaceContext; it owns no route of its own.

When to use

Every surface: SPA recipe renders inside it; a page that renders outside <Navigation> has no chrome, no workspace switcher and no palette. Trigger phrases: "add a module to the rail", "the sub-menu for bookings", "hide this from clients", "admin-only page", "Cmd-K".

Do not use it for public pages (login, the hosted form, the booking shell), for the Prima chat surface, or for an Astro site. Do not add a second shell: AdminNavigation.tsx still exists at fd8558f and is scheduled for retirement, not for reuse.

Stack assumptions

S1 tokens (paper, ink, ink-60, ink-40, line, primary-*, purple-*, shadow-lift, shadow-pop, animate-fade-in) and S2 for the provider tree it sits in. Pinned per S2: react ^19.1.1, react-router-dom ^7.8.2, tailwindcss ^3.4.17. Data-fetch style: none of its own; it reads useAuth(), useWorkspace(), usePrima(), useDeskEmployee() and useOnboarding(). Copy: the shell's chrome is English ("Workspace", "Search", "Jump to anything", "No matches.") with two German strings ("Zeig mir die App", "Abmelden"); page content is du-Form German.

Data model

Domain (nav.tsx lines 47 to 70): id: DomainId, label, blurb? (one line in the user's words, shown on Home tiles), codename?, group: 'top' | 'attract' | 'sell' | 'understand' | 'finances' | 'system' | 'agency', paths: string[] (real route prefixes), items: { label, to, end?, employeeOnly? }[], live?, adminOnly?, hidden?.

DOMAINS at fd8558f (lines 77 to 347), in order: home (top), prima (top, hidden: true), activity (top), contacts (top), forms (attract), campaigns (agency, adminOnly), store (sell), bookings (sell), insights (understand), money (agency, adminOnly), desk (system, with four employeeOnly items), settings (system), projects, studio, admin (agency, adminOnly). GROUP_ORDER (line 349) fixes the rail order. activeDomain(path) (367 to 382) picks the longest matching prefix and falls back to home.

Shell state (AppShell.tsx lines 45 to 60): paletteOpen, wsOpen, the memoised visibleDomains (filters hidden and adminOnly against user.isAdmin or the public demo account), visibleItems (filters employeeOnly against useDeskEmployee()), groups, showWsSlug (slugs shown only when two workspaces share a name), isFullBleed (line 61: /forms/new, /forms/edit/*, /admin/forms/distillation*). Browser storage: none of its own (auth and workspace keys are S2's).

API surface

No direct call. The contexts it reads are fed by GET /api/auth/me (user, isAdmin, surfaces), the workspace list and switchWorkspace (S2 API surface), and the desk-employee check behind useDeskEmployee(). Logout calls useAuth().logout() then navigates to /login. Nothing in the shell writes.

Application logic

Cmd-K palette open palette-open client only
  1. A private page renders <Navigation>{content}</Navigation>; Navigation delegates to AppShell; AppShell mounts OnboardingProvider and AppShellBody.
  2. AppShellBody resolves activeDomain(loc.pathname), filters domains and items, groups the rail by GROUP_ORDER, and registers the Cmd-K and Escape key handlers (lines 89 to 100).
  3. The rail renders one RailButton per visible domain; home is folded into the purple brand header (a home button on the rail below md); prima opens the slide-over instead of navigating (line 186); activity carries a badge that is hard-coded to 0 today (line 85).
  4. The contextual column renders the workspace switcher (the panel opens on every click and reports an empty list, never renders nothing: comment at lines 265 to 271), the domain header (label, codename, a "concept" marker when live is false), the items as NavLink.ctx-link (index.css 271 to 278: active gets bg-purple-100 text-purple-700), and the account row with logout.
  5. main renders {children} with page padding, or without it for the full-bleed editor routes.
  6. Cmd-K opens CommandPalette: targets are every visible domain plus its items (except "Overview"), filtered by substring, first 8 shown, Enter goes to the first, Escape or a backdrop click closes.
  7. Resize below md (768 px): the contextual column and the brand header disappear, the rail stays; below sm (640 px) the whole sidebar is hidden and the page is content only.

Invariants

  • DOMAINS is the only IA; paths[] are real routes (/accounting, not a clean alias), so deep links never break (64 path-resolution cases in nav.test.ts).
  • Every private page renders inside <Navigation>; a page without it is a defect visible at first glance.
  • hidden, adminOnly and employeeOnly remove entries from the rail and column and nothing else: the routes stay mounted and the pages behind them bounce (AdminRoute to /, desk pages to /desk) and the APIs answer 403 or 404. Nothing in the nav model is load-bearing for authorisation.
  • The Prima domain is hidden (hidden: true, since 2026-09-06) but its routes resolve; flipping the flag re-lists it.
  • The shell paints with tokens only: bg-paper, text-ink, border-line, bg-primary-500, hover:bg-primary-50, shadow-lift, shadow-pop. No hex literal (measured: grep -c "#" AppShell.tsx hits only SVG path data and comments).

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

Admin and desk employee: the agency group on the rail, four more desk items admin server rendered, hydrated in view

Meine Formulare

Erstelle und verwalte deine Lead-Capture-Formulare

Noch keine Formulare

Erstelle dein erstes Formular, um Leads zu erfassen und mit deinen Kunden in Kontakt zu treten.

Workspace panel open, three workspaces, two sharing a name workspace-open server rendered, hydrated in view

Meine Formulare

Erstelle und verwalte deine Lead-Capture-Formulare

Noch keine Formulare

Erstelle dein erstes Formular, um Leads zu erfassen und mit deinen Kunden in Kontakt zu treten.

ui-design-system/ui/appshell-navigation/demo.tsx
/**
 * Live demo for ui-design-system/ui/appshell-navigation.
 *
 * The three-column shell of Labs' AppShell.tsx (fd8558f, 512 lines) rebuilt
 * as a presentational component. The real shell reads useAuth, useWorkspace,
 * usePrima, useDeskEmployee, useOnboarding and React Router; this rebuild
 * lifts the same tree (brand header, icon rail, contextual column, main,
 * command palette) into a component that takes the user, the workspaces, the
 * navigation model and the open panels as props, and wraps it in a
 * MemoryRouter so NavLink and useLocation behave as in the app.
 *
 * Differences from the app, all so the shell fits a frame instead of the
 * viewport: the root is min-h-[560px] instead of min-h-screen and the
 * sidebar, rail and column take the root's height from flex stretch instead
 * of sticky h-screen with h-full inside it (the docs stage sets only a
 * min-height, so a percentage height has nothing to resolve against; with
 * stretch the white column runs the full frame and the account row is
 * pinned by its mt-auto, as in the app), the palette overlay is absolute
 * instead of fixed, the brand header carries an inline mark instead of the
 * logo file, the Prima slide-over is not mounted, and the Cmd-K and
 * outside-click handlers are local to the frame. Pixel-literal type sizes
 * are kept as they are in the source (recipe Gotchas).
 *
 * The sample pages inside the shell are built from the library, not drawn
 * to look like it (corrected 2026-09-12): the member state is the empty
 * Forms overview as Forms.tsx lines 403 to 455 compose it (Heading level 1,
 * Text muted, the HIN quick link, Card variant glass with CardContent,
 * Button variant primary); the full-bleed state is the form builder's top
 * bar as TallyFormBuilder/index.tsx lines 303 to 411 compose it (ghost and
 * primary Buttons in size sm around an Input inputSize sm). The copy is the
 * app's; the icons are the same lucide glyphs.
 */
import React, { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { MemoryRouter, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronLeft, Eye, FileText, Mail, Plus, RefreshCw, Save, Settings } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Button, Card, CardContent, Heading, Input, Text } from '@prima-modules/alchemy-ui';
import { DOMAINS, GROUP_ORDER, activeDomain, DomainIcon, type Domain } from './nav';

/* ---- props ----------------------------------------------------------- */

export interface ShellUser {
  name: string;
  email: string;
  isAdmin: boolean;
  isDeskEmployee?: boolean;
}

export interface ShellWorkspace {
  id: string;
  name: string;
  slug: string;
}

export interface ShellProps {
  domains: Domain[];
  user: ShellUser;
  workspaces: ShellWorkspace[];
  currentWorkspaceId: string;
  activePath: string;
  paletteOpen?: boolean;
  wsOpen?: boolean;
  fullBleed?: boolean;
  /** The current module has a tour: the rail shows "Zeig mir die App". */
  hasTour?: boolean;
  /** The word next to the mark in the brand header. */
  brandWord?: string;
  /** Suppress the left sidebar (icon rail + contextual column) entirely. */
  hideChrome?: boolean;
  children?: ReactNode;
}

export interface Props extends Omit<ShellProps, 'children'> {
  page: 'forms-empty' | 'editor';
}

/* ---- sample data ------------------------------------------------------ */

const member: ShellUser = { name: 'Mira Keller', email: '[email protected]', isAdmin: false };
const admin: ShellUser = { name: 'Jonas Weber', email: '[email protected]', isAdmin: true, isDeskEmployee: true };
const oneWorkspace: ShellWorkspace[] = [{ id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' }];
const threeWorkspaces: ShellWorkspace[] = [
  { id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' },
  { id: 'ws_2', name: 'Mein Workspace', slug: 'mein-workspace-2' },
  { id: 'ws_3', name: 'Mein Workspace', slug: 'mein-workspace-3' },
];

const base: Props = {
  domains: DOMAINS,
  user: member,
  workspaces: oneWorkspace,
  currentWorkspaceId: 'ws_1',
  activePath: '/forms',
  hasTour: true,
  brandWord: 'Your App',
  page: 'forms-empty',
};

export const states = defineStates<Props>({
  member: { label: 'Member on /forms: the shell around an empty module page', props: base, height: 640, viewport: 1440 },
  admin: { label: 'Admin and desk employee: the agency group on the rail, four more desk items', props: { ...base, user: admin, activePath: '/desk' }, height: 640, viewport: 1440 },
  'palette-open': { label: 'Cmd-K palette open', props: { ...base, paletteOpen: true }, height: 640, viewport: 1440, hydrate: 'only' },
  'workspace-open': { label: 'Workspace panel open, three workspaces, two sharing a name', props: { ...base, workspaces: threeWorkspaces, wsOpen: true }, height: 640, viewport: 1440 },
  'full-bleed': { label: 'Full-bleed editor route: no page padding', props: { ...base, activePath: '/forms/new', page: 'editor' }, height: 640, viewport: 1440 },
});

/* ---- tooltip (RailTooltip.tsx, fd8558f) -------------------------------- */

function Tooltip({ content, delay = 120, children }: { content: ReactNode; delay?: number; children: React.ReactElement }) {
  const [visible, setVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const triggerRef = useRef<HTMLElement>(null);
  const tipRef = useRef<HTMLDivElement>(null);

  const place = () => {
    if (!triggerRef.current || !tipRef.current) return;
    const t = triggerRef.current.getBoundingClientRect();
    const tip = tipRef.current.getBoundingClientRect();
    const spacing = 10;
    const pad = 8;
    let top = t.top + (t.height - tip.height) / 2;
    let left = t.right + spacing;
    if (top < pad) top = pad;
    if (top + tip.height > window.innerHeight - pad) top = window.innerHeight - tip.height - pad;
    if (left + tip.width > window.innerWidth - pad) left = t.left - tip.width - spacing;
    setCoords({ top, left });
  };
  const show = () => {
    timeoutRef.current = setTimeout(() => {
      setVisible(true);
      requestAnimationFrame(place);
    }, delay);
  };
  const hide = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setVisible(false);
  };
  useEffect(() => {
    if (!visible) return;
    place();
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    return () => {
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
    };
  }, [visible]);

  const trigger = React.cloneElement(children as React.ReactElement<any>, {
    ref: triggerRef,
    onMouseEnter: show,
    onMouseLeave: hide,
    onFocus: show,
    onBlur: hide,
  });
  return (
    <>
      {trigger}
      {visible &&
        createPortal(
          <div
            ref={tipRef}
            role="tooltip"
            style={{ position: 'fixed', top: coords.top, left: coords.left, zIndex: 9999 }}
            className="pointer-events-none rounded-lg bg-ink px-3 py-2 text-left text-white shadow-pop font-sans"
          >
            {content}
          </div>,
          document.body,
        )}
    </>
  );
}

/* ---- the shell --------------------------------------------------------- */

function Mark({ className }: { className?: string }) {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
      <path d="M12 3l1.8 4.9L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.1L12 3z" />
    </svg>
  );
}

function ShellBody({
  domains,
  user,
  workspaces,
  currentWorkspaceId,
  paletteOpen: paletteOpenInitial = false,
  wsOpen: wsOpenInitial = false,
  fullBleed,
  hasTour = true,
  brandWord = 'Your App',
  hideChrome,
  children,
}: ShellProps) {
  const loc = useLocation();
  const navigate = useNavigate();
  const [paletteOpen, setPaletteOpen] = useState(paletteOpenInitial);
  const [wsOpen, setWsOpen] = useState(wsOpenInitial);
  const [current, setCurrent] = useState(currentWorkspaceId);
  const rootRef = useRef<HTMLDivElement | null>(null);
  const wsRef = useRef<HTMLDivElement | null>(null);

  const active = activeDomain(loc.pathname, domains);
  const isFullBleed = fullBleed ?? /^\/forms\/new$|^\/forms\/edit\/|^\/admin\/forms\/distillation/.test(loc.pathname);
  const canSeeAgency = user.isAdmin;
  const visibleDomains = useMemo(() => domains.filter((d) => !d.hidden && (d.adminOnly ? canSeeAgency : true)), [domains, canSeeAgency]);
  const visibleItems = useMemo(() => active.items.filter((it) => (it.employeeOnly ? !!user.isDeskEmployee : true)), [active, user.isDeskEmployee]);
  const needsAction = 0;

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault();
        setPaletteOpen((v) => !v);
      }
      if (e.key === 'Escape') setPaletteOpen(false);
    };
    root.addEventListener('keydown', onKey);
    return () => root.removeEventListener('keydown', onKey);
  }, []);

  const groups = useMemo(
    () => GROUP_ORDER.map((g) => ({ g, domains: visibleDomains.filter((d) => d.group === g) })).filter((grp) => grp.domains.length > 0),
    [visibleDomains],
  );

  useEffect(() => {
    if (!wsOpen) return;
    const onPointer = (e: MouseEvent) => {
      if (!wsRef.current?.contains(e.target as Node)) setWsOpen(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setWsOpen(false);
    };
    document.addEventListener('mousedown', onPointer);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onPointer);
      document.removeEventListener('keydown', onKey);
    };
  }, [wsOpen]);

  const showWsSlug = useMemo(() => {
    const names = workspaces.map((w) => w.name.trim().toLowerCase());
    return new Set(names).size !== names.length;
  }, [workspaces]);

  const currentWorkspace = workspaces.find((w) => w.id === current);
  const wsInitial = (currentWorkspace?.name || 'A').trim().charAt(0).toUpperCase();
  const userInitial = (user.name || user.email || 'U').trim().charAt(0).toUpperCase();

  return (
    <div ref={rootRef} tabIndex={-1} className="relative min-h-[560px] flex bg-paper text-ink font-sans outline-none overflow-hidden">
      {/* ---- left sidebar: brand header spanning the icon rail and the contextual column ---- */}
      {!hideChrome && (
      <div className="hidden sm:flex flex-col shrink-0 z-20">
        <button
          onClick={() => navigate('/dashboard')}
          aria-label="Home"
          className="hidden md:flex items-center gap-2.5 h-14 pl-6 pr-4 bg-primary-500 border-b border-line shrink-0 hover:bg-primary-600 transition-colors"
        >
          <Mark className="text-white" />
          <span className="text-white/85 text-lg font-semibold leading-none">{brandWord}</span>
        </button>

        <div className="flex flex-1 min-h-0">
          {/* ---- icon rail ---- */}
          <nav data-onboard="rail" className="relative z-30 w-[68px] shrink-0 flex flex-col items-center border-r border-line bg-white/70 backdrop-blur-sm py-4">
            <div className="md:hidden">
              <Tooltip content="Home" delay={120}>
                <button onClick={() => navigate('/dashboard')} aria-label="Home" className="grid place-items-center h-10 w-10 rounded-xl bg-primary-500 text-white shadow-lift mb-4">
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                    <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
                    <path d="M9 22V12h6v10" />
                  </svg>
                </button>
              </Tooltip>
            </div>

            <div className="flex-1 flex flex-col items-center gap-1 overflow-y-auto scrollbar-hide">
              {groups.map(({ g, domains: ds }, gi) => (
                <div key={g} className="flex flex-col items-center gap-1">
                  {gi > 0 && <div className="h-px w-7 bg-line my-1.5" />}
                  {ds
                    .filter((d) => d.id !== 'home')
                    .map((d) => (
                      <RailButton key={d.id} domain={d} active={active.id === d.id} badge={d.id === 'activity' ? needsAction : 0} />
                    ))}
                </div>
              ))}
            </div>

            <Tooltip
              content={
                <span className="flex items-center gap-1.5">
                  Search <kbd className="rounded bg-white/20 px-1 py-0.5 text-[10px] font-semibold">Cmd K</kbd>
                </span>
              }
              delay={120}
            >
              <button
                onClick={() => setPaletteOpen(true)}
                aria-label="Search"
                className="mt-2 grid place-items-center h-9 w-9 rounded-xl text-ink-40 hover:bg-primary-50 hover:text-primary-500 transition-colors"
              >
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
                  <circle cx="11" cy="11" r="7" />
                  <path d="m20 20-3-3" />
                </svg>
              </button>
            </Tooltip>

            {hasTour && (
              <Tooltip content="Zeig mir die App" delay={120}>
                <button
                  data-onboard="help"
                  aria-label="Zeig mir die App"
                  className="mt-1 grid place-items-center h-9 w-9 rounded-xl text-ink-40 hover:bg-primary-50 hover:text-primary-500 transition-colors"
                >
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <circle cx="12" cy="12" r="9" />
                    <path d="M9.5 9a2.5 2.5 0 1 1 5 .5c0 1.5-2.5 2-2.5 3.5M12 17h.01" />
                  </svg>
                </button>
              </Tooltip>
            )}
          </nav>

          {/* ---- contextual column ---- */}
          <aside className="w-[224px] shrink-0 hidden md:flex flex-col border-r border-line bg-white/60 backdrop-blur-sm px-3 py-4 overflow-y-auto scrollbar-hide">
            <div className="relative mb-4" ref={wsRef}>
              <button
                data-onboard="workspace"
                onClick={() => setWsOpen((v) => !v)}
                aria-haspopup="listbox"
                aria-expanded={wsOpen}
                className="w-full flex items-center gap-2.5 rounded-xl px-2.5 py-2 hover:bg-primary-50 transition-colors text-left"
              >
                <span className="h-7 w-7 rounded-lg bg-primary-500/12 text-primary-500 grid place-items-center text-[13px] font-bold">{wsInitial}</span>
                <span className="min-w-0 flex-1 leading-tight">
                  <span className="block text-[13.5px] font-bold text-ink truncate">{currentWorkspace?.name || 'Workspace'}</span>
                  <span className="block text-[11px] text-ink-60">Workspace</span>
                </span>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-ink-40">
                  <path d="m8 10 4 4 4-4" />
                </svg>
              </button>
              {/* The panel opens on every press and reports an empty list rather than rendering nothing (AppShell.tsx lines 265 to 271). */}
              {wsOpen && (
                <div role="listbox" className="absolute left-0 right-0 mt-1 z-30 rounded-xl border border-line bg-white shadow-pop overflow-hidden py-1">
                  {workspaces.length === 0 ? (
                    <div className="px-3 py-2.5 text-[12.5px] text-ink-60 leading-snug">
                      Diesem Konto ist kein Workspace zugeordnet.
                      <span className="block text-ink-40 mt-0.5">Bitte wende Dich an Deine Administratorin.</span>
                    </div>
                  ) : (
                    workspaces.map((w) => {
                      const isCurrent = current === w.id;
                      return (
                        <button
                          key={w.id}
                          role="option"
                          aria-selected={isCurrent}
                          onClick={() => {
                            setCurrent(w.id);
                            setWsOpen(false);
                          }}
                          className={`w-full flex items-center gap-2 px-3 py-2 text-left text-[13.5px] hover:bg-primary-50 transition-colors ${isCurrent ? 'text-primary-700 font-semibold' : 'text-ink'}`}
                        >
                          <span className="h-5 w-5 shrink-0 rounded bg-primary-500/12 text-primary-500 grid place-items-center text-[11px] font-bold">
                            {w.name.charAt(0).toUpperCase()}
                          </span>
                          <span className="min-w-0 flex-1 leading-tight">
                            <span className="block truncate">{w.name}</span>
                            {showWsSlug && <span className="block text-[10.5px] text-ink-40 truncate font-mono">{w.slug}</span>}
                          </span>
                          {isCurrent && (
                            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
                              <path d="m5 12 5 5L20 7" />
                            </svg>
                          )}
                        </button>
                      );
                    })
                  )}
                </div>
              )}
            </div>

            <div className="px-2.5 mb-2">
              <div className="text-[15px] font-extrabold tracking-tight text-ink">{active.label}</div>
              {active.codename ? (
                <div className="text-[11px] text-ink-40">
                  powered by {active.codename}
                  {!active.live && <span className="ml-1.5 text-primary-500 font-semibold">concept</span>}
                </div>
              ) : (
                <div className="text-[11px] text-ink-40">Workspace</div>
              )}
            </div>

            <div data-onboard="subnav" className="flex flex-col gap-0.5 overflow-y-auto scrollbar-hide">
              {visibleItems.map((it) => (
                <NavLink key={it.to} to={it.to} end={it.end} className={({ isActive }) => `ctx-link${isActive ? ' active' : ''}`}>
                  {it.label}
                </NavLink>
              ))}
            </div>

            <div className="mt-auto pt-3 flex items-center gap-1.5">
              <span className="h-7 w-7 rounded-full bg-gradient-to-br from-primary-500 to-primary-700 text-white grid place-items-center text-[12px] font-bold">{userInitial}</span>
              <span className="min-w-0 flex-1 text-[12.5px] font-medium text-ink truncate">{user.name || user.email.split('@')[0] || 'Account'}</span>
              <button title="Abmelden" className="grid place-items-center h-7 w-7 rounded-lg text-ink-40 hover:bg-primary-50 hover:text-primary-500 transition-colors">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
                </svg>
              </button>
            </div>
          </aside>
        </div>
      </div>
      )}

      {/* ---- main ---- */}
      <main className="flex-1 min-w-0 overflow-y-auto">
        <div key={loc.pathname} className={isFullBleed ? 'animate-fade-in' : 'animate-fade-in px-6 lg:px-10 py-8'}>
          {children}
        </div>
      </main>

      {paletteOpen && (
        <CommandPalette
          domains={visibleDomains}
          onClose={() => setPaletteOpen(false)}
          onGo={(to) => {
            setPaletteOpen(false);
            navigate(to);
          }}
        />
      )}
    </div>
  );
}

function RailButton({ domain, active, badge }: { domain: Domain; active: boolean; badge: number }) {
  const className = `relative grid place-items-center h-10 w-10 rounded-xl transition-colors ${
    active ? 'bg-primary-500 text-white shadow-[0_8px_18px_-10px_rgba(72,41,138,0.7)]' : 'text-ink-60 hover:bg-primary-50 hover:text-primary-500'
  }`;
  return (
    <Tooltip content={<span className="block text-[13px] font-semibold leading-tight">{domain.label}</span>} delay={120}>
      <NavLink to={domain.paths[0]} aria-label={domain.label} data-onboard={`rail-${domain.id}`} className={className}>
        <DomainIcon id={domain.id} />
        {badge > 0 && (
          <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-warn text-white text-[10px] font-bold grid place-items-center ring-2 ring-white">
            {badge}
          </span>
        )}
      </NavLink>
    </Tooltip>
  );
}

function CommandPalette({ domains, onClose, onGo }: { domains: Domain[]; onClose: () => void; onGo: (to: string) => void }) {
  const [q, setQ] = useState('');
  const targets = useMemo(() => {
    const list: { label: string; sub?: string; to: string }[] = [];
    for (const d of domains) {
      list.push({ label: d.label, sub: d.codename, to: d.paths[0] });
      for (const it of d.items) if (it.label !== 'Overview') list.push({ label: `${d.label} / ${it.label}`, to: it.to });
    }
    return list;
  }, [domains]);
  const filtered = targets.filter((t) => t.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);

  return (
    <div className="absolute inset-0 z-50 bg-ink/30 backdrop-blur-sm animate-fade-in flex items-start justify-center pt-[14vh] px-4" onClick={onClose}>
      <div className="w-full max-w-lg bg-white border border-line rounded-t-2xl shadow-pop overflow-hidden" onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center gap-2 px-4 border-b border-line">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-ink-40">
            <circle cx="11" cy="11" r="7" />
            <path d="m20 20-3-3" />
          </svg>
          <input
            autoFocus
            value={q}
            onChange={(e) => setQ(e.target.value)}
            onKeyDown={(e) => e.key === 'Enter' && filtered[0] && onGo(filtered[0].to)}
            placeholder="Jump to anything"
            className="flex-1 py-3.5 text-[15px] outline-none bg-transparent placeholder:text-ink-40 text-ink"
          />
          <kbd className="text-[11px] text-ink-40 border border-line rounded px-1.5 py-0.5">esc</kbd>
        </div>
        <div className="py-2 max-h-[320px] overflow-y-auto">
          {filtered.map((t) => (
            <button key={t.to + t.label} onClick={() => onGo(t.to)} className="w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-primary-50 transition-colors">
              <span className="h-7 w-7 rounded-lg bg-primary-100 text-primary-500 grid place-items-center text-[12px] font-bold">{t.label[0]}</span>
              <span className="flex-1 text-[14px] text-ink">{t.label}</span>
              {t.sub && <span className="text-[11px] text-ink-40">{t.sub}</span>}
            </button>
          ))}
          {filtered.length === 0 && <div className="px-4 py-6 text-center text-ink-60 text-[14px]">No matches.</div>}
        </div>
      </div>
    </div>
  );
}

/** The shell with a router around it. The dashboard and coachmark demos render their pages inside this. */
export function Shell({ activePath, children, ...rest }: ShellProps) {
  return (
    <MemoryRouter initialEntries={[activePath]}>
      <ShellBody activePath={activePath} {...rest}>
        {children}
      </ShellBody>
    </MemoryRouter>
  );
}

/* ---- sample pages ------------------------------------------------------ */

/** The empty Forms overview, Forms.tsx lines 403 to 455: heading and lead,
    the "Neues Formular" action, the HIN quick link, the glass card. The
    PrimaMachtsButton beside the action is not in the kit and is left out.
    The `shrink-0` on the action group and on the toolbar icons below are
    the one addition: the docs stage is narrower than any viewport the app
    lays these rows out for, and without it flex shrinks the icons to
    nothing and wraps the button label. */
function FormsEmpty() {
  return (
    <div className="max-w-6xl mx-auto">
      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
        <div>
          <Heading level={1} className="mb-2">Meine Formulare</Heading>
          <Text muted>Erstelle und verwalte deine Lead-Capture-Formulare</Text>
        </div>
        <div className="flex items-center gap-2 shrink-0">
          <Button variant="primary" className="flex items-center gap-2">
            <Plus className="w-4 h-4" />
            Neues Formular
          </Button>
        </div>
      </div>

      <button
        type="button"
        className="w-full mb-8 flex items-center gap-3 rounded-2xl border border-line bg-white/70 hover:bg-white hover:border-brand-purple/30 px-4 py-3.5 text-left transition group"
      >
        <div className="w-10 h-10 rounded-xl bg-brand-purple/10 grid place-items-center shrink-0">
          <Mail className="w-5 h-5 text-brand-purple" />
        </div>
        <div className="flex-1 min-w-0">
          <Text size="sm" weight="medium">Sicherer E-Mail-Versand (HIN)</Text>
          <Text size="xs" muted>SMTP-Relay und Empfänger für medizinische Formulare einrichten</Text>
        </div>
        <span className="text-brand-purple text-sm font-medium shrink-0 group-hover:translate-x-0.5 transition-transform">Einrichten</span>
      </button>

      <Card variant="glass" padding="lg">
        <CardContent className="py-16 text-center">
          <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-brand-purple/20 to-[#5D4EA7]/20 flex items-center justify-center">
            <FileText className="w-10 h-10 text-brand-purple" />
          </div>
          <Heading level={3} className="mb-2">Noch keine Formulare</Heading>
          <Text muted className="mb-6 max-w-md mx-auto">
            Erstelle dein erstes Formular, um Leads zu erfassen und mit deinen Kunden in Kontakt zu treten.
          </Text>
          <Button variant="primary">
            <Plus className="w-4 h-4 mr-2" />
            Formular erstellen
          </Button>
        </CardContent>
      </Card>
    </div>
  );
}

/** The form builder's top bar, TallyFormBuilder/index.tsx lines 303 to 411:
    back, the name input, undo and redo, preview, settings, save. The canvas
    under it stands in for the builder's field list. */
function Editor() {
  return (
    <div>
      <div className="sticky top-0 z-40 backdrop-blur-xl bg-white/70 border-b border-line">
        <div className="max-w-5xl mx-auto px-4 py-3">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <Button variant="ghost" size="sm" className="p-2" aria-label="Zurück">
                <ChevronLeft className="w-5 h-5 shrink-0" />
              </Button>
              <Input
                type="text"
                defaultValue="Kontaktformular"
                placeholder="Unbenanntes Formular"
                inputSize="sm"
                className="text-lg font-semibold bg-transparent border-0 outline-none focus:ring-0 p-0 max-w-xs"
              />
            </div>
            <div className="flex items-center gap-2">
              <div className="flex items-center border-r border-line pr-2 mr-2">
                <Button variant="ghost" size="sm" className="p-2" title="Rückgängig (Cmd+Z)" disabled>
                  <ArrowLeft className="w-4 h-4 shrink-0" />
                </Button>
                <Button variant="ghost" size="sm" className="p-2" title="Wiederholen (Cmd+Shift+Z)" disabled>
                  <RefreshCw className="w-4 h-4 shrink-0" />
                </Button>
              </div>
              <Button variant="ghost" size="sm" className="p-2" title="Vorschau umschalten">
                <Eye className="w-4 h-4 shrink-0" />
              </Button>
              <Button variant="ghost" size="sm" className="p-2" title="Formular-Einstellungen">
                <Settings className="w-4 h-4 shrink-0" />
              </Button>
              <Button variant="primary" size="sm">
                <Save className="w-4 h-4 shrink-0" />
                <span>Speichern</span>
              </Button>
            </div>
          </div>
        </div>
      </div>
      <div className="max-w-5xl mx-auto px-4 py-8 space-y-3">
        {['Name', 'E-Mail', 'Nachricht'].map((f) => (
          <div key={f} className="rounded-xl border border-line bg-white px-4 py-3 text-[13.5px] text-ink">
            {f}
          </div>
        ))}
      </div>
    </div>
  );
}

export default function Demo({ page, ...shell }: Props) {
  return <Shell {...shell}>{page === 'editor' ? <Editor /> : <FormsEmpty />}</Shell>;
}
Component tree, from the recipe
<Page>                                  every private page: <Navigation>{content}</Navigation>
  Navigation (24-line delegator)
    AppShell
      OnboardingProvider                (coachmark portal, see onboarding-coachmarks)
        div.min-h-screen.flex.bg-paper.text-ink.font-sans
          div.sidebar (sticky, hidden below sm)
            button.brand-header h-14 bg-primary-500   logo + "Labs", hidden below md, -> /dashboard
            nav.rail w-[68px] [data-onboard=rail]
              RailButton x visible Domain (GROUP_ORDER, hairline between groups; home merged into the header)
              button.search (opens CommandPalette)
              button.help "Zeig mir die App" [data-onboard=help] (only when the domain has a tour)
            aside.contextual w-[224px] hidden below md
              button.workspace-switcher [data-onboard=workspace] (+ listbox panel)
              div.domain-header  active.label, "powered by <codename>" or "Workspace"
              div.items [data-onboard=subnav]  NavLink.ctx-link x visibleItems
              div.account  initial, name, logout ("Abmelden")
          main.flex-1.min-w-0
            div.animate-fade-in.px-6.lg:px-10.py-8   (no padding for the full-bleed editor routes)
              {children}
          CommandPalette (Cmd-K / Ctrl-K; fixed overlay bg-ink/30, input "Jump to anything", 8 results)
          PrimaSlideOver (mounted once)
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild appshell-navigation/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md, section 3). AppShell.tsx reads useAuth, useWorkspace, usePrima, useDeskEmployee, useOnboarding and React Router; the demo lifts the presentational tree (brand header, rail, contextual column, main, palette) into a component that takes { domains, user, workspaces, activePath, paletteOpen, wsOpen, fullBleed } as props and wraps it in a MemoryRouter, so the tree in the frontmatter is what renders, with the contexts replaced by literals. The Domain shape is nav.tsx lines 47 to 70; the sample list beside the demo uses generic module names (Home, Activity, Contacts, Forms, Store, Bookings, Insights, Service Desk, Settings, and an agency group of Campaigns, Money, Projects, Studio, Operate for the admin state). The frame's stage stands in for the viewport, and every difference from the Labs markup follows from that, listed in the demo's header: the root is min-h-[560px] instead of min-h-screen, the sidebar, rail and column take the root's height from flex stretch instead of sticky h-screen with h-full inside it (corrected 2026-09-12: the stage sets only a min-height, so h-full resolved to nothing and the column stopped under its last link), the palette overlay is absolute instead of fixed, the Cmd-K and outside-click handlers are scoped to the frame, PrimaSlideOver is not mounted (closed, it renders nothing), and the brand header carries an inline mark and the word "Labs" where Labs loads its logo file. Pixel-literal type sizes are kept as they are in the source (Gotchas). The pages inside the shell are built from the kit's atoms, not drawn to look like them (corrected 2026-09-12): member shows the empty Forms overview as pages/Forms.tsx lines 403 to 455 compose it (Heading level 1, Text muted, the HIN quick link, Card variant glass with CardContent, Button variant primary), full-bleed the form builder's top bar as components/forms/TallyFormBuilder/index.tsx lines 303 to 411 compose it (Button ghost and primary in size sm around an Input with inputSize sm).

States: member (the shell around the Forms overview, Forms active in the rail, "All forms" active in the column, the account row), admin (the agency group at the bottom of the rail), palette-open (Cmd-K open on its first eight targets, before a query is typed; client only, since the input takes focus), workspace-open (the switcher panel with the current workspace ticked and two more listed, two of the three sharing a name so the slug line shows), full-bleed (an editor route: no page padding). Below md the contextual column and the brand header disappear and the rail stays; below sm the whole sidebar is hidden and the content is full width, which is what a 390 px frame shows.

Tokens: paper ground, white/70 and white/60 translucent rail and column with backdrop-blur-sm, line hairlines, primary-500 header and active rail item, primary-50 hover, purple-100 and purple-700 for the active column link, ink, ink-60, ink-40 text, shadow-lift on the mobile home button, shadow-pop on the palette, animate-fade-in on route change, rounded-xl and rounded-lg on controls, rounded-t-2xl on the palette.

States: signed-in (rendered); admin (the agency group appears at the bottom of the rail); desk employee (four more column items under Service Desk); palette open (paletteOpen); workspace panel open, including the empty-list message; full-bleed editor route (no page padding). Loading and error states belong to the page inside; the shell itself has none (it renders as soon as the contexts do).

Security & tenancy

The browser holds what S2 says it holds; the shell adds nothing. It reads user.isAdmin and user.email for visibility only; the security boundary is the backend's router mount and the route guards. The public demo account's agency visibility is keyed on an email literal in AppShell.tsx (line 65), which is a UX exception, not a grant. Workspace switching goes through switchWorkspace, which re-reads memberships server-side; the header X-Workspace-ID that follows is re-validated by the backend (S2 API surface). Nothing in the palette or rail is hidden by CSS alone: the entries are not rendered.

Tests

  • src/components/__tests__/nav.test.ts: 64 table-driven cases that resolve every real route through activeDomain() and assert group order (passed 64 of 64 on 2026-09-11, S2 Tests). Every new route adds a row.
  • What a new app adds: a jsdom render of AppShell with renderWithProviders for the three visibilities (member, admin, desk employee) asserting which rail labels and column items are in the document; a keyboard test that Cmd-K opens the palette and Escape closes it.
  • Playwright: e2e/critical-flows.spec.ts signs in and walks pages inside the shell; the shell has no spec of its own at fd8558f.
  • The kit's render smoke (node tooling/verify/render-smoke.mjs) renders the five demo states with renderToString (palette-open is mounted in jsdom, since it is hydrate: only); the verifier run (tooling/verify/compare.mjs, --state member against /forms) is the acceptance check that the tree in the frontmatter is complete, and its report is never committed.

Deploy & ops

Ships in the Labs bundle (S2 Deploy & ops); no env, no CSP entry of its own. The brand header's logo is /logo/AZ_Logo_Secondary_negativ.svg from public/; a customer build replaces that file and the "Labs" word. Rollback is the bundle's.

Gotchas

Full-bleed editor route: no page padding full-bleed server rendered, hydrated in view
Name
E-Mail
Nachricht
  • 2026-09-11 (measured): Navigation.tsx is a 24-line delegator kept so the historical <Navigation>{children}</Navigation> contract on every page survives; a new app can render the shell from a layout route instead, but then every page must lose the wrapper in the same change or it double-frames.
  • 2026-09-11 (measured, AppShell.tsx line 85): the Activity badge is const needsAction = 0 with a comment saying the store context does not populate it yet; the badge code path is live and always empty.
  • 2026-09-11 (measured, line 65): the demo account's agency visibility is keyed on an email literal; S2 lists it as a deviation. A new app keys it on a surface grant.
  • 2026-09-11 (measured, lines 265 to 271): the workspace panel used to render nothing for accounts without a workspace_users row (12 of 31 accounts at the time), which read as a broken dropdown; it now always opens and reports the empty list.
  • 2026-09-11 (measured): shell type sizes are pixel literals (text-[13.5px], text-[11px], text-[15px]), against S1 rule 1; a new app names them in the config's fontSize.
  • 2026-09-11 (rendering trap): pages with their own sticky header (the form editor) must match the isFullBleed regex at line 61 or the shell double-pads them and the sticky bar sits inset.
  • 2026-09-11 (rendering trap): the sidebar is hidden sm:flex, so a 390 px frame shows no navigation at all; there is no burger. A mobile rendering of a page inside the shell is a rendering of the page alone.
  • 2026-09-11 (rendering trap): the shell is min-h-screen with a sticky h-screen sidebar, which inside a fixed-height demo stage would size to the docs page's viewport; the demo swaps those for a min-height and flex stretch and changes nothing else (a percentage height on the sidebar has nothing to resolve against in a stage that sets only min-height, 2026-09-12). RailTooltip portals to document.body on hover only, so the demo renders on the server and hydrates in place.
  • 2026-09-06 (measured, commit noted in nav.tsx comments): the Prima domain was hidden from the rail while its routes stayed mounted; PrimaMachtsButton carries a matching PRIMA_DISABLED flag. Re-enabling is two flags, not a revert.

Reference implementation

Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-11), forked from feat/service-desk b1089b6. Paths relative to frontend/:

  • src/components/AppShell.tsx (512 lines): header comment (20 to 32), AppShell (34 to 43), AppShellBody (45 to 387: isFullBleed 61, demo account 65, visibleDomains 67 to 70, visibleItems 78 to 81, key handlers 87 to 100, groups 103 to 108, brand header 145 to 154, rail 157 to 226, contextual column 230 to 364, main 366 to 370, palette 372 to 381, PrimaSlideOver 384), RailButton (389 to 438), CommandPalette (445 to 512).
  • src/components/nav.tsx (468 lines): Domain (47 to 70), DOMAINS (77 to 347), GROUP_ORDER (349 to 357), activeDomain (367 to 382), DomainIcon (385 onward).
  • src/components/Navigation.tsx (24 lines): the delegator.
  • src/components/RailTooltip.tsx: the rail tooltip, with the comment on why it does not import the deleted legacy stylesheet.
  • src/index.css lines 271 to 278: .ctx-link and .ctx-link.active.
  • src/components/__tests__/nav.test.ts: the 64 cases.

Deviations of the reference from this recipe, dated 2026-09-11: the email-keyed demo visibility; the hard-coded Activity badge; pixel-literal type sizes; AdminNavigation.tsx still present; no mobile navigation.

Acceptance checks

From alchemy_labs/frontend at fd8558f, or a new app built to this recipe:

  1. npx vitest run src/components/__tests__/nav.test.ts prints 64 passed (or the new app's row count plus one).
  2. grep -c "id: '" src/components/nav.tsx prints 15; grep -c "hidden: true" src/components/nav.tsx prints 1; grep -c "adminOnly: true" src/components/nav.tsx prints 5.
  3. grep -rl "<Navigation>" src/pages src/components | wc -l equals the number of private pages; grep -l "import AppShell" src/components/*.tsx prints Navigation.tsx and AdminNavigation.tsx at fd8558f, and Navigation.tsx alone once the admin shell is retired.
  4. Browser walk-through, signed in as a member: (a) open /forms, observe the Forms rail icon filled purple and "All forms" highlighted in the column; (b) press Cmd-K, type book, observe "Bookings" first, press Enter, observe /calendar; (c) click the workspace name, observe the panel with the current workspace ticked; (d) resize to 700 px, observe the column gone and the rail present; resize to 600 px, observe no sidebar; (e) sign in as an admin, observe the agency group (Campaigns, Money, Projects, Studio, Operate) at the bottom of the rail.
  5. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs ui-design-system/ui/appshell-navigation prints ok for the five states and 0 failures.
  6. Verifier: node tooling/verify/compare.mjs ui-design-system/ui/appshell-navigation --state member --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (the Labs harness up, tour closed through verify.storage) writes a report with page_errors: []; the verifier reads mismatch_ratio for the sidebar only, since the page content inside the frame is sample copy and the app's is the Forms overview.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.