Skip to content

Cloudhal Studio design system

Binding for every admin surface (apps/studio, capability panels, docs UI). Cloudhal is a premium, community-facing product; its admin must read like the current Cloudflare dashboard. The implementation vocabulary is Kumo (@cloudflare/kumo 2.14, 54 components + 3 blocks) consumed through @cloudhal/ui. The official kumo-design skill (.agents/skills/kumo-design, installed from cloudflare/kumo) is part of this document; apps/studio/test/design-rules.test.ts enforces the mechanical rules in CI.

Verified sources: kumo-ui.com (installation, components-vs-blocks, blocks, component docs, registry), the Kumo repository (sidebar.tsx, blocks, the docs app shell), the kumo-design skill, Cloudflare dashboard documentation and changelogs (accounts/zones navigation, keyboard shortcuts 2026-05, navigation updates 2025–2026). Date: 2026-10-08.

1. Dashboard anatomy

The Cloudflare dashboard is: a left sidebar that is the primary navigation and whose contents switch between an account surface and a zone surface (a switcher sits at the top of the sidebar); grouped, collapsible menus named by capability, not product; a slim top bar with quick search (/, ⌘K) and the user menu; a content area with breadcrumbs, a page header (title, description, tabs, right-aligned actions), and cards or tables; resource lists carry a sticky right column with usage/help; empty states, skeleton loading, toasts and banners are system-wide.

Studio mirrors this one-to-one:

Part Kumo implementation
App shell Sidebar.Provider defaultOpen collapsible="icon" peekable mobileBreakpoint={768} className="h-dvh" → <Sidebar> + a content column flex h-full min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-kumo-overlay. The provider must have a definite height because Kumo’s <aside> is h-full; the column scrolls, the sidebar stays viewport-tall. The document never scrolls: the shell is h-dvh (the visible viewport, mobile toolbars included) and #root is min-h-dvh — with h-svh/min-h-screen the page was taller than a phone’s visible area, so reaching the end of the column or the sidebar scrolled the whole document, half-hiding the top bar and opening an empty strip below. Scrolling never chains on: the column is overscroll-y-contain, the sidebar’s viewport overscroll-behavior: contain (@cloudhal/ui/styles.css), <body> overscroll-none. The column is the page’s scroll root (usePageScrollRootRef / usePageScrollRoot from PageFrameProvider); scrollspy and jumps use it, never window. The column is relative, so absolutely positioned bits in pages (sr-only labels in long tables) are contained by it — with the provider as their containing block they sat below a long list and made the document taller than the window (the “extra space after the end” on Snapshots). Sidebar 16.25 rem expanded, 57 px icon rail; Sidebar.Header is h-12 like the top bar, so their bottom edges are one line.
Surfaces Account surface (all sites) and site surface (one site); the switcher at the top of the sidebar is a DropdownMenu listing sites and “All sites”.
Top bar header.sticky.top-0.z-10.h-12.shrink-0.border-b.border-kumo-line.bg-kumo-canvas: Sidebar.Trigger (below md, the only way into the sheet), the page’s breadcrumbs (PageCrumbsSlot; Kumo Breadcrumbs are h-12, exactly the bar), then right-aligned the CommandPalette button (⌘K and /; the palette, CommandMenu, loads with its first opening), the surface’s actions (site surface: SitePublish, the Publish split button), the notification bell (NotificationsBell, §2n), the theme menu (ThemeSwitch: ghost icon button showing the current choice, DropdownMenu radio items Light / Dark / System; hidden below sm), the language Select (hidden below sm) and last the user menu (UserMenu: a circle ghost button with the person’s Avatar, DropdownMenu with name + role, Account, Team, All sites, Theme and Language submenus, Sign out) — all size="sm". The palette also offers “Theme: …” commands. On a site (RFC-0029 §7) it is Kumo’s grouped palette (Results → Group + GroupLabel → Item / ResultItem):
  • Groups. Commands first (useSitePalette: the site’s screens as a table, then New article, Create snapshot, Publish, All sites), then the site’s content from GET /api/sites/:siteId/search, from two letters on, after 150 ms. Content comes one group per kind: Articles, Pages, Categories and tags, Media, Menu items, Redirects.
  • A hit. It shows its kind’s icon, then where it lives (breadcrumbs: collection or taxonomy, path or slug, and Draft / In review). Enter or a click opens its editor; ⌘/Ctrl opens it in a new tab.
  • Publish. Publish opens SitePublish’s ApprovalDialog; the palette never publishes on its own.
  • Footer. It lists the keys.
  • Matching. Commands match with foldName, so case and Turkish İ/ı are folded. | | Content container | PageLayout (@cloudhal/ui) — the only container: mx-auto max-w-[1400px] px-4 py-6 sm:px-6 md:px-8 md:py-8 lg:px-10, width="narrow" caps at 960 px. Screens never set mx-auto/max-w-* themselves (lint page-container). | | Breadcrumbs | Built only by Crumbs / SiteCrumbs (components/Crumbs.tsx): “Cloudhal Studio › ui-audit › Pages › About”, Breadcrumbs.Current loading, Breadcrumbs.Clipboard for the current resource id. Passed to PageLayout crumbs, which portals them into the top bar — never rendered in the content. | | Page header | Kumo’s PageHeader block (owned copy: every row flush with the content edge, 14 px description, icon, actions, controlled tab, loading title), rendered by PageLayout from its title, description, icon (24 px, top-level list pages only), actions (the page’s primary action — “New page”, “Create key” — lives here, never in the toolbar row) and tabs. Tabs are for sub-views of one page only; a tab bar that repeats the sidebar is forbidden (the old site tab bar was removed for that reason). | | Lists | PageLayout (+ aside for usage/help: the ResourceListPage two-column body, 380 px sticky once the content is ≥ 64 rem) + ListToolbar (Kumo Toolbar, default size so it lines up with Select/Button) + LayerCard className="p-0" + Table + Pagination; empty → a bare Empty in the table’s place. | | Sections | SectionCard (@cloudhal/ui): Kumo LayerCard with the Secondary header row (title, optional 16 px icon, actions right: count Badge, ghost LinkButton “View all”, small buttons) and the Primary body (description, children in grid gap-5 px-5 pt-4 pb-5, flush for a Table, footer row for Save/Discard). Never nested (lint layer-card-nesting); never a bare LayerCard with hand-made headers. | | Neutral panels | InsetPanel (@cloudhal/ui, outline / recessed) for summaries and quoted agent text inside cards and dialogs (“Now → New”, the agent’s reason); Surface for standalone neutral surfaces. Hand-made rounded-* ring divs are forbidden in screens (lint no-ad-hoc-surface). | | Feedback | Banner (page/section), Field/Input error (inline), Toasty + useKumoToastManager (transient). | | Confirmation | ApprovalDialog (ours, on Dialog) for publish/rollback; DeleteResource (block) for typed-name deletes; LayerDialog.Alert for other destructive alerts. Always rendered, controlled by open. | | Forms & detail sheets | LayerDialog.Root open onOpenChange → LayerDialog.Content whose direct children are Title, optional Description, Body, optional Actions (Kumo throws otherwise; branch inside each part). Desktop dialog, bottom sheet on phones. | | Code & config | CodeHighlighted from @cloudhal/ui/code (Shiki, lazy, showCopyButton) for snippets and JSON payloads; ShikiProvider lives at the app root (CodeProvider, localised copy labels). Code stays for one-line CLI hints in usage panels. |

Keyboard parity with the dashboard: ⌘K / / open the palette; t s toggles the sidebar; ? lists shortcuts (palette entry).

Lazy message namespaces. Every word goes through useT() (apps/studio/src/i18n), but not every word is in every page’s first load. The core catalogs en.ts/<locale>.ts hold what the shell and most screens need, and only one language of them loads: English is in the first load (it is also every key’s fallback), every other language is a dynamic import (loadLocale, found by file name) that main.tsx awaits before the first render and a language switch awaits before it applies; a feature whose wording only its own screens show keeps it in a lazy namespace: one top-level key, moved verbatim into i18n/lazy/<ns>.en.ts (export const messages = { <ns>: { … } } as const, the source of truth for its keys) and i18n/lazy/<ns>.tr.ts (export const messages: NamespaceMessages<"<ns>"> = …, so a missing or extra Turkish key is a type error), registered in i18n/namespaces.ts (one dynamic import per namespace and locale — a page fetches only the language it shows). Today: analytics (Analytics screen), analyticsSettings (site settings, Agents’ “Allow analytics”), readers (editors’ Readers card, overview card, list views column), comments (comment queue, thread, commenter, article editor card, Approvals) commentsSettings (site settings in every locale, Agents’ “Allow comments”) appearance (the color roles under site settings’ Appearance form and its Header and footer section) media (the media library, a file’s page, uploads, the image picker, takedowns; loaded with every route that shows them) and menus (the navigation editor and its dialogs, and menu references in the page, article and term editors, the page and article lists and the snapshot diff).

  • Reading. A component that shows namespace words asks for them: useT("analytics") / useI18n("readers") returns a t typed for the core keys plus that namespace (useT() alone does not accept them, so a forgotten namespace is a type error) and suspends until the current locale’s module is there. Text in another locale (the privacy notice) uses useMessages([ns], LOCALE_LIST) + translate.
  • Loading — no flash of keys. The route that renders the namespace loads it with its chunk: lazyRouteComponent(() => withMessages(import("./routes/X.tsx"), ["readers"]), "X") in router.tsx (in parallel, also on hover preload), so the screen never suspends for words; if one is missed, the component suspends to the route’s pending state rather than showing keys. A language switch first loads, in the new language, every namespace loaded in the old one, then switches (in a transition). bundle-budget.ts fails the build if i18n/lazy/ reaches the initial load.
  • No hard-coded words (AGENTS.md hard rule 10). Nothing a person reads is a literal in apps/studio/src or packages/ui/src: JSX text and string children, aria-label, title, placeholder, alt, label, description, content and every …Label/…Text/…Title/…Description/…Placeholder/…Message/…Hint attribute or property, tables of labels ({ draft: "Draft", … }), default values and ??/|| fallbacks, toasts and confirm(), document.title. Blocks take every word as a prop and have no English fallback (StatusBadge label, DeleteResource labels, ApprovalDialog confirmLabel/cancelLabel). A failed request is worded by apiErrorText(t, error) (lib/api-errors.ts: errors.api.<code>, then the server’s message, then the status), never error.message directly. Search folds text with the Studio locale (usePaging’s fold, toLocaleLowerCase(locale)), never toLowerCase(). The i18n rule of the design lint (apps/studio/scripts/i18n-lint.ts) parses every .ts/.tsx of both trees with oxc and fails pnpm check; ids, keys, class lists, URLs, protocol values and logs are not prose and pass. A literal that reads the same in every language goes in its ALLOWED list (product and format names) with a reason, or carries an i18n-ignore: <reason> comment on its line or the line above.
  • Message syntax (RFC-0032, ADR-0032). A catalog value is text with {name} arguments, or an ICU MessageFormat 1 message: {count, plural, =0 {…} one {# file} other {# files}} (the CLDR categories of the language; # or {count} is the number in the locale), {kind, select, page {…} other {…}}, {n, selectordinal, …}, {n, number} / {n, number, percent|integer|compact}. other is required; a literal brace is quoted, '{}'. @cloudhal/messages compiles the catalogs at build time (the catalogs() Vite plugin in vite.config.ts and vitest.config.ts), so no parser ships. t() is typed per key from src/i18n/generated/params.ts (pnpm gen; a plural argument is a number, a select one of its values); a message a block fills in itself keeps its placeholders with keep("name"). A count is one plural message called with the number (t("editor.words", { count })), never a …One key or a count === 1 ? ternary (the test rejects …One/…Many keys). When a translation needs another grammatical form of a data argument (Turkish names a role once, {role}, where English says {roles}; a taxonomy’s own singular and plural labels), English declares it in forms (en.ts, by key prefix): the form becomes a required parameter of those keys and the only substitute a translation may use. A word that changes with a value is a select over the value with whole sentences ({action, select, approve {… approved} … other {…}}), never a translated word injected into a sentence. A translation that cannot use an argument at all (a Turkish case suffix on an arbitrary name) declares it in its core catalog’s dropped, with the reason; the test rejects any other missing argument and any stale entry. A list a person reads goes through listOf(items) (Intl.ListFormat in the Studio language: “a, b, and c”, “a, b ve c”; formatList(locale, …) from @cloudhal/messages/format outside React), never items.join(", "); " · " between separate facts is a visual separator and stays. Numbers, dates, times, relative times, display names and collation come from the cached, locale-taking layer in @cloudhal/messages/format (formatNumber, formatDate, formatRelative, numberFormat, dateTimeFormat, relativeTimeFormat, displayNames, collator; NUMERIC_DATE/NUMERIC_DATE_TIME for what toLocaleDateString/toLocaleString showed), always with the Studio locale (or a fixed one for machine formats such as the en-CA day keys), never new Intl.* or toLocaleString() in a screen or block. The design lint enforces these: i18n-format (a formatter built on the spot), i18n-plural (two messages chosen by whether a number is 1) and i18n-concat (a sentence assembled from several t() results, or a list joined with ", "), in pnpm check, pnpm design:lint and the agent hook; a reviewed exception carries i18n-ignore: <reason>. test/i18n-messages.test.ts checks every language: valid syntax, English’s argument kinds kept, every plural category of the language present.
  • Adding keys. Keys go in English and in every registered language (core: en.ts and each <locale>.ts; a lazy namespace: each lazy/<ns>.<locale>.ts, never back into the core catalogs; test/i18n.test.tsx checks keys and placeholders for every language). A branch made before a namespace moved (e.g. analytics.bots.* added under analytics in en.ts/tr.ts) merges by moving its lines unchanged into lazy/analytics.en.ts/lazy/analytics.tr.ts — the blocks keep their indentation there. A new feature with more than a few kilobytes of wording, shown on its own screens, starts as a lazy namespace; words the shell shows (navigation, commands) stay core (nav.analytics, commands.goAnalytics).
  • Languages. i18n/locales.ts is the registry: one entry per language, keyed by its BCP 47 tag (also <html lang>, every Intl formatter and calendarLocaleProps, so dates and numbers follow the Studio language, never the browser’s), with its native name, dir (set on <html dir>), fallback chain (always ending in English) reviewed and, where formatting needs it, an intl tag (Arabic and Persian …-u-nu-latn, Western digits, ADR-0033; setIntlTags makes every formatter of @cloudhal/messages use it). Besides English (the source) and Turkish (the owner’s), every language is an AI translation awaiting review by a person who speaks it (reviewed: false, ADR-0034; the first right-to-left one is Arabic). The language menus show each name in itself only; that a language is an unreviewed AI translation is a comment on its registry entry, for maintainers. Loaders are not written by hand: import.meta.glob finds i18n/<locale>.ts (export const messages: Messages) and i18n/lazy/<ns>.<locale>.ts; bundle-budget.ts keeps every core catalog but English out of the first load. Studio opens in English, its global default, for everyone: the browser’s language is not consulted; a person’s own choice in the language menu is stored and wins (matchLocale remains for matching a tag to a registered language).
  • Pseudo-locale and catalog checks (the i18n review’s Phase 5). On a dev server (and in tests) ?pseudo or localStorage["cloudhal.studio.pseudo"] = "1" shows every catalog message as “[Šààṽéé]” — accented, about a third longer, bracketed — generated from the compiled English messages (@cloudhal/messages/pseudo) with every argument and plural kept: plain English on screen is a hard-coded word, a missing “]” is a cut-off label, a broken layout has no room for German. It is never a language in the menus, and production builds do not contain it (import.meta.env.DEV; the bundle budget fails if the module reaches a chunk). SHOTS_PSEUDO=1 pnpm design:shots -- … takes the screenshots in it. test/i18n-messages.test.ts also fails on an unused key: apps/studio/scripts/i18n-usage.ts collects every literal and template key in Studio’s code (string constants resolved, a literal naming a group reaches the group); node apps/studio/scripts/i18n-usage.ts lists them. Missing keys and placeholder parity were already checked (types, test/i18n.test.tsx).
  • Right-to-left readiness (ADR-0033 D and B). Studio and the @cloudhal/ui blocks use logical utilities only — ms/me, ps/pe, start/end, text-start/text-end, border-s/border-e, rounded-s/rounded-e/rounded-ss… — never ml/mr, pl/pr, left/right, text-left/text-right, border-l/border-r, rounded-l/rounded-r (lint rtl-logical; scripts/i18n-codemod/logical-utilities.ts converts; an element placed by Floating UI in physical coordinates keeps left-0 with an i18n-ignore reason). <html dir> and Base UI’s DirectionProvider (in KumoLocale, through @cloudhal/ui) follow the locale registry’s dir. SHOTS_PSEUDO=rtl pnpm design:shots -- … (or ?pseudo=rtl on a dev server) renders the right-to-left pseudo-locale to find what does not mirror. Kumo’s own components are made logical by the generated patch (node scripts/kumo-patch.ts, ADR-0033 A1; kumo-upstream-notes.md §7); Icons that mean a direction in reading order — back and forward, previous and next, “from → to”, a closed tree row, opening a row, reply, indent, opening elsewhere, signing in and out, sending — go through DirectionalIcon (<DirectionalIcon icon={ArrowRight} />, or directional(ArrowRight) where a prop or table takes an icon component), which sets Phosphor’s mirrored from Base UI’s direction (ADR-0033 C1, lint rtl-icon). Physical and temporal directions stay as they are: up and down, sort order, clocks, refresh and undo circles, a chart’s trend; such an icon among the matched names carries an i18n-ignore reason. Kumo’s own ten directional icons (submenu, sidebar, pagination, date range, command palette) and the breadcrumb separator mirror through rtl:-scale-x-100 from the hand-written patches/kumo/rtl-icons.patch, which reads <html dir> rather than Base UI’s context (kumo-upstream-notes.md §7). Arrow keys follow the direction too: blocks whose Left and Right mean previous and next, open and close, or nest and move out (MenuTree, CalendarMonth) read the key through readingKey(key, useDirection()), and their help text names the keys as they work in the language (menus.tree.instructionsRtl).
  • Translation workflow (ADR-0034). Only English is edited by hand (en.ts, lazy/<ns>.en.ts). Every other catalog is generated by pnpm i18n:import from translation files — nested ICU JSON, .i18n/<locale>/<core|namespace>.json, written by pnpm i18n:export with .i18n/descriptions.json (English text and the note above each key) — and carries a hash of its messages: a hand edit fails pnpm check (node scripts/i18n-exchange.ts check, run by test/i18n-messages.test.ts). The import checks keys and ICU syntax before writing anything, keeps a catalog’s own exports after its messages (Turkish dropped), formats the files and runs the catalog tests. New English keys: export, translate the new keys in each language’s JSON (by hand or with the translate skill, .agents/skills/translate), import. The translation platform (Weblate or Tolgee) and its hosting wait for the owner; the exchange format is the one it will read.
  • Adding a language (the i18n review’s Phase 6): (1) prerequisites — a language with plural categories beyond one/other (ar, ru, uk, pl, cs, …: Intl.PluralRules(…).resolvedOptions().pluralCategories) waits for ICU plurals (the review’s Phase 1), a right-to-left one (ar, he, fa, ur) for logical CSS and Kumo’s direction support (Phase 3); (2) add the registry entry with reviewed: false; (3) pnpm i18n:export, write .i18n/<locale>/*.json (the translate skill drafts them; an AI translation is fine as a first draft; its registry entry says so in a comment until reviewed), pnpm i18n:import --locale <locale> — it generates i18n/<locale>.ts and every lazy/<ns>.<locale>.ts; (4) pnpm --filter @cloudhal/studio typecheck and test/i18n.test.tsx (keys, placeholders, no English sentences left, every namespace present); (5) vite build — the bundle budget must not move (the language loads only for its users); (6) pnpm design:shots in the language at 1440 and 390 px (German and Finnish run long); (7) a speaker reviews in context, then reviewed: true.

2. Page templates

Every route file declares one template in ROUTE_TEMPLATES (apps/studio/scripts/design-lint.ts); the lint then requires that template’s blocks. A new screen fails until it is registered.

Template Built from
queue PageLayout, flush SectionCards of divide-y rows / a Table, SectionEmpty (Approvals)
moderation PageLayout, ListToolbar ↔ BulkActionBar, LayerCard p-0 of CommentRows, Empty, Pagination (reader comments, §2w)
list PageLayout (crumbs, title, description, actions, optional aside), ListToolbar, LayerCard p-0 + Table, Empty, Pagination
dashboard PageLayout, Columns variant="2-1", SectionCards
settings PageLayout, SettingsLayout (sticky TableOfContents), SettingsSection (= SectionCard)
editor PageLayout, EditorLayout (+ PreviewPanel), SectionCards
auth AuthLayout (@cloudhal/ui), Tabs, Input/PasswordField, full-width primary Button, Banner
token AuthLayout without tabs, takeHashToken, Banner for the link’s state, SkeletonLines while it is inspected, Input/NewPasswordFields, full-width primary Button (/invite, /reset-password, §2r)
recover AuthLayout without tabs, one email Input, full-width primary Button, the neutral answer as a Banner, a LinkButton back to sign in (/forgot-password, §2r)
shell AppFrame, Sidebar.*, SidebarSwitcher
<PageLayout
crumbs={<SiteCrumbs siteId={siteId} trail={[{ label: t("pageHeaders.pages.title") }]} />}
title={t("pageHeaders.pages.title")}
description={t("pageHeaders.pages.description")}
actions={<Button variant="primary" icon={<Plus />}>…</Button>}
aside={<SectionCard title=…>…</SectionCard>} // optional
>
…toolbar, cards, tables…
</PageLayout>

Reference implementations: routes/SitesPage.tsx (list + aside), routes/PagesPage.tsx (list), routes/SiteOverviewPage.tsx (dashboard), routes/LoginPage.tsx (auth).

2a. Resource list (sites, pages, articles, snapshots, activity, agent keys, skills)

<PageLayout crumbs={…} icon={<Icon size={24} />} title description
actions={<Button variant="primary" icon={<Plus />}>New page</Button>}
aside={<SectionCard title="From the terminal" icon={<TerminalWindow size={16} />}>…<Code /></SectionCard>}>
<ListToolbar search onSearch placeholder actions={…Select filters, RefreshButton…} />
<LayerCard className="p-0"><Table><Table.Header><Table.Row><Table.Head/>…<Table.Head sticky="right"/></Table.Row></Table.Header>
<Table.Body>{rows.map(r => <Table.Row key={r.id}><Table.Cell><Link href>{r.title}</Link></Table.Cell>…<Table.Cell sticky="right"><RowMenu /></Table.Cell></Table.Row>)}</Table.Body></Table></LayerCard>
<Pagination page perPage={25} totalCount setPage controls="full" />
</PageLayout>

Empty → a bare Empty icon={<Icon size={48} />} title description commandLine contents in place of the table (Kumo Empty draws its own frame — never wrap it in LayerCard); inside a card, cell or dialog use SectionEmpty (frameless). Never pass className to Empty. Loading → TableSkeleton (a Table.Body, so always inside <Table>). Times in lists are relative (RelativeTime) with the absolute time in a Tooltip. Every control on one toolbar row has the same height (Kumo default size).

Multilingual sites (site.locales.length > 1, an internal review (2026-10) §4a): Pages and Articles show one row per translation group (lib/translation-groups.ts), by its default-language version or the original, with a “Languages” column of LanguageChips. The toolbar gets a language Select (“All languages (grouped)” by default, or one language: that language’s versions as a flat list, without the Languages column); picking a language makes it the site’s working language (lib/working-language.ts, this browser only), which new pages and articles start in. Single-language sites show neither.

Snapshots: the primary “Create snapshot” opens a LayerDialog (optional label Input + Radio appearance="card": keep the live site / publish it); publishing continues into ApprovalDialog before anything is sent. SnapshotTimeline rows keep Compare (ghost) and Publish/Roll back (secondary) in separate trailing columns so actions align, the last one sticky="right"; times via its renderTime prop (RelativeTime).

2b. Resource detail / editor

PageLayout (crumbs via SiteCrumbs with copy={id}; title; description = status badge + path in mono; actions = PreviewToggle, StatusActions — a secondary “More actions” ⋯ DropdownMenu with a “Status” group plus Duplicate and Delete, and the next lifecycle step as a labelled secondary button, Publish via ApprovalDialog — and the primary Save with loading, disabled until dirty) → EditorLayout. Main: SectionCards with fields in Grid 2up/3up (items-start gap-x-4 gap-y-5, inputs w-full); page sections are one SectionCard each (kind icon + name, id as InlineCopyText, ghost square icon buttons with tooltips: move up/down, remove → ConfirmDialog), added from an “Add section” DropdownMenu. Side: DetailsCard (dl rows: ID, language, revision, updated as RelativeTime…) and DangerZoneCard (secondary-destructive → DeleteResource), both in EditorLayout.tsx. Lifecycle = explicit actions, never a status Select.

2c. Settings / form

Same header; Columns variant="1-2" (a fixed 13 rem sticky TableOfContents and cards capped at 56 rem, side by side once the page container is ≥ @4xl); one SectionCard per section, Switch for instant settings, SensitiveInput for secrets, ClipboardText for ids; sticky footer border-t border-kumo-line with Button variant="primary" type="submit" loading. Studio implements it as SettingsLayout + SettingsSection (components/Settings.tsx): the TableOfContents is sticky from the page container’s @4xl breakpoint (hidden below it; the cards stack); its label is a sentence-case Text with the nav’s aria-label, never TableOfContents.Title (Kumo renders it in capitals) and driven by useSectionSpy on the shell’s scroll root (Kumo’s useTableOfContentsActiveId watches window, which never scrolls in the shell): the current section is the last one whose top passed the line below the top bar, the last section at the end of the page (short closing sections can never reach the line), and a clicked item stays current until the person scrolls again (wheel, touch, pointer, keys), so a jump never snaps back to its neighbour; items scroll the content column themselves (no router hash navigation), and a #section deep link lands on its card. Each section is a SectionCard (SettingsSection maps aside → actions): header (title, optional badge/actions right), body with a muted description, controls carrying their own label/description/error, label/value rows as dl (SettingRow), and a footer row border-t border-kumo-hairline with the section’s own actions (ghost “Discard” only while dirty, primary “Save changes” disabled until dirty). Sections save independently. A section holding a Table uses flush (no body padding). Text inputs in cards get className="w-full" so they never overflow the card at 375 px; field pairs use a container-query grid (@container → @lg:grid-cols-2 items-start), each field with its own description so rows stay aligned. Saves that PUT a whole document send the latest cached copy plus the section’s fields; a 409 shows Banner variant="alert" “This site changed somewhere else” with Banner.Action “Load latest” (the form keeps unsaved edits unless the server’s values for those fields changed).

2d. Destructive flow

Button variant="secondary-destructive" → DeleteResource open onOpenChange resourceName onDelete isDeleting {...deleteResourceProps(t, type, { action?, error })} (Studio’s components/deleteLabels.ts: the dialog’s labels and the failure, worded from the catalog). Non-typed destructive confirmations: LayerDialog.Alert. Never {open && <Dialog/>}.

2e. Auth

AuthLayout (@cloudhal/ui): canvas background, a centred 400 px column — brand row (icon tile + product name), one Surface card (rounded-xl px-6 py-5 ring-kumo-line) with the heading (Text heading lg, “Welcome back” / “Create your account”) and description, then the form: segmented Tabs (sign in / create account), Input label fields at full width, PasswordField (show/hide), Button variant="primary" loading full width, ErrorBanner for failures; a footer row under the card (licence note, language Select). No sidebar.

2f. Editor with live preview (page and article editors)

EditorLayout (Studio) around the 2b form, with PreviewPanel (@cloudhal/ui) as the preview:

  • content ≥ 64 rem (container query on PageLayout’s @container/page, so the sidebar’s state counts): two equal columns — the form (main sections, then details and danger zone) left, the preview right, sticky top-16, height calc(100svh - 6rem), filling its column (PreviewPanel fill). A secondary PreviewToggle labelled “Hide preview” / “Show preview” (Tooltip, aria-pressed) in the header actions hides it; the choice is remembered per browser and the layout falls back to Columns 2-1.
  • content < 64 rem: underline Tabs “Edit | Preview” above the content; Edit shows the 2b Columns 2-1 (stacked on phones).
  • PreviewPanel: LayerCard with a Secondary toolbar — heading, status slot (Badge warning dot “Unsaved changes” + Tooltip when the form is dirty), segmented Tabs size="sm" for Desktop/Tablet/Mobile (icon + label, label hidden on phones), RefreshButton, LinkButton external “open in new tab” — and a Primary viewport on bg-kumo-recessed. Desktop fills the panel; tablet (768) and mobile (390) render at their real width, scaled down only when the panel is narrower, framed with ring ring-kumo-line. Loading = SkeletonLine rows in an overlay with role="status".
  • The frame is always <iframe sandbox=""> (no scripts, no same-origin, no forms), except in the Build view (§2ac) on the same-origin /preview endpoint; it reloads when the document’s rev changes (i.e. after every successful save) and on demand. Unsaved changes are previewed too: the term editor (§2p) and, since RFC-0014 phase 1c, the page and article editors (DraftEditorPreview) send the form to POST …/preview (scope content:read, nothing written) after each 400 ms pause in typing, and PreviewPanel live srcDoc swaps the server’s rendering of the draft into the same sandboxed frame — so there is no “Unsaved changes” badge to explain; “Open in new tab” opens the saved version. With nothing unsaved the frame shows the saved /preview as before. A failed draft render shows an error Badge with the reason in a Tooltip.

2g. Credentials (agent keys) — modes, create, one-time reveal

Every key has a mode (ADR-0015) — how independently the agent works; scopes stay the hard limit and a mode never grants more. Studio offers the four key modes, in order of autonomy: plan (read + preview), manual (every change waits), edits (default: edits free, publish/rollback/delete wait), auto (like edits, small article-only publishes go live without a person). bypass is never offered or sent for a key (the API answers 400); it is only explained, with cloudhal mcp --mode bypass.

List page per 2a (PageLayout, crumbs Agents › Keys and MCP, “Create key” in actions; the elevated-keys banner sits between the toolbar and the table; empty = bare Empty with commandLine + secondary Create key): name + key prefix (start…, mono) in one cell; Mode column with ModeBadge (while elevated: KeyModeCell, see Elevation below); scopes — one outline Badge “Mode default · n scopes” (scopes in its Tooltip) when they equal the mode’s prefill, otherwise the full ScopeBadge list (a Tooltip per scope), so customised keys stand out; created/expiry; status dot; row DropdownMenu (Change mode, Elevate temporarily, End elevation now while one runs, Copy key ID, separator, Revoke). The aside holds the SectionCard “Connect an MCP client”: endpoint and header as ClipboardText, segmented Tabs over ready-to-paste client configs in CodeHighlighted (the local tab uses cloudhal mcp --mode edits); the copy says the agent is told its mode when it connects. Then the compact “How modes work” flush SectionCard: one-line description, a compact Table (Mode × Content / Publish / Rollback, delete) whose cells are dot Badges — Blocked neutral, Waits warning, Free success, “Small: free” success — each with a Tooltip explaining the rule, then the tools that always wait with a key (rollback, delete_page, delete_entry; label + mono name), a ClockCountdown line recommending a temporary elevation over a permanent Auto key, and the local bypass command. The table overrides Kumo’s cell padding to px-1 (first/last column pl-5/pr-5) so four columns fit the 380 px side column and a phone in English and Turkish; keep labels short.

Mode picker (ModePicker, components/AgentModes.tsx) — the one way to choose a mode:

  • Radio.Group appearance="card" (legend visually hidden via Radio.Legend className="sr-only"), one Radio.Item per key mode, iterating KEY_MODES (never a literal list; CI checks).
  • Card label: mode icon (Phosphor: plan Eye, manual UserCheck, edits PencilSimple, auto Lightning, bypass LockSimpleOpen, 16 px, text-kumo-subtle), the name, a risk Badge: plan/manual success + ShieldCheck “Safest”, edits info + Star “Recommended”, auto warning + Warning “Use with care”; in the change dialog the key’s current mode adds an outline “Current”.
  • Card description: one line on behaviour, then a HandPalm line “Waits for you: …”.
  • BypassHint under the cards; edits preselected.
  • Choosing auto (where it is not already the mode) shows AutoModeConfirm: Banner variant="alert" with a Lightning icon explaining the low-risk rule (articles only, nothing removed, ≤ 20 changes, marked auto-approved, one-click rollback), and a Checkbox “I understand that this agent can publish without my approval” that must be ticked; the primary action shows an inline error otherwise. CI checks that every screen rendering ModePicker also renders AutoModeConfirm.

ModeBadge: filled, semantic tone by autonomy — plan secondary, manual success, edits info, auto/bypass warning — with the mode icon and a Tooltip (behaviour + what waits). subtle renders it outline for dense audit cells so the decision badge stays loudest. Carries data-mode.

Create = one LayerDialog, three states in the same parts (no fragments in Content):

  1. Mode (step 1 of 2): the mode picker (+ auto consent, bypass hint). Primary “Continue”.
  2. Name and scopes (step 2 of 2): an InsetPanel layout="row" summary (Mode + ModeBadge, “Waits for you: …”, secondary “Change” back to step 1), Input name (+description, inline error after a submit attempt), then Collapsible “Advanced: scopes” — closed by default, a “Customised” Badge info in the trigger when the set differs from the mode’s prefill. Inside: “Prefilled for {mode} mode…” hint, ghost “Use the mode’s scopes” reset when customised, the scope Checkboxes grouped in labelled InsetPanels (Read / Write / Publish and roll back, hint per group, raw scope in mono), and Banner variant="secondary" when scopes the mode needs are missing. Changing the mode re-fills the scopes. The request sends {name, mode} and adds scopes only when customised (the server prefills from the mode). Primary “Create key” (loading); dismissal disabled while pending. No <form> element around Kumo Checkbox (it is a <button> and would submit) — Enter in the name field submits.
  3. Reveal: Banner variant="alert" “You won’t see this key again”, the key in ClipboardText size="lg", the client config with the key filled in (CodeHighlighted + copy). Primary “I’ve stored the key”. The plaintext lives only in component state and is dropped when the dialog closes.

Change mode = LayerDialog from the row menu (ChangeAgentModeDialog): the mode picker starting at the current mode, bypass hint, auto consent, then — right above the action — an InsetPanel “Now [ModeBadge] → New [ModeBadge]” with “Applies on the agent’s next call” (running calls finish in the old mode; the change is audited). Primary “Change mode” is disabled until the mode differs. A refusal (400) stays in the dialog as Banner variant="error" with the server’s message; success closes it, shows a success toast “{name} now works in {mode} mode.” and refetches the list. Revoke = DeleteResource with the action “Revoke key” (deleteResourceProps(…, { action })), success toast.

Elevation (ADR-0016) — a key works in a more autonomous mode for a while, then returns to its own mode by itself. The icon for anything time-boxed is Phosphor ClockCountdown.

  • Row menu “Elevate temporarily” sits under “Change mode”. For a key whose own mode is already auto nothing is above it (bypass is local), so the item is disabled with a Tooltip “Already in Auto…” (the tooltip hangs on a render={<div />} wrapper because a disabled Kumo menu item takes no pointer events). Never hidden.
  • Elevate dialog (ElevateAgentKeyDialog, LayerDialog size="base"), top to bottom: an info Banner size="sm" “This replaces the elevation in force” when one runs (with its mode and end); “Own mode [ModeBadge]” with “Stays as it is”; a visible “Elevate to” label over ModePicker only={modesAbove(own)} — cards with nothing to offer are absent, the next mode up is preselected; AutoModeConfirm whenever the target is auto (also when replacing an Auto elevation); Duration as a fieldset with segmented Tabs presets 15 min / 1 hour (default) / 4 hours / 1 day / Custom — presets outside the server’s agentModes.elevation bounds are dropped; Custom shows Input type="number" (label, “Between 5 minutes and 1 day.” description, inline error “At least …”/“At most …” while typing, “Enter a number” after a submit attempt) next to a Select Minutes/Hours (hours accept a decimal comma, rounded to whole minutes); Reason Input (optional, ≤ 200 characters, “Shown in the audit log.”). Right above the action, the InsetPanel summary: “Now [own] → Elevated [target]”, then ClockCountdown + bold “Until 14:30 (in 1 hour), then back to Edits.” (elevationSummary: today → time only, within the week → weekday + time, else date + time; the person’s locale and time zone) and “Applies on the agent’s next call. You can end it early from the key’s menu.” A 400 stays in the dialog as Banner variant="error" with the server’s message; success closes it, toasts “Desk works in Auto mode until 14:30, then back to Edits.” and refetches. Primary “Elevate” (loading), dismissal disabled while pending.
  • Mode cell while elevated (KeyModeCell): the elevated ModeBadge, then an outline Badge with ClockCountdown “42 min left” (short units, nearest minute, at least 1) whose Tooltip reads “Elevated from Edits until 14:30 by you” plus the reason in quotes; under them Text xs secondary “Own mode: Edits”, so the key’s own mode stays recognisable. One line (whitespace-nowrap); the table scrolls sideways on phones. The page re-reads the clock every 30 s (useNow) and schedules a refetch one second after the earliest end (useElevationExpiry); a cell whose end has passed shows the own mode even before the refetch lands.
  • Summary banner (ElevatedKeysBanner, top of the list, role="status"): “1 agent key is elevated” / “n agent keys are elevated”, one line per key “Desk: Edits → Auto until 14:30”, Banner.Action “End now” (or “End all now”). alert tone when any key is elevated to Auto, default otherwise. Keys inside the notice window (agentModes.elevation.noticeMinutes, 10) add a warning Badge with ClockCountdown “Ends in 8 min” to their line, and the banner adds Banner.Action “Extend 1 hour” (several: “Extend all 1 hour”) before “End now” — the same useExtendElevation as the bell (§2n). The actions wrap under the text on phones. In the key’s Mode cell the timer badge turns warning inside that window.
  • End now (row menu, banner) acts immediately with a success toast “Elevation ended · Desk is back in Edits mode.” — no confirmation dialog: it only takes autonomy away, is audited, and elevating again is one dialog away (the same reasoning as approving a non-destructive request, §2m). Failures are error toasts.
  • Agents ask (ADR-0016 addendum): on servers reporting features.elevationRequests, a key whose agent waits for an elevation shows a dot Badge warning “Asks for Auto” under its mode, a Link to that site’s Approvals with a Tooltip (“Desk asks to work in Auto mode for 1 hour. Open Approvals to decide.”). It reads each site’s pending list through the same cached query the site surface polls (usePendingElevations), without polling of its own. A grant made from a request keeps the agent’s words as the key’s reason; the timer tooltip renders it as “Requested by the agent: “…””.
  • Change mode while elevated: an info Banner size="sm" at the top of the dialog, “Elevated to Auto until 14:30”, whose description follows the selected card — “The elevation stays in force until 14:30, then the key works in Manual mode.” while the new own mode is still below the elevation, “Auto reaches the elevated mode, so this change ends the elevation.” otherwise (the server’s rule). The list is updated from the returned summary as a whole, so an ended elevation disappears at once.

2h. Audit log (site › Activity)

Toolbar row: ListToolbar (search Toolbar, default size: tool, label, actor, skill) with, on the right, a ghost “Clear filters” (only while filtered), a Toolbar of Select render={<Toolbar.Button />} per facet (actor type, decision) and a RefreshButton, all at the default height. Empty and filtered-empty are a bare Empty in the table’s place. LayerCard p-0 overflow-x-auto + Table, columns in reading order: Tool (localised imperative label, raw MCP name in mono below), Decision (DecisionBadge: filled, icon; allow = success, approval-required = warning, deny = error; an auto-mode publish that went live without a person — autoCheck.lowRisk on an allow row (older rows: reason starting “auto mode: published without approval”) — adds AutoApprovedBadge, warning + Lightning “Auto-approved” with a localised tooltip; both via DecisionCell, also on the overview’s recent activity), Actor (ActorBadge + name + the mode the call ran under as a subtle ModeBadge; people have none; a call made under a time-boxed elevation — elevation: {from, until} on the row — gets a ClockCountdown marker inside the badge (role="img", label “Temporarily elevated”) and its tooltip leads with “Temporarily elevated from Edits until 14:30”), When (relative, absolute in Tooltip), Outcome (OutcomeBadge, dot), Skill, Snapshot (link to the diff), sticky chevron Button that opens the row. The whole row is clickable for pointer users; the chevron is the keyboard path. Paging is cursor-based: useInfiniteQuery over nextBefore, a footer with “Showing n of m loaded events” and a secondary “Load more” (loading), or “You’ve reached the first recorded event”. Filters apply to loaded rows and say so in the filtered-empty state. Search also matches the mode. Detail = LayerDialog size="lg": reason as a Banner (error for deny, alert for approval; for an auto-approved publish an alert “Published without a person” with the article count, replacing the raw reason), a dl of facts (including Mode with a full ModeBadge, and for elevated calls an Elevation row: “Temporarily elevated from Edits until ” with a muted line on the key’s own mode) separated by divide-kumo-hairline, the input JSON in CodeHighlighted, “View changes” → snapshot diff. People’s actions on keys have labels too (agent-key.elevate “Elevate agent key”, agent-key.mode, agent-key.elevation-end); a grant row adds, under the tool name, subtle “[Edits] → [Auto ⏱]” badges (ElevationGrant) and an outline Robot badge “Requested by the agent” when its reason ends with “requested by the agent (appr_…)”; its detail adds an Elevation granted row (badges, “Auto mode until , then back to Edits.”, the request id as InlineCopyText). The overview page shows the five newest events in a compact “Recent activity” card with “View all”.

2i. Snapshot diff

Opened from the snapshot row’s ghost “Compare” button or ?diff=<snapshotId> (Activity links there), so it is linkable and closes back to the plain URL. LayerDialog size="xl": segmented Tabs choosing the base (previous snapshot / live version; plain text when only one exists), counts as Badges (n added success, n changed warning, n removed error, with icons; a zero count is secondary; loading is SkeletonLines in an InsetPanel), then one section per non-empty change type: heading + count, LayerCard p-0 + Table with the item (title linked to its editor, kind as an outline Badge, path and copyable id below; “No longer exists” for deleted nodes) and the revision (rev a → b). Empty states: “No differences”, “Nothing to compare with”.

  • Text changes (RFC-0014 phase 2): a changed page or article row has a ghost “Show changes” (aria-expanded) that loads both versions from their snapshots (GET …/snapshots/:id/nodes/:nodeId, content:read; cached, snapshots never change) and shows NodeTextChanges: short fields as “old → new” (<del>/<ins>), then the body — or each rich-text section, with other sections as Added/Removed/Changed badges — as a BodyDiff (@cloudhal/ui). BodyDiff works on the markdown source: a success/danger Badge pair with the words added and removed; added blocks on bg-kumo-success-tint (<ins>), removed ones struck through on bg-kumo-danger-tint (<del>), edited ones on bg-kumo-recessed with the changed words tinted inline; a hidden “Added:”/“Removed:”/“Edited:” before each change, so colour is never the only signal; runs of unchanged blocks fold behind a ghost “3 unchanged blocks”. The split and the diff are @cloudhal/core-ts/body-blocks (bodyDiff, Worker-safe; blocks aligned by longest common subsequence, removed and added blocks that still share ≥ 35 % of their words paired as edits). The release compare (§2x) shows the same BodyDiff for the body under its field table, live → pinned.

2j. Account (account surface › Settings › Account; also in the user menu)

Settings template (§2c). Profile: initials Avatar (block, lg), name Input (validated, Enter saves), email read-only Input with an explanation, actor ID (user_…, what activity and snapshots record) as InlineCopyText, and the person’s staff role as a RoleBadge row (§2s). Password: PasswordField — Kumo’s InputGroup password pattern (InputGroup.Input + InputGroup.Button eye toggle with tooltip) — for current / new / confirm; errors appear after the first submit attempt and Better Auth codes map onto fields (INVALID_PASSWORD → current password); “Sign out of all other sessions” Checkbox, checked by default; no <form> (Kumo Checkbox is a button), Enter submits. Active sessions: flush Table (device icon + “Chrome on macOS” with the raw user agent in a Tooltip, “This device” Badge info, IP in mono, last active / signed in as RelativeTime, ghost “Sign out” on other rows) the newest five rows with a ghost “Show all n” in the footer, and a footer action “Sign out other sessions” (the header holds only the count badge); both confirm through ConfirmDialog (LayerDialog.Alert). Better Auth lists sessions only for a sign-in younger than a day (SESSION_NOT_FRESH): that is Banner variant="secondary" with “Sign in again”, not an error. Notifications (between sessions and access, §2n): a Kumo Switch “Notify me in this browser when an elevation is about to end or an agent asks for one” with a muted hint, a SettingRow “Browser permission” with a dot Badge (Allowed success / Not asked yet neutral / Blocked error), a secondary “Send a test notification” while on; denied adds Banner variant="secondary" explaining how to allow notifications in the browser’s site settings (the switch is disabled); a browser without the API gets no switch, only a muted note. Studio access and This server are two sections. Studio access: the sign-up policy from /api/instance as a SettingRow with a dot Badge and a plain explanation of the mode (owner only / closed / open / not reported; open adds Banner variant="alert"), how to change it, and “This server” features with On/Off dot badges.

2k. Site settings (site › Settings tab and the sidebar group “Settings”)

Settings template. General: name Input, slug read-only in an InputGroup with a / addon, default language Select over site.locales, languages as outline Badges, site ID InlineCopyText. Domains: flush Table (hostname in mono, role Badge info with a star for primary / outline “Alias”, row DropdownMenu: Visit, Make primary, Remove domain) or a flush SectionEmpty; an add row as a hybrid InputGroup: https:// addon, input, attached secondary InputGroup.Button “Add domain”. Input is normalised (scheme, path, case) and validated with the contract’s hostname pattern; the first domain becomes primary, removing the primary promotes the next (the ConfirmDialog says which). Every change is a full-document PUT (CAS). A muted info line says DNS is configured separately. Preview links: Radio appearance="card" (current drafts / a snapshot), snapshot Select (label · time · live), page InputGroup with the primary host as addon, lifetime Select (1 h / 1 d / 7 d); the result is an InsetPanel with the URL in ClipboardText, “Open link” LinkButton external, target Badge and expiry; without a domain the token is shown under Banner variant="alert". A server without a preview secret (features.previewLinks === false, or 404) gets Banner variant="secondary" and no form.

2l. Skills (account surface › Agents › Skills)

PageLayout (crumbs Agents › Skills) with aside: “How agents use skills” SectionCard — four numbered steps and a Collapsible tools/call example whose _meta["cloudhal.skill"] narrows the call; then the “Add your own” SectionCard). Table: skill (name, two-line description, skill://…/SKILL.md as InlineCopyText), scopes (the agent-keys ScopeBadge; unknown scopes as mono outline badges), tools that need approval (warning Badge with a hand icon, localised tool name), reference count (file names in a Tooltip), chevron → SkillDetailDialog (LayerDialog lg: scopes with the “never more than the key” note, approvals, requirements, reference files, ClipboardText resource, CodeHighlighted call). Empty: Empty with a commandLine; a server without MCP: Banner variant="secondary".

2m. Approvals (site › Approvals; sidebar History › Approvals with a count)

Agents with keys cannot approve their own publish / rollback / delete (ADR-0014): the request waits here for a person. Agents in manual mode (ADR-0015) also send every content edit here; those requests (cause: "manual-mode"; older servers: reason starting “manual mode:”) are not shown with the server’s terse reason but replaced by a localised sentence with a ModeBadge mode="manual" (“{agent} works in Manual mode, so this change waits for you: Update article.”) plus a muted hint, and by “Manual mode: {tool}” in History; they approve directly (no confirmation dialog), like any non-destructive tool. Every other request shows its mode as a subtle ModeBadge before the prompt; an auto-mode publish the low-risk rule held back (autoCheck.lowRisk === false) adds a muted line saying why in the person’s language (“Auto mode publishes small article updates on its own. This one waits for you because it changes pages, site settings, taxonomies or redirects.”). Activity and Approvals are separate sidebar items; the pending count appears on the sidebar MenuBadge and the overview banner from one query, refresh is in the page header actions, polled every 15 s (refetchInterval) only while the site shell is mounted; a 404 (approvals off) stops polling and shows Banner variant="secondary".

  • Waiting for you: one flush SectionCard (HandPalm icon, description) with divide-y rows (no nested cards): ToolCell (localised label + raw name), ActorCell (ActorBadge + name), Deny (secondary) / Approve (primary) with loading, the server’s prompt as plain text (it can quote agent-written titles; never HTML — CI checks there is no dangerouslySetInnerHTML), requested / expires as RelativeTime with a warning Badge under an hour, and Collapsible arguments in CodeHighlighted. Ordered by expiry. Empty: SectionEmpty “Nothing is waiting for you”.

  • Approve on publish, rollback, delete_page, delete_entry opens ApproveRequestDialog (LayerDialog): the prompt in an InsetPanel; for publish the approximate changes (live snapshot → newest snapshot: count badges + “View changes”, labelled as an approximation because edits after the newest snapshot are not counted); for rollback the target snapshot with “View changes”; for deletes the target id; and an info Banner “Single use, for the site as it is now” (void if anything changes before the agent calls again). request_elevation has its own dialog (below). Other tools and Deny act immediately with a toast.

  • 409 (decided, used or expired meanwhile): error toast “This request is no longer pending”, the dialog closes, the list refetches.

  • Elevation requests (tool: "request_elevation", ADR-0016 addendum; ElevationRequest.tsx) are a different kind of request and look it. The server’s English prompt is never shown. The left cell is laid out like ToolCell: a 16 px ClockCountdown icon with “Elevation request” and the raw request_elevation in mono below; History uses the same cell. The body shows, in order:

    • the change as two full ModeBadges, “Edits → Auto”. The own mode comes from /api/agent-keys (input.keyId), else the prompt’s “its own mode is …”, else the request’s mode (requestOwnMode);
    • a localised sentence, “Desk asks to work in Auto mode for 1 hour.”;
    • the agent’s reason as AgentReason (recessed InsetPanel) labelled “The agent’s reason”, quoted, as plain text (agent-controlled);
    • a muted line on what approving does. When the key’s own mode has caught up with the request meanwhile, Banner variant="secondary" size="sm" instead says there is nothing to grant and to deny it;
    • in the meta row, ClockCountdown “If you approve now: until 14:30”.

    Approve always opens ApproveElevationDialog (LayerDialog size="base"); Deny stays one click. Top to bottom, the dialog has:

    • AgentReason;
    • Mode with the requested ModeBadge, fixed (no picker), and “To grant another mode, deny this request and elevate the key from Agents.”;
    • AutoModeConfirm when the mode is auto;
    • Duration: “Requested: 1 hour. Shorter is fine, longer isn’t possible.”, then segmented Tabs with the standard presets shorter than the request plus the request itself, preselected (grantPresets; an odd request reads “1 hr 30 min” in the presets’ words), then Custom. Custom has the elevate dialog’s number + unit fields, bounded by the server minimum and the request (“At most 1 hour”);
    • an outline InsetPanel summary “Now [own] → Elevated [mode]”, the bold “Desk works in Auto mode until 14:30, then back to Edits.” and “Applies on the agent’s next call…”.

    The primary action is “Grant elevation” (loading). The POST sends {minutes} only when shortened. Errors:

    • A 400 stays in the dialog as Banner variant="error".
    • A 409 shows the server’s message plus “The request stays in the list while it is pending…”, and refetches approvals and keys. If the refetch shows the request is no longer pending, the dialog closes with the “no longer pending” toast.

    On success, a toast “Elevation granted · Desk works in Auto mode until 14:30, then back to Edits.” and approvals, agent keys and activity are invalidated. CI checks that the dialog is the only caller of api.approvals.grantElevation, renders AutoModeConfirm with its guard, never sends more than requested, and that the page routes elevation approvals to it first.

  • History: flush SectionCard (ClockCounterClockwise icon) with a Table (or SectionEmpty): request (tool + truncated prompt in a Tooltip), status dot Badge (approved success, denied error, used neutral, expired warning), agent, requested, decided (time + “by you” or the deciding actor id). Elevation requests read ClockCountdown “Elevation request” over “Elevation to Auto · 1 hour” (the requested time), and a used one is “Granted” (success).

  • Overview: while requests wait, Banner variant="alert" with a hand icon and Banner.Action “Review requests” above the cards.

2n. Notifications (top bar bell; ADR-0016 addendum “told before it ends”)

GET /api/notifications lists what needs the signed-in person: their elevations ending within noticeMinutes (elevation-ending) and agents asking to elevate one of their keys (elevation-request). Items carry stable ids. Studio reads it with one query (useNotifications, key ["notifications"]), owned by the bell, so it runs only while someone is signed in: every 60 s, every 15 s while an elevation is ending, also in a background tab (refetchIntervalInBackground) so browser notifications can arrive. A 404 (older server) is “no notifications”: nothing is fetched again — not on an interval, on focus or on remount.

  • Bell (NotificationBell block, @cloudhal/ui): a ghost square Button size="sm" with a 16 px Bell in the top bar, between the surface’s actions and the language select, on every surface and at every width. A count Badge variant="primary" sits on its corner (hidden at 0, “9+” above 9); the button’s name carries the count (“Notifications (2)”).
  • Panel: Kumo Popover (align="end", positionMethod="fixed", 24 rem, at most the viewport minus 1 rem) without its own padding: a header row (Popover.Title “Notifications”, a secondary count Badge), a scrolling list of NotificationItems divided by divide-kumo-hairline (max 32 rem / 70 svh), and a footer row with a ghost “Notification settings” that opens Account › Notifications. Empty: SectionEmpty with BellSimple “You’re all caught up”. Items whose end or expiry has passed drop out before the next poll.
  • NotificationItem: a 16 px tone icon aligned with the first line (warning ClockCountdown for an ending elevation, info Robot for a request), a bold title with optional meta on the right, the body, then one row of size="sm" actions that wraps on phones.
    • Elevation ending soon: ModeBadge of the elevated mode + warning Badge ClockCountdown “8 min left” (recomputed from until), then “Desk’s Auto mode ends in 8 min (14:30), then back to Edits.” Actions: a ButtonGroup — secondary “Extend 1 hour” and a ▾ DropdownMenu with a “Counts from now” group of 30 min / 1 hour / 4 hours, each with its new end (“4 hours · until 18:30”); secondary “End now”; ghost LinkButton “Open Agents”. Extend and End disable each other while pending.
    • Agent asks: “Planner asks for Edits · 30 min” with RelativeTime as meta, the reason in AgentReason (recessed InsetPanel, plain text: agent-controlled), and a secondary LinkButton “Review” to that site’s Approvals.
  • Extend = re-post the mode in force (useExtendElevation, components/Elevation.tsx): POST /api/agent-keys/:id/elevation {mode, minutes} with the elevated mode and the preset clamped to the server’s bounds; it replaces the elevation in force and counts from now. The keys are read first, and an elevation that has ended or changed mode is refused with an error toast (“This elevation has already ended”) — re-elevating is a new grant, and new grants (with the Auto consent) only happen in the elevate dialog. End is useEndElevation. Both toast, update the key list and invalidate agent-keys, notifications and approvals; deciding an approval invalidates notifications too. The bell, its toasts and the Agents banner never call the API for elevations themselves (CI checks).
  • One-time toasts (useNotificationAlerts, hosted by the bell): an id seen for the first time while Studio is open raises a notice toast once — warning “Desk’s Auto mode ends in 8 min” / “At 14:30 it goes back to Edits…” with the actions “Extend 1 hour” and “Open Agents”; info “Planner asks for Edits · 30 min” with the quoted reason and “Review”. The toast carries the item’s id (Kumo shows an id once), stays 15 s, and an action closes it. Whatever is there on the session’s first answer is not announced (the bell lists it). Announced ids are remembered per browser in localStorage (cloudhal.studio.notifications.shown, last 200, every access in try/catch), so a reload never repeats one. New items also refresh the key list or approvals.
  • Browser notifications (opt-in, Account › Notifications): turning the switch on calls Notification.requestPermission() from the click; the choice lives in localStorage (cloudhal.studio.notifications.browser). For each newly announced id, when the person opted in, permission is granted and document.visibilityState !== "visible", Studio raises new Notification(title, {body, tag: id}) with the toast’s words; clicking it focuses the window and opens Agents or the site’s Approvals. A visible tab gets only the toast. Delivery beyond an open tab (email, push) is not Studio’s job yet.

2o. Search and sharing (editors and lists)

The public site emits canonical, robots noindex, Open Graph/Twitter tags, JSON-LD, sitemap.xml and RSS from what the editors store (page.seo; for articles the summary as description and coverImage as og:image). Studio edits those fields and shows their effect read-only; helpers live in lib/seo.ts, the cards in components/Seo.tsx, the visuals in the SeoPreview blocks (@cloudhal/ui).

  • Blocks (packages/ui/src/blocks/SeoPreview.tsx): ImagePreview — the only <img> in Studio (referrerPolicy="no-referrer"): a fixed frame (card 1.91:1, the Open Graph crop; thumb 16:9, 3.5 rem) with four states, data-image-status empty / loading (Loader) / loaded / error (ImageBroken + label), a caption under the frame (the size) that an errorHint replaces while broken, and onLoad with the natural size. SearchResultPreview — a neutral sketch of a search result built from tokens (globe tile, site name, host › path breadcrumb, title in text-kumo-link, description; a muted italic placeholder when nothing is set; optional badge footer). SocialCardPreview — image frame, then host, bold title and two-line description. No third-party logos or fonts. All text is plain text.
  • Sources: screens never pass a stored src straight to ImagePreview; it goes through previewImageUrl (the validateImageSrc rule: https://… or a site path starting with /, never //, http:, data: or other schemes — stricter than the renderer’s safeSrc), which resolves site paths against the primary domain (primaryHostname). CI checks both.
  • Article › Cover image (CoverImageCard, a SectionCard with an Image icon after the Article card): the description says the image comes from the media library. Body is ImageRefField (§2u), a @container grid — preview 2/5 and fields 3/5 side by side from @md, stacked below. The source is a media-library file chosen with the picker (InsetPanel “From your media library” with the file’s name, type, size and a secondary “Change”; a secondary “Choose from library” while empty), then the alt text — “Alt text for this use”, optional, placeholder and hint quoting the file’s own alt — and, under a Collapsible “Use an image address instead” (open on load for an image that has only an address), the “Image URL” Input (mono, inputMode="url", (optional), hint with an example path). Typing an address detaches the library file (editImageRef drops assetId). An address image keeps the old rules: errors on the URL after leaving the field or a save attempt (“Use https://…”, “Enter an https:// address or a path that starts with /”), alt “required” once a URL is set, broken-image hints explaining where site paths load from (the primary domain, or “add one in Settings › Domains”). A ghost “Remove image” in the header clears the slot; clearing both fields by hand removes coverImage too (the contract has no empty src). Dimensions are not form fields: a library file brings its own; when a newly typed address loads, its natural width/height are stored and shown as the caption (“1200 × 630 px”); a new URL drops stale ones.
  • Article › Search and social preview (ArticleSeoPreviewCard, read-only, first card of the editor’s side column): segmented Tabs size="sm" in the header — “Search result” (SearchResultPreview in an outline InsetPanel: site name, host › collection › slug, title, summary cut by truncateForSearch) and “Social card” (SocialCardPreview, max-w-md, the cover image). The description says the summary is used as the meta description and the cover as the share image — articles have no SEO object until RFC-0002, so no new fields. Under the preview a dl of SettingRow checks with dot Badges: Title (≤ 60 good, longer “Too long”), Meta description (summary) (missing / short < 50 / good / long > 160, warning tone), Share image (missing warning, broken error, good). The Summary field itself shows “Used as the meta description…” and a counter.
  • Page › Search engines and sharing (PageSeoCard, after the Page card): “SEO title” Input (placeholder = page title, “Leave empty to use the page title”, counter n / 60, maxLength 300), “Meta description” InputArea (guidance “Aim for 50–160”, counter n / 160, maxLength 500), “Hide from search engines” Kumo Switch with a hint (adds noindex; the page stays reachable) — on, it adds Banner variant="alert" with an EyeSlash icon explaining what noindex does (CI checks the switch never appears without it); Collapsible “Advanced” (a Custom info Badge in the trigger when set, open on load when set) with “Canonical URL” (mono Input, absolute http(s)://host… only, error after leaving the field or a save attempt, hint “Usually empty: the page’s address on {primary domain} is used…”); then “Search preview” — the same SearchResultPreview in an InsetPanel, showing the canonical’s host and path when valid and a warning Badge “Hidden from search” while noindex is on. Empty strings and noindex: false are removed, an empty seo is dropped (applySeo/cleanSeo); limits come from the contract (CI compares them).
  • Counters (CharacterCount): Text-sized n / recommended at the right end of the field’s description, tabular-nums, text-kumo-warning past the recommended length (the hard limit is the input’s maxLength).
  • Saving: every save path of both editors (Save, lifecycle buttons, Publish before the ApprovalDialog opens) runs valid() first; invalid fields show their errors, an error toast “Fix the highlighted fields before saving.” appears and the card scrolls into view. CI checks no save.mutate is unguarded.
  • Lists: Pages shows an outline Badge with EyeSlash “noindex” next to the title of hidden pages (Tooltip explains it). Articles lead the title cell with a thumb ImagePreview of the cover (an image placeholder when there is none), so rows stay aligned; site paths load from the primary domain.

2p. Categories, tags and redirects (RFC-0002 §8, ADR-0017)

People govern the vocabulary in Studio; agents use MCP. Helpers live in lib/taxonomy.ts (validation mirrors the core-ts invariants; names fold and resolve with @cloudhal/core-ts/names, the store’s own functions) and lib/redirects.ts; shared cells and the data hook (useTaxonomyData: site, taxonomies, every term with usage, one query each) in components/Taxonomy.tsx. A taxonomy’s label is the site’s own in Studio’s language, else Studio’s word for the default category/tag (taxonomy.defaults.*), else the site’s label; mid-sentence labels are lower-cased.

  • Taxonomy screen (site › Content › “Categories & tags”, list template, routes/TaxonomyPage.tsx, ?taxonomy=<key>&filter=<status>): PageLayout tabs, one per taxonomy (categories, tags, then custom keys); header actions, left to right: a secondary square GearSix button (Tooltip and aria-label “Tag settings”) opening the settings dialog for the current tab, secondary FolderSimplePlus “New taxonomy”, primary “New category”/“New tag”. Toolbar: search (names, synonyms, slugs) and a segmented Tabs filter All / Public / Internal / Proposed (warning count Badge) / Retired — it scrolls inside the toolbar on phones. While proposals wait, an alert Banner “Proposals waiting for review: n” with Banner.Action “Review”. LayerCard p-0 + Table: Name (TermNameCell: bold name in the default locale linked to the editor, state badges — warning Hourglass “Proposed”, outline EyeSlash “Internal”, secondary “Retired”/“Merged into X” — the archive path in mono; hierarchical taxonomies render a tree, depth-first in position order, each level indented with an ArrowElbowDownRight; a filtered child whose parent is hidden says “in News”), Articles (“published / total”, linked to the articles list filtered by that term, Tooltip “n published of m”; “Last used” with RelativeTime below — one column so the table fits next to the aside), Search engines (IndexBadge: success dot “Indexed”, warning dot “Not indexed: 1 of 3”, neutral “Empty” (404), warning “noindex”, or muted “No archive”/“Not public yet”; every state has a Tooltip; categories count their sub-tree, as archives do), Visibility (info “Public” / outline EyeSlash “Internal”), and a sticky row DropdownMenu — Edit, Rename slug, Merge into…, Move… (hierarchical), Make internal/public, separator, Retire (danger); a proposal’s menu has Merge into an existing term… and Reject. Proposals show “Proposed by” with ActorCell (agent badge + name) and their review actions in the name cell: secondary “Approve” (one click, like approving a non-destructive request; toast), ghost “Merge…” and “Reject” (both through the governance dialog). Empty: bare Empty per filter; a site without taxonomies gets Empty with commandLine cloudhal migrate tags-to-terms --dry-run and “Preview the move”. Aside: “Vocabulary health” (TaxonomyHealthCard, from taxonomyReport in @cloudhal/core-ts — the one report: MCP taxonomy_report only words the same result, and a parity test keeps them equal; the card reads every entry of every collection, as MCP does): near-duplicates as “LLMs → LLM” with the reason and a secondary “Merge” that opens the merge dialog prefilled (a proposed source goes through review), waiting for review (+ “Review”), articles without a primary term (required taxonomies) or without any term (“Articles without a category”, hierarchical and not required), indexed archives without an intro, used once, not on any article (flat taxonomies), empty (no published article in the sub-tree: a 404) and very large categories; SectionEmpty “Nothing to tidy up” otherwise. Then “How categories work”: structure, who adds terms, per-article rule (required · up to n), archives and the indexing threshold, as SettingRows, with a ghost GearSix “Edit” in its header. A site without taxonomies offers “New taxonomy” next to “Preview the move” in its Empty. The sidebar item carries the proposal count (Sidebar.MenuBadge).
  • Term editor (routes/TermEditorPage.tsx, editor template, EditorLayout with the §2f preview column): header title = name, description = taxonomy Badge, state badges and the archive path; actions = PreviewToggle (its tooltip says the preview shows unsaved changes), Approve (proposals), a secondary ⋯ DropdownMenu with the governance operations, primary Save (disabled until dirty). Archive preview (TermArchivePreview): unlike page and article previews it shows the unsaved draft — names, intro, SEO, image are posted (POST …/terms/:id/preview, after a 350 ms pause in typing, keepPreviousData) and the server’s rendering of the archive page (the starter’s markup, archiveHead title and description) is swapped into the same sandbox="" frame through srcdoc, the document carrying the preview CSP in a meta tag; the refresh spinner turns while a newer render is on its way, no skeleton per keystroke. The panel’s status slot shows what the public address answers with the draft: the IndexBadge (indexed / “Not indexed: 2 of 3” / noindex / empty / not public yet …, the renderer’s archiveState table) and an outline HTTP 200|404|410 badge with a Tooltip; it renders the page even for a proposed, internal or empty term. “Open in new tab” opens the saved version (GET …/preview). A segmented Tabs locale switch appears above the cards on multi-locale sites (an error Badge marks a locale with errors after a save attempt). Main: “Names and synonyms” (Input name, required in the default locale; synonyms as TagInput, maxValues 50), “Archive introduction” (markdown InputArea, mono, counter to 20,000), “Image” (the cover-image card, §2o, retitled), “Search engines and sharing” (SeoFields kind="archive", the page SEO panel’s fields: counters, noindex Switch + its Banner, canonical under “Advanced”, search preview of the archive path). Aside: “Indexing” (the IndexBadge and a sentence: “/tag/js has 2 of the 3 published articles it needs…”), “Placement” (parent + “Move…”, position number input), “Slug history” (read-only InsetPanel of earlier archive paths with the redirect each left; SectionEmpty when none; ghost “Rename slug”), DetailsCard, DangerZoneCard “Retire this tag”. Slug, parent, visibility and retirement never change through Save — only through the governance dialog. valid() runs before every save: default-locale name, lengths, synonyms that repeat or already name another term (invariant 12, folded like the store), SEO limits, the image rule, position 0–100,000; a retired term is read-only behind a secondary Banner.
  • Governance dialog (GovernanceDialog, one component for rename, merge, retire, move, visibility, reject and merge-a-proposal; LayerDialog size="lg", always rendered): title naming the term, a description of what the operation does, then the choice — rename: slug InputGroup with the archive base as addon, validated like the store (taken, reserved by a history → a Checkbox “Reclaim it”), earlier slugs listed; merge: a tree-aware TermCombobox of active terms (never the term or its sub-tree); retire: TermCombobox “Redirect to…” with “No redirect” and a hint that says 410 Gone (hierarchical: the public parent is the default); move: parents within depth 5 plus “Top level”. Then the preview (a dry run, POST …/<op> {dryRun: true}, debounced while typing; SkeletonLines while it loads; “Make a choice to see what changes.” before): “Articles n” with up to five titles, paths and Status (“These articles change/lose it in the same operation. One change can touch up to 400 articles.”), “Redirects n” as from → to mono rows with an outline “301 permanent” Badge and “Pages and feeds below the old address follow too.”, existing redirects that follow along. Consequences as Banners: error “Too large for one change” with the count and the 400 limit (too_large; the action stays disabled — also when a 409 too_large comes back), error “n published or in-review articles would lose their only category” (required_term), alert “Its archive will answer 410 Gone” / “stops being public” (404), default “becomes public”. Every dialog ends with a muted Camera line “One change, one snapshot. It goes live with the next publish.” The primary is destructive for retire and reject and disabled until the preview is in. A 409 conflict stays in the dialog; success toasts with the counts and invalidates terms, taxonomies, entries, redirects and snapshots. CI checks that only this dialog runs governance operations (approve excepted) and that it previews first.
  • Taxonomy settings (TaxonomySettingsDialog, LayerDialog size="lg", one component for “New taxonomy” and a taxonomy’s settings; validation in lib/taxonomy-settings.ts mirrors core-ts #checkTaxonomy / updateTaxonomy): four sections separated by hairlines, each a Text bold heading with a muted line. Name: key Input (mono; lower-cased as typed; reserved words of the filter grammar — RESERVED_FIELDS from @cloudhal/core-ts/filter — and taken keys refused while typing; disabled with “Keys never change” when editing), then singular/plural Inputs per site locale in a container-query two-column grid (default locale required, others optional but in pairs). Terms: Switch “Terms can have a parent” with its hint (an error Banner while terms still have parents and it is turned off); Radio.Group appearance="card" “Who adds new terms” — People only (success ShieldCheck “Safest”), Agents propose (info Star “Recommended”), Agents add freely (warning Warning “Use with care”) — each card saying exactly what agents may do (an error Banner while proposals wait and “People only” is chosen). On articles: Switch Required, number Input “At most per article” (empty: 50), Switch “Applies to every collection”; off shows an InsetPanel of Checkboxes, one per collection in mono (/posts). Archive pages: Switch public archives, the address as an InputGroup with the primary host as addon (placeholder /<key>, follows the key until typed; errors name the colliding taxonomy, page or collection prefix), then what saving does to addresses — moving an enabled archive previews the prefix redirect the core leaves (/category → /topics with an outline “301 permanent” badge in an InsetPanel, “Pages and feeds below…”, re-pointed redirects), turning archives off is an alert Banner “Archive pages will answer 404”, turning them on a default Banner —, Switch RSS feed (disabled without archives), number Input “Articles needed for search engines”. The Camera one-snapshot line closes the body; primary “Create taxonomy” / “Save settings” (disabled until something changed). A created taxonomy’s tab opens.
  • New term (CreateTermDialog, LayerDialog): name (default locale), slug derived from the name until edited (InputGroup with the base path), parent for categories (TermCombobox with “Top level”), an “Internal” Switch with its hint, and an alert Banner size="sm" “This looks like an existing term” naming near-duplicates (similarTerms: same folded form, plural, small edit distance). People create active terms directly.
  • Pickers (components/TermPickers.tsx, article editor card “Categories and tags”, ArticleTermsCard): hierarchical taxonomies get a multi-select Combobox (TriggerMultipleWithInput, Combobox.Chip) whose options are the tree — children indented with their path (“in News”), proposals marked — matched on names, synonyms and slugs; at most maxPerEntry (other options disabled, the description says so); one choice is the primary automatically, several show a Select “Primary category” (and a star on the primary chip). Required taxonomies say “Needed to send it to review or publish it”; the editor’s valid(status) refuses review/publish without a primary (field error + toast “Choose a category first”, the card scrolls into view). Flat taxonomies get the tag Autocomplete (filter={null}, suggestions from tagSuggestions): existing tags first — exact matches on top, then prefix and substring matches on name, synonym (“synonym “JS””) or slug, more used first; a query that resolves (RFC-0002 §6.3, plural folding included) never offers “create”; otherwise the last option is Plus “Create “x”” with a warning line “Looks like LLM…” when near-duplicates exist (they are listed as options too). Chosen tags are TermChips (@cloudhal/ui) with a remove button; proposals are quieter with an Hourglass and a Tooltip. The article saves terms (canonical: no empty assignment) and tags: []; legacy tags on a migrated site show as a secondary Banner “… become tags when you save”. A site without taxonomies keeps the legacy TagInput on tags and shows the migration Banner.
  • Migration (TagsMigrationBanner + TagsMigrationDialog): an info Banner “Move your tags to categories and tags” with Banner.Action “Preview the move” on the articles list, the article editor and the taxonomy screen while the site has no taxonomies or entries still carry tags. The dialog runs the dry run on open and shows an InsetPanel summary (tags found, articles rewritten, tags new/reused, taxonomies created), skipped articles as an alert Banner, and “Studio takes a snapshot before and after…”; “Run the move” executes it, after which the dialog offers “Undo the move” (revert by migration id).
  • Articles list: a Category column (primary first with a warning star, +n with the rest in a Tooltip; a warning Badge “No category” for review/published articles of a required taxonomy) and a Tags column (two outline Badges and +n). Facets in the toolbar: a Toolbar of Select render={<Toolbar.Button />} — category (tree, indented) and tag (by usage) — plus a ghost “Clear filters”; they become ?category=&tag= and the API filter category:<slug>>*+tag:<slug> (sub-categories included, + sent as %2B). Filtered-empty is its own Empty.
  • Redirects (site › Settings › Redirects, list template, routes/RedirectsPage.tsx): toolbar search + a source Select; Table columns Old address (mono, /… for prefix redirects), Sends to (mono with an arrow; an outline “External” Badge with the host in a Tooltip), Type (301/302 badge with a Tooltip, “and below” for prefix), Source (slug change info TextAa, merge secondary ArrowsMerge, retired warning Archive, manual outline User; the source node linked to its editor; the note), Updated, row menu (Edit, Delete → ConfirmDialog that says the old address answers 404 after the next publish, and that an automatic one breaks links). RedirectDialog (LayerDialog): old address in an InputGroup with the primary host (normalised: a pasted URL keeps its path; read-only when editing), target, “Applies to” and “Kind” as card Radios (302 only for manual redirects), note; validated like core-ts (pattern, served paths — pages, published articles, public archives —, duplicates, http:, self and loops through existing redirects). An external https target goes through a second step: alert Banner “Visitors will leave for help.example.org”, the host in an InsetPanel, primary “Redirect to help.example.org” (CI checks it).
  • Snapshot diff (§2i): terms are named in the default locale with their archive path and link to the term editor, taxonomies by label and base path, redirects as from → to; changed and added articles list their term changes as outline badges under the row (“+ tag JavaScript”, “− tag LLM”, “Primary category → News”), from GET …/diff/…?assignments=1.

2q. Team: members and invites (account surface › Settings › Team; RFC-0005, ADR-0018)

List template (routes/MembersPage.tsx, /team, ?view=activity), helpers in lib/roles.ts, dialogs in components/MemberDialogs.tsx and components/InviteDialog.tsx, badges and the explainer in components/Roles.tsx. The sidebar’s Settings group lists Team (member count MenuBadge) above Account; the account menu has Team too.

  • Header: UsersThree 24 px, title “Team”, primary “Invite people” (useGate("members.invite"): disabled with the reason for Authors and Contributors), PageLayout tabs Members | Activity (sub-views of one page).
  • Members table (LayerCard p-0 + Table, ListToolbar search, Pagination over 25): Member — the Avatar block (initials, @cloudhal/ui), bold name, an outline “You” badge on the person’s own row, the email in secondary text only when the server sends it (members:manage); Role — RoleBadge (icon + name, a Tooltip with what the role may do: Owner/Administrator info, Editor/Author secondary, Contributor outline); Joined — RelativeTime; sticky row menu. Owner first, then by rank and name. Below sm the role sits under the name and Joined is left out, so a row fits next to its menu at 375 px.
  • Row menu (DropdownMenu): Change role…, Create password reset link…, Transfer ownership… (only for the Owner; on non-Administrator rows disabled with “Ownership goes only to an Administrator”), separator, Remove from team… (danger). Every item follows the rank rule (roleChangeProblem: self, the Owner, no members:manage) and stays in place disabled with a tooltip naming why (GatedItem, the tooltip on a render={<div />} wrapper). Someone without members:manage gets one disabled ⋯ trigger with the reason instead of a menu of no’s.
  • Change role (ChangeRoleDialog, LayerDialog): the member in an InsetPanel (identity + current RoleBadge), a Select of every role but Owner (ownership moves only by transfer) whose description is the chosen role’s summary, an InsetPanel “Now [Editor] → [Author] · Applies at once”, and for a demotion an alert Banner “Their agent keys narrow too”. A 403 rank stays in the dialog in words (members.rank.<problem>). When the server returns keysNarrowed, the dialog turns into its result before closing: an info Banner “Agent keys narrowed: n” and one row per key (name, new mode as ModeBadges when it changed, the scopes it works with now as ScopeBadges, “Its elevation ended”). Otherwise a toast.
  • Remove (RemoveMemberDialog, LayerDialog.Alert, destructive primary): the member, then what happens, one icon line each — signed out everywhere; agent keys disabled (counted from GET /api/agent-keys?all=1 when the caller has agents:manage); pending invites cancelled (counted); their agents’ waiting requests denied; “their articles and activity stay; inviting them again restores access”. The toast repeats the server’s counts.
  • Password reset link (ResetLinkDialog): confirm (works once, one hour, signs them out everywhere, voids earlier links) → the link once, like a key reveal: alert Banner “You won’t see this link again”, ClipboardText size="lg", expiry in an InsetPanel (“Expires in 1 hour (absolute)”). Primary “I’ve copied the link”; closing drops it.
  • Transfer ownership (TransferOwnershipDialog, LayerDialog.Alert): strong words — alert Banner with Crown “You can’t undo this yourself”, “Ada becomes the Owner and you become an Administrator. Only Ada can give ownership back.”; the change as RoleBadges Administrator → Owner; three consequence lines; PasswordField “Your password” (re-entry; wrong_password lands on the field, not_administrator in a Banner); destructive primary “Transfer ownership”.
  • Pending invites: flush SectionCard (EnvelopeSimple, count Badge) shown to people with members:invite; Table of email (with “Link”/“Sent by email”, a warning Badge “Not completed” for an accepted invite without an account), role, expiry (“in 7 days”, rounded by expiresIn, the absolute time under it; a warning dot “Expired”), inviter (“You” + RelativeTime), row menu Re-invite (opens the invite dialog prefilled; the server replaces the pending invite) and Revoke (ConfirmDialog: “the link stops working at once”). Invites for roles above the person’s are disabled with the reason. Empty: SectionEmpty with an “Invite people” button.
  • Aside: flush SectionCard “Roles” — RoleList, each role as a badge and one sentence (the person’s own marked “Your role”) — and “How invites work”: single use, bound to one email and role, expiry (7 days by default, Administrator invites within 72 h, from /api/instance), agent keys never exceed their person, one Owner.
  • Activity view: the deployment’s own feed (ActivityList source={{kind:"deployment"}}, GET /api/activity: invites, accepts, role changes, removals, transfers, key narrowing, labelled under tools.member*), without the skill and snapshot columns. Without audit:read a secondary Banner says who sees it.
  • Invite dialog (InviteDialog, LayerDialog, two steps in the same parts):
    1. Who and in which role: Input email (Enter submits; errors after a submit attempt), a Select of invitableRoles from /api/me (an Editor sees Author and Contributor only), the expiry Select of presets within the server’s bounds (1 hour, 1 day, 3 days, 7 days, 14 days; for Administrator at most 72 h, with the description saying so), the role explainer — a compact Table of the offered roles × Articles / Publish / Structure / Team, cells as dot Badges (green all, amber limited, grey none) with a Tooltip each, worked out from instance.roles.abilities — and, when the server can mail (invites.delivery includes email), card Radios “Copy a link” / “Send by email”. API refusals (rank, invalid_expiry, delivery_unavailable, already_member, too_many_invites, rate_limited) are words in an error Banner.
    2. The link, once: alert Banner “You won’t see this link again — share it privately, in a direct message; whoever opens it first joins as Editor”, ClipboardText size="lg", an InsetPanel with the role, email, “Expires in 7 days (absolute)” and “If it reaches the wrong person, revoke it from Pending invites.” Primary “I’ve copied the link”. The link lives only in component state. With email delivery step 2 is a success Banner “Sent to …”.

2r. Public token pages (/invite, /reset-password)

Template token, the auth frame of §2e without tabs. The secret is in the URL fragment (#chinv_…, #chrst_…): takeHashToken reads it once (memoised for re-renders), strips it at once with history.replaceState so it never stays in history, screenshots or Referers, and sends it only in POST bodies (/api/invites/inspect|accept, /api/password-reset/inspect|complete; CI checks). No session needed; no GET touches the token.

  • Invite: SkeletonLines while inspecting; then title “Join {deployment}”, description “{inviter} invited you as {Role}”, an InsetPanel with the RoleBadge, the role’s summary and the expiry; the email as a read-only Input (“The invite is for this address”). A new account adds Name and NewPasswordFields (new + repeat, a Kumo Meter with “Strength: fair” and one line of advice, tones by strength); an existing one (a removed person invited back) gets a secondary Banner and its password. Primary full-width “Accept invite”; success signs in, drops every cached query of the previous person, toasts “Welcome to {deployment}” and lands on /sites.
  • Errors in place: 410 or no token → error Banner “This invite can’t be used” / “This link is incomplete” with a “Go to sign in” LinkButton; someone else signed in → alert Banner “You’re signed in as …” with Banner.Action “Sign out” and the primary disabled with the reason; already_member → Banner with sign in; weak_password, wrong_password, name_required land on their fields.
  • Reset: the same pattern — “Choose a new password” for {name}, the expiry, the email read-only, NewPasswordFields, “You’ll be signed out on every device”; success shows “Your password is set” and a primary “Sign in” LinkButton; 410 says to ask for a new one (sign-in page or an Administrator).
  • Forgot password (/forgot-password, template recover, RFC-0011): the sign-in tab shows a small “Forgot password?” Link under the password (always: the sign-in screen cannot read /api/instance). One email Input and “Email me a link”; the answer is the same for every address — a default Banner EnvelopeSimple “Check your email” / “If {email} belongs to an account here, a reset link is on its way…” — so the screen never tells who has an account. A server without a sender (404 email_unavailable) shows a secondary Banner “This server can’t send email — ask an Administrator”. Both end with “Back to sign in”.

2s. Role gating (RFC-0005 I16)

The server decides; Studio only hides or disables what it would refuse and says why.

  • Source: GET /api/me (useMe, query ["me"]) — role, scopes, the can ability map, invitableRoles. useCan(ability) reads it; anything not known to be denied (loading, older servers) reads as allowed. useGate(ability) adds the reason, built from the least senior role that has the ability (minimumRole over /api/instance → roles): “Only Editors and above can publish others’ work.” Screens never compare role names (CI checks); only the Team screens do.
  • Patterns: a gated Button is disabled with title={gate.reason} (Kumo wraps a disabled titled button in a Tooltip; CI checks every disabled={!x.allowed} carries its reason); a gated menu item stays in place, disabled, its Tooltip on a render={<div />} wrapper; a screen the person may read but not change gets ViewOnlyBanner (secondary, LockSimple, “You can view this” + the reason) and its controls go inside ReadOnly (a disabled <fieldset>: inputs, buttons and menu triggers off at once, links still work). Sidebar items stay enabled: every member may read every screen.
  • Where: pages and the page editor (pages.write), categories/tags, the term editor and redirects (structure.write), site settings General and Domains (settings.write; preview links stay), the tags migration (settings.write), others’ articles (entryAccess: Editors any, Authors their own, Contributors their own until published — the editor shows ViewOnlyBanner and a disabled fieldset, and the articles list disables Delete).
  • Publishing (usePublishMode, usePublishSite in components/Publish.tsx): Editors and above “Publish”; an Author “Publish” when Studio’s guess (ownOnlyGuess: only their own published articles changed since the live snapshot) says own-only, else “Request publish” with its own confirm copy (“Your changes include work by others…”, “Send request”); a 202 shows an info notice “Publish requested — an Editor must approve” with “Open Approvals”. A Contributor sees Publish disabled with the reason; in the article editor the draft step reads “Submit for review” and Publish stays disabled (“An Editor publishes yours after review”). Snapshot rows use SnapshotTimeline’s publishDisabledReason / rollbackDisabledReason.
  • Approvals: canDecide === false disables Approve (and Deny, unless it is the person’s own or their agent’s request) with a reason line under the meta (approvals.decide.*: own request, not the key’s creator, mode above the role, missing scope, not own); a 403 cannot_decide toasts the same words. A person’s own request (cause role) shows a warning Badge Hourglass “Waiting for an Editor”, “{name} wants to publish the site.” and why their role can’t alone (from the server’s reason), and Withdraw instead of Deny/Approve. Approving such a request publishes it (“Approved and published”); 409 state_changed says the site moved on. People’s names carry their RoleBadge (from GET /api/members) here and in Activity.
  • Agent keys: useAgentModeRules carries the person’s cap — keyModes stops at it (a Contributor’s picker shows Plan, Manual, Edits and RoleCapHint “Your role allows agent keys up to Edits mode”), elevations offer only modes up to it, and the Elevate item is disabled with that reason. The scope picker shows each scope as the role grants it (scopeOptions): an own variant in place of the full scope (“Your role gives content:write only for your own work”), or the scope disabled (“Your role can’t give this to a key”). A key whose effectiveScopes are narrower than its stored scopes shows the effective ones with a warning Badge “Narrowed by role” (tooltip: the creator’s role now, and what it was given). Administrators get Your keys | All keys tabs; All keys lists every key with a “Created by” column (Avatar, name, RoleBadge) and a menu with End elevation and Revoke, while Elevate stays disabled (“Only the person who created this key can elevate it”).
  • Activity: without audit:read a secondary Banner “Your activity — Editors and above see everyone’s”.
  • Notifications (§2n): member-joined (success UserPlus, “Ada joined”, the role badge, “Open Team”) and role-changed (info ShieldStar, “Your role is now Author”, Editor → Author badges, who changed it); a new role-changed refetches /api/me, the members and the keys, so gating follows at once.
  • Account: the profile shows the person’s role as a RoleBadge row; the account menu’s header shows the name and the role.

2t. Navigation menus (RFC-0003 §8, ADR-0019)

People shape the site’s menus in Studio; agents propose changes over MCP and wait for approval. Helpers live in lib/menus.ts (draft operations through applyMenuOps, the item form and its contract checks, target options, the §6.3 delta lines, draftProblem), shared cells and hooks in components/Menus.tsx (TargetChip, HiddenBadge, useHiddenReason, useMenuReferences, UsedInMenusCard, MenuDeleteNotice, useStructureWrite).

  • Screen (site › Settings › Navigation, editor template, routes/NavigationPage.tsx, ?menu=<key>&item=<itemId>): PageLayout tabs per menu — “Header” (primary), “Footer”, then custom keys; the header actions are PreviewToggle, secondary “New menu”, ghost “Discard” while dirty and primary “Save menu” (disabled until dirty). Switching tabs with unsaved changes asks first (ConfirmDialog). EditorLayout with the preview column: main — banners (changed elsewhere, save problem, “n items are hidden on your site”), then the items SectionCard (flush, TreeStructure icon, count Badge “12 / 500” in the header that turns warning from 80 % of a limit, secondary small “Add item”); aside — “Settings” (Select “Levels” 1–6 with an inline error when items already sit deeper, then SettingRows for items, deepest level and fullest level with the same badges), DetailsCard (key, id, revision, updated) and DangerZoneCard “Delete this menu” (ConfirmDialog that says the theme’s derived default returns with the next publish and earlier snapshots keep the menu).
  • MenuTree (@cloudhal/ui block; Kumo has no tree): the WAI-ARIA tree pattern on flat rows — role="tree" with aria-describedby keyboard help, rows role="treeitem" with aria-level, aria-setsize, aria-posinset, aria-expanded on parents, a name from the label and a description from the chips; one tab stop (roving tabIndex). Keys: ↑/↓ move focus, →/← expand, collapse and step in and out, Home/End, Alt+↑/↓ reorder among siblings, Alt+→ nests under the previous sibling, Alt+← moves out right after the parent, Enter/F2 edit, Delete/Backspace remove; keys pressed inside a row’s own controls are theirs. Every move keeps focus on the item and is announced in a polite live region (“Status nested: position 1 of 1, under Docs, level 2.”); a refused move says why (first, last, no item above, top level, maxDepth, a full level). Pointer drag and drop is an enhancement: a drop line before/after a row (indented to its level) or a ring for “inside”, refused (danger tint, nothing moves) beyond maxDepth, past perLevel or into the item’s own subtree. Rows: a DotsSixVertical handle, the expand caret, hairline guides per level, the label (a fallback title is muted with “from the page title”), the target chip and the state badge on a second line, then the screen’s actions. Controlled: the block reports MenuTreeMoves (applyMenuOps “move” semantics); readOnly drops handles and moves.
  • Row actions: a ghost square “Edit” (PencilSimple, tooltip) and a ⋯ DropdownMenu — “Add item inside”, “Move up”, “Move down”, “Move under the item above”, “Move out one level”, separator, “Remove” (danger). Unavailable entries stay visible and disabled; “Add item inside” and the indent at the depth limit carry a Tooltip on a render={<div />} wrapper explaining the limit. Removing an item with children confirms first (ConfirmDialog with the count); removing a leaf only changes the draft.
  • Target chips (TargetChip, outline Badge with the kind’s icon): “Page /about”, “Article /posts/hello”, “Category News”, “Collection posts”, “External status.example.com ↗” (host only, so a label cannot hide where a link goes; “(opens in a new tab)” for screen readers), “Group”; a deleted target reads “Deleted page”.
  • Hidden items (HiddenBadge, warning + EyeSlash, secondary for “item above is hidden”): “Hidden: page deleted”, “page not published”, “archive empty or not public”, “term retired”, “no page lists it”, “nothing inside is shown”, with the full explanation in a Tooltip. Rows hidden for a reason of their own get a secondary small “Fix” DropdownMenu — “Choose another target…” (the item dialog) or “Add item inside” for an empty group, and “Remove”. Resolution comes from the server’s preview helper (POST /menus/preview, the renderer’s menuLookup + resolveMenu), so Studio, MCP and the site agree.
  • Item dialog (MenuItemDialog, LayerDialog lg): the target picker — a grouped Combobox (Combobox.Group + GroupLabel): Pages, Articles, one group per taxonomy (terms as a tree, indented, retired terms left out), Collections, Other (“External link”, “Group (no link)”); options show the path in mono and a status Badge (Draft, In review, Archived, Proposed, Internal). A target that will stay hidden adds a secondary Banner (“This is a draft…”). External links get a mono “Web address” Input (https only, validated like the contract: http: and user-info refused, errors after leaving the field or an attempt), an InsetPanel “Visitors leave for status.example.com.” and the Checkbox “Open in a new tab”. Groups explain themselves in an InsetPanel. Then one label Input per site locale: the placeholder is the fallback (the target’s title, or the default-locale label for other locales), the description says what happens when it stays empty, and it is required for collection, URL and group items. New items choose a “Place” (Select: top level or under any item with room). The dialog edits the draft only.
  • Save (MenuSaveDialog, LayerDialog lg): the change in the RFC-0003 §6.3 format inside an InsetPanel — one row per line with a coloured sign (+ success, − danger, ↕/↳ info, ~/! warning) and the sentence (““Status” → external: status.example.com, new tab, under “Help”, position 1”; a fallback label is marked “(title)”), the description “3 changes · 14 → 15 items”, an alert Banner when added or edited items stay hidden, and the Camera line “One change, one snapshot. It goes live with the next publish.” Before it opens, draftProblem catches what the store would refuse (an empty group, a missing label, too deep, too many) as an error Banner and focuses the item. Confirming sends one PUT with the menu’s rev. A 409 keeps the edits and shows an alert Banner with “Compare with latest” (rebase the draft on the newer menu; the delta then shows the difference) and “Load latest”. CI checks the save path.
  • Preview (NavigationPreview block in a SectionCard with segmented Desktop/Mobile Tabs and an “Unsaved changes” dot badge): a neutral sketch of the visitor-facing header (site name, top-level items, dropdowns for children up to three levels — the blog theme’s — the active trail opened, external links marked) and footer (“Built with Cloudhal”, the footer menu or the theme’s RSS link), plus page lines between them; the edited part is tinted. “Preview as” takes a path and marks the active item with the renderer’s rules (exact → underlined, prefix/context → marked; a sentence says which rule matched); a language Select appears on multi-locale sites. Custom keys get a secondary Banner: the theme decides where they appear.
  • Without a menu (well-known keys): the main card “Current header” lists the derived items read-only in a MenuTree (readOnly), explains why, and offers primary “Create menu from current pages” and secondary “Start empty”; the aside explains “How menus work” (linked by id, hidden never broken, agents ask first, one save one snapshot). From pages (FromPagesDialog) runs the dry run on open and lists the items in an InsetPanel (hidden ones with their badge), a Checkbox “Include top-level categories (n)” re-runs it, and “Create menu” is the only write. New menu (CreateMenuDialog): card Radios for the well-known places the site lacks plus “Custom key” (a slug Input, immutable later), and “Levels”.
  • Elsewhere: the pages list marks linked pages with an outline TreeStructure Badge “In menu” (the menus in a Tooltip); page, article and term editors carry “In menus” (UsedInMenusCard: “Header (primary) · About” linking to ?menu=primary&item=…, or SectionEmpty “Not in any menu”); every page and article delete (DeleteResource notice) shows an alert Banner naming the items that will be hidden and flagged (MenuDeleteNotice). The snapshot diff names menu rows “Header (primary)” and adds outline badges “+2 −1 ↕1 ~0” and “3 → 4 items” (?menus=1). Activity labels the five MCP menu tools.
  • Who: writes need structure.write (Editor and above, RFC-0005); others see the same screen read-only with a secondary Banner, no handles, no Save, no Add, no row actions (useStructureWrite reads /api/me’s ability map; the API refuses anyway).

2u. Media library (RFC-0004 §8, ADR-0020)

Files are Asset nodes; people manage them in Studio over REST, agents over MCP. Helpers live in lib/media.ts (the type table, sniffing and image header reader are @cloudhal/core-ts/media’s, so Studio refuses what the pipeline refuses before sending a byte; names, sizes in the person’s locale, the details form and its checks) and lib/uploads.ts (the only place file bytes leave Studio); cells in components/Media.tsx (KindIcon, AssetFrame, AssetBadges, UsageBadge), the queue in components/MediaUpload.tsx, the picker and the image slot in components/MediaPicker.tsx, the takedown in components/TakedownDialog.tsx.

  • Library (site › Content › Media, list template, routes/MediaPage.tsx): Images 24 px, primary “Upload” (useGate("assets.upload"), disabled with the reason; it opens the system chooser through useFileChooser). ListToolbar search (titles, alt text, captions, credits, file names; 250 ms pause) and, on the right, a ghost “Clear filters” while filtered, a Toolbar of Select render={<Toolbar.Button />} — kind (All kinds, Images, Video, Audio, Documents, Data files) and state (All files, Unused, Missing alt text, Added by agents, Retired; retired files never show otherwise) — and a segmented Tabs Grid | List (icon + label, the label hidden on phones; remembered per browser). The body is wrapped in MediaDropZone variant="overlay": dropping files anywhere on the library opens the upload dialog with them. Grid: MediaGrid of MediaTiles linking to the file (AssetFrame size="tile": the picture from the private content route, or the kind’s icon and extension; name; “PNG · 34 KB”), solid overlay badges: usage (“Unused”, “Used in 2 places”) and, compact, retired and “No alt”. List: LayerCard p-0 + Table — File (thumb, name linked, state badges), Type (with dimensions), Size, Used in (UsageBadge), Added (name + RelativeTime), sticky RowMenu. Loading: SkeletonLines (grid) or TableSkeleton; empty and filtered-empty are bare Emptys (the first with the Upload button). Paging is the cursor API: “Showing n files” with a secondary “Load more”, or “All files are shown”.
  • Upload (UploadDialog, LayerDialog lg, and the picker’s Upload tab; one UploadPanel): a MediaDropZone (“Drop files here”, the limits, “Choose files”; paste works while it has focus), then an InsetPanel “Files” with “n of m uploaded” and one row per file — ImagePreview thumb of the person’s own file (an object URL) or MediaKindFrame, name, type Badge, size and dimensions, and the state in words: “Checking the file…”, “Ready to upload”, a Meter with the percentage, “Uploaded”, “Already in your library” (the server knew the hash), “Stopped”, or the refusal in red (media.upload.errors.*: type not accepted, HEIC → convert to JPEG, contents don’t match the type, over the cap, too many pixels, SVG with scripts, quota, taken down, link expired or used, too many uploads waiting, at most 20 at a time, connection lost). Images in the queue get an “Alt text” Input (required) and a Checkbox “Decorative — it adds nothing a reader would miss”; a photo with GPS adds an alert Banner “This photo contains the place it was taken” and the Checkbox “Keep location and camera details” (people only; stripped by default). The primary reads “Upload n files” and stays disabled until every image is described, then “Done”; dismissal is disabled while bytes move; a ghost ✕ removes a waiting file or stops one in flight. One ticket request for the batch, then one streamed PUT per file (three at a time), each to its own signed, single-use URL; the dialog never posts multipart forms (CI checks).
  • A file (routes/AssetPage.tsx, editor template without a preview column): title = the file’s name; description = kind Badge, UsageBadge, state badges and the public path in mono; actions = secondary LinkButton “Open file” (the private content route, new tab) and primary Save (disabled until dirty, or for a file the person may not edit). Banners: error “This file was taken down” (with the reason; the Owner gets Banner.Action “Allow it again”), secondary “This file is retired”, ViewOnlyBanner when someone else uploaded it and the person lacks assets.manage (the server’s editable; the form sits in ReadOnly). Main: “Preview” (AssetFrame size="card", fit="contain"), “Description and credit” (a segmented locale Tabs on multi-locale sites; Title; for images “Alt text” InputArea with a CharacterCount against 125 and a “Decorative image” Switch with its hint — alt is required in the default locale unless decorative, checked by valid() before every save like the server’s rule; Caption; Credit and License side by side in a container grid — the license Select offers common SPDX licences, “Another SPDX license…” with an identifier Input, and “Link to a license…” with address and name), “Address” (InputGroup with /media/<16 hex>/ addon and the extension as suffix; old links redirect), “Used in” (flush Table of the nodes with kind Badge, title linked to its editor, path in mono, and the slot — cover, share image, section, archive image, text — or SectionEmpty “Not used anywhere”). Aside: “File” (dl of SettingRows: type and MIME, size, dimensions, SHA-256 and the as-uploaded SHA-256 as InlineCopyText in 0.9 em mono, public URL and checksum file “live after the next publish”; ghost “Download” in the header), “Added by” (ActorCell, “Uploaded as …” / “Fetched from …”, RelativeTime), DetailsCard (ID, revision, updated) and DangerZoneCard items — Retire/Restore (secondary, assets.manage; restore refused while taken down), Delete (assets.manage, disabled while used: “Used in n places. Remove those uses first, or retire it instead.” → DeleteResource naming the file) and Take down (Owner only, TakedownDialog). Every disabled action carries its reason.
  • Take down (TakedownDialog, LayerDialog.Alert): alert Banner “You can’t undo this”, an InsetPanel “What happens” (bytes deleted from storage and every snapshot; 410, or 451 for a legal reason; the file retires and pages lose the picture; the same bytes refused until an Owner allows them; browsers and services that saved a copy may keep it), a required “Reason” InputArea (audited with the name) and a Checkbox “This is a legal requirement (answer 451)”; destructive primary “Take down”.
  • Picker (AssetPickerDialog, LayerDialog lg): segmented Tabs Library | Upload. Library: a search Input and a MediaGrid of active images as toggle MediaTiles (brand ring and check when chosen; the slot’s current file preselected), “Load more”, SectionEmpty with a switch to Upload when there is nothing. Upload: the upload panel for one image with an inline secondary “Upload”; a finished upload becomes the choice. Primary “Use this image” writes {assetId, src: url, alt, width, height} (imageRefFor). ImageRefField is the image slot everywhere — the article cover and the term image (inside CoverImageCard, §2o) and the page editor’s Image and Hero sections (Hero optional) — so every slot reads and behaves the same.
  • Elsewhere: the sidebar’s Content group lists Media (Images) after Categories & tags, and the command palette has “Go to media”; the snapshot diff names media rows by title or file name with “PNG · 34 KB” below and links them to the file (deleted files read “No longer exists”); Activity labels the agents’ asset tools and people’s asset.* events (“Upload file”, “Take down file”).

2v. Analytics (RFC-0006 §10, ADR-0021)

Cookie-less site analytics: page views and visits (unique visitors only when the owner turns them on), sources, countries, devices, Core Web Vitals, errors and crawler renders. People read them in Studio over REST, agents over MCP (analytics:read). The blocks are in @cloudhal/ui; the Analytics screen is below. Blocks take every word as a prop (Studio passes useT("analytics") strings, keys analytics.* in the lazy namespace i18n/lazy/analytics.{en,tr}.ts (§1 “Lazy message namespaces”) and pageHeaders.analytics.*); numbers, dates and country names go through Intl in the Studio locale (createAnalyticsFormatter), times are UTC because buckets are.

  • Entries. StatTile/StatTileGroup, AnalyticsRangePicker, BreakdownTable/BreakdownMeters, AnalyticsQuotaBanner, AnalyticsBotBanner, useChartPalette and the range (analytics-range.ts) and formatting (analytics-format.ts) helpers live in the main entry; @cloudhal/ui declares sideEffects (CSS and the ECharts registration only), so a screen pays only for the blocks it uses. Studio’s build fails when analytics code reaches the initial load or the entry/initial budget is exceeded (apps/studio/bundle-budget.ts); the router validates the Analytics URL with lib/analytics-search.ts and the range checks only. The blocks that draw with ECharts — AnalyticsTimeseries, CountryMap, ReadersSummary — live in @cloudhal/ui/charts, which registers ECharts once with only the modules they use (LineChart, BarChart, MapChart, grid, tooltip, visual map, aria, mark line, brush, toolbox, SVGRenderer). Only the lazy Analytics route and the editors’ Readers card import it, so nothing else pays for ECharts (echarts@^6, Apache-2.0, Kumo’s optional peer).
  • StatTile (in StatTileGroup, a dl): label (Text sm secondary) with an optional info Button + Tooltip (hint: “Daily unique visitors, summed”), the value as a 20 px heading in tabular-nums (“—” plus emptyLabel for screen readers when unknown), a DeltaBadge against the previous period — success + TrendUp up, warning + TrendDown down, secondary + Minus within ±2 % (higherIsBetter={false} flips the tones), its words (describeDelta: “+12% vs previous 30 days”) in a Tooltip and for screen readers — and an optional Sparkline (token stroke, decorative). Loading = SkeletonLine in the value’s place. Each tile is an outline InsetPanel. The group is a container-query grid: two columns, three from @lg for three tiles, four from @2xl for four; density="compact" stays at two (side columns, Readers card).
  • AnalyticsRangePicker (page header actions): segmented Tabs size="sm" Today · 7 days · 30 days · 90 days · 12 months; a secondary “Custom…” Button (CalendarBlank) opening a Popover with Kumo’s DatePicker (mode="range", two months, timeZone="UTC", the range in force preselected with its last month on the right, today/endMonth/disabled so nothing after today (UTC) can be picked; two clicks pick a range, a click on a finished range starts a new one) in the Studio locale through calendarLocaleProps (§2v Kumo locale), a “Days are in UTC” line with GlobeHemisphereWest, a live line with the picked range or the hint, an error past two years, ghost Cancel and primary Apply (disabled until valid); and, when the screen passes onCompareChange, a small “Compare” Switch. Controlled through {preset} or {from, to} (UTC days, to exclusive — the URL’s ?range=30d / ?from=&to=); the picked days are applied on Apply. The calendar (react-day-picker and date-fns inside Kumo, ~67 kB minified) is React.lazy from Kumo’s deep components/date-picker entry, preloaded on hover or focus of “Custom…”, with SkeletonLines until it arrives. Kumo’s DateRangePicker is deprecated and hard-codes English: never use it. A custom range shows on the button (“Sep 1 – 30, 2026”, aria-pressed). resolveAnalyticsRange turns a range into UTC days and the bucket size (hourly ≤ 2 days, daily otherwise); previousAnalyticsRange gives the comparison period. The two months wrap onto two rows when the popover is narrower than both (react-day-picker’s .rdp-months wraps), so it fits a phone; the block also takes trailing controls (the screen’s refresh button) that wrap with it.
  • AnalyticsTimeseries (the “Traffic” card, 280 px; the Readers card, 120 px type="bar" compact): Kumo TimeseriesChart — gradient lines, series colours from useChartPalette (brand, purple, teal, orange tokens), yAxisMinInterval={1}, Intl ticks (compact numbers on the y axis), tooltipValueFormat, tooltipTimestampFormat (the bucket in the Studio locale and UTC — “Thu, Oct 8, 2026”, “Oct 8, 14:00”, “8 Eki 2026 Per”; Cloudhal’s Kumo patch, §2v Kumo locale), tooltipFooter “Times in UTC”, the current partial bucket dashed (incomplete.after), loading, isDarkMode from data-mode, ariaDescription with the numbers. Kumo’s time axis works in the browser’s zone, so the block shifts instants by the local offset and formats ticks and the tooltip’s time back in UTC: the tooltip, the axis and day boundaries all read UTC. While loading, Kumo’s skeleton (which announces “Loading chart”) sits in an aria-hidden wrapper and a role="status" line speaks labels.loading instead. Above the plot, ChartLegend.SmallItems with each series’ total and a ghost “Show as table” toggle (aria-expanded) that swaps the plot for a scrolling, focusable Table of every bucket — the chart’s text alternative. Overlapping axis labels are hidden.
  • BreakdownTable (flush SectionCards: Top content, Sources › Channels | Referrers | Campaigns, the countries list): item (16 px channel icon, title as a Link to its editor or plain text — referrer hosts are never linked, a mono detail line such as the path, a badge slot for the kind or “No longer exists”), the metric and an optional second metric in tabular-nums, and the share as a Kumo Meter whose label is visually hidden (the row names it) with “12%” beside it. limit folds the rest, server-marked other rows and whatever the period’s total leaves unlisted into one muted “(other)” row (its second metric shows “—” unless it is known). Container-aware: the second metric appears from @lg, the bar from @sm, so a phone keeps names, figures and percentages. Loading = skeleton rows inside the table; no rows = the screen’s empty (SectionEmpty). BreakdownMeters: the same rows as a list of labelled Meters (“62% · 7,700”) for devices, browsers, operating systems and an article’s top channels (showOther={false} lists only the top rows, shares still of the whole); a bar list reads better than a pie, so no pie charts.
  • CountryMap (flush “Countries” SectionCard in the aside): Kumo ChoroplethMap (aspectRatio 1.6, showLegend; the legend’s ends are labels.high/labels.low, merged into ECharts’ visualMap.text by useChartFrame after Kumo’s own options, because Kumo hard-codes “High”/“Low”) over Natural Earth 1:110m admin-0 shapes passed in by the screen (lazy-loaded; a pulsing bg-kumo-recessed placeholder until they arrive), joined on ISO_A2_EH (Natural Earth’s ISO_A2 is -99 for some countries). Never tooltipFormatter — Kumo inserts its return value as raw HTML (the design lint refuses it in screens and blocks): the block writes each feature’s localised name (Intl.DisplayNames) into the shapes and joins on it, so Kumo’s own escaping tooltip reads “Germany · 1,204”. Shading uses log1p so one large country does not wash out the rest (the tooltip shows the real figure); the ramp runs from the brand faded into the card to the brand pushed towards the contrast colour, and countries without data use --color-kumo-fill (--color-kumo-recessed vanishes on a dark card). Under the map, the top eight countries as a compact BreakdownTable (XX “Unknown”, T1 “Tor”, the rest “Other countries”) — the map’s text alternative; the map itself is a role="img" with a description.
  • AnalyticsQuotaBanner: the Free-plan warning, an alert Banner (Warning fill) with the explanation, a warning-toned Meter “Requests yesterday” (“62,400 of 100,000 a day”) and the screen’s Banner.Actions (“Lower vitals sampling”, “Analytics settings”), wrapping under the text on phones. Shown when shouldWarnFreePlan(requests, planHint): not marked paid and yesterday’s page views + analytics requests + renders above 60,000.
  • AnalyticsBotBanner: “possible bot traffic” (RFC-0006 §5.5), an alert Banner (Robot fill) with three parts in its description — what was seen (“1,502 page views arrived in the hour from Oct 11, 3:00 AM UTC, 250.3 times a usual hour. The numbers for this period may be inflated by scripts posing as visitors.”), what to do (“Check that day’s top pages and referrers. If it happens again, set analytics.rateLimit in cloudhal.json…”) and a Collapsible “Why this warning” with one line per reason (the spike against a usual hour, the share of visits without a referrer, one browser, one page — each with the threshold it crossed) and a secondary note that it is an estimate from patterns, not proof, and that a post shared in chat apps can look the same. The screen’s Banner.Action (“Show that day”) wraps under the text on phones. Words are props; the screen words each reason from its code.
  • ReadersSummary: the body of the editors’ “Readers” card (§10.2) — compact StatTiles (Page views, Visits), the 120 px daily bar chart, the top three channels as Meters, the slug-history line (“Old addresses still bring readers: 42 this month”, ArrowBendDownRight) and a ghost LinkButton “Open in analytics” (/analytics?filter=node:<id>). empty replaces the body with the card’s own state: never published, no readers yet, or — for Contributors — the reason (§2s).
  • Colour, theme and motion. useChartPalette resolves Kumo’s tokens to hex through the browser (a probe element and a 1×1 canvas) and again when data-mode or the OS scheme changes; without a canvas it falls back to Kumo’s ChartPalette, the same family. Charts are destroyed and redrawn in the new theme. With prefers-reduced-motion the charts’ animations, the meters’ transitions and the map placeholder’s pulse are off. Every chart is resized whenever its card changes width (Kumo’s own observer skips its first report), including in a hidden tab.
  • Screen (routes/AnalyticsPage.tsx, template dashboard, lazy route /sites/:id/analytics, RFC-0006 §10.1; cards in components/AnalyticsSections.tsx, words and shared hooks in components/Analytics.tsx, URL and vitals helpers in lib/analytics.ts): sidebar Overview › Analytics (ChartLine) after Overview and “Go to analytics” in the palette. PageLayout (crumbs SiteCrumbs › Analytics, ChartLine 24 px, “Analytics”, “Visits to your site, counted without cookies or personal data.”, no page tabs); actions = AnalyticsRangePicker with the compare Switch (on by default) and the RefreshButton passed as its trailing control, so on phones the presets fill one row and Custom… · Compare · refresh the next. The range lives in the URL (?range=7d, ?from=&to=; 30 days is the plain URL, presets go to the API by name so the server’s clock decides the days), &compare=0 turns deltas and the previous-period line off, &filter=node:<id>|channel:…|country:XX|device:… filters every report that takes one (a secondary Banner with Funnel names it — “Showing Germany only” — with “Show all traffic”; the site-wide errors and crawler cards are left out while filtered, the vitals device switch is disabled). “Today” refetches every 60 s while visible; the refresh button invalidates every ["analytics", siteId, …] query. Each card fetches its own report (useReport), shows its own skeleton, and on failure a SectionEmpty “This could not be loaded” with “Try again”; the summary failing shows AnalyticsErrorBanner above the cards (“Cloudflare’s analytics service is busy” for a retryable 503). Reads wait for /api/me (useAnalyticsAccess), so a Contributor gets only the secondary LockSimple Banner “Analytics is for Authors and above” with the reason and no request.
    • Banners, above the cards, at most one of each: not running (secondary, features.analytics off or store: "none"), can’t read yet (secondary, reader: "none"), the Free-plan warning (AnalyticsQuotaBanner with “Analytics settings” when shouldWarnFreePlan(quota.requests, planHint)), query budget reached (secondary, ClockCountdown), off for this site but with history (secondary, “Turn on in settings”), and possible bot traffic (AnalyticsBotBanner, from …/analytics/bots, when the level is watch — title “Unusual spike in page views” — or likely — “Possible bot traffic”; site-wide, so it ignores the filter; “Show that day” sets ?from=&to= to the busiest hour’s UTC day and is left out when that day is already shown; not asked while live reads are unavailable — can’t read yet, budget spent, nothing recorded — or the period has no page views; a failed read shows nothing, since a missing warning is better than a false one).
    • Empty states (a bare Empty in the columns’ place, only when the period has no page views and no filter): the server has no analytics (404) or the deployment records nothing → “Analytics is not running” with commandLine cloudhal deploy; the site is off → “Analytics is off for this site” with primary “Turn on in settings” (useGate("analytics.settings"), disabled with the reason; it opens settings#analytics); can’t read yet → the A1 explanation; nothing in 12 months → “No visits yet” with “Open the site” (primary domain); a quiet period → “No visits in this period” with “Show the last 12 months”.
    • Main column: “Traffic” (StatTileGroup: Page views, Visits with its info hint, Unique visitors only when on or present, Views per visit; deltas from the summary’s previous period, sparklines from the series; while three tiles share two columns the third spans both; AnalyticsTimeseries 280 px with Page views, Visits and — when comparing — “Page views, previous period” shifted onto the current days; a muted note when the answer is partial or estimated; ghost “Export CSV” in the header → …/analytics/export), “Top content” (flush, BreakdownTable limit 10 with visits as the second metric, kind outline Badge, titles linked to the article, page or term editor, “No longer exists” muted; “Show all” → LayerDialog lg with 100 rows, Pagination of 20 and showOther={false}), “Sources” (flush; header Tabs Channels | Referrers | Campaigns, campaigns with their own Campaign | Source | Medium Tabs row; by visits; channel icons; referrer hosts plain text with a note), “Errors and redirects” (flush; header Tabs Broken links | Redirects; status Badge 404 warning, 410/451 secondary, 5xx error, 3xx info; the requested path truncated mono plain text; “Linked from” linked to the page or article editor when the path is one, else mono text or “Typed or external”; RelativeTime; a sticky square ghost Signpost button with a tooltip opens RedirectDialog prefilled with the path (initialFrom), disabled with the reason without structure.write; a note “Includes every error, cached or not…”).
    • Aside: “Countries” (flush CountryMap over the Natural Earth shapes in src/assets/geo, imported with ?url and fetched once, never cached out), “Devices” (Tabs Device | Browser | OS + BreakdownMeters, top 5 + other), “Core Web Vitals” (Tabs All | Mobile | Desktop; per metric the short name and full name, a rating Badge, a Meter of good samples toned success/warning/danger with “p75 2.1 sec · 80% good”; “From 1 in 5 page views…”; sampling off → SectionEmpty with “Analytics settings” for those who may change it), “AI and search crawlers” (flush Table, outline Badge “Cache misses only”, class Badge AI info / Search secondary / others outline), “About this data” (SettingRows: Privacy “Cookie-less” success dot, Unique visitors On/Off dot with “Change in settings”, while they are on “Privacy signals” with the share of page views that sent GPC/DNT (“5% of page views”), Source live/daily totals/mixed, on Cloudflare the “Cache hits (estimated)” Meter (“about 90%”, hint: 1 − pages rendered ÷ page views, no-JS visitors not counted; hidden when the API returns null), the Free-plan request Meter “198 of 100,000 a day on Workers Free”, the live query budget Meter when budgeted, “Daily totals updated” RelativeTime; an InsetPanel saying when totals are computed).
    • Every Kumo string on the screen follows the Studio language (the calendar, the map legend, the chart tooltip’s time and loader; §2v Kumo locale). The overview “Last 7 days” card, the editors’ Readers card (SectionCard + 7 days | 30 days | All time Tabs around ReadersSummary) and the site settings Analytics section (§2k, RFC-0006 §10.3) are separate screens.
  • Readers card (built, components/Readers.tsx + lib/readers.ts, keys readers.*, a lazy namespace loaded with the editors, the overview and the lists, §1): ReadersCard in the article editor’s aside right after the search preview, and first in the page editor’s aside (RFC-0006 §10.2 “in the page editor’s too”). SectionCard “Readers” (ChartLine, id="readers") with segmented Tabs size="sm" 7 days | 30 days (default) | All time in its header; “All time” asks from = the first publish (publishedAt, else createdAt) cut to the API’s two years, and is never compared; 7 and 30 days ask compare=previous, so the tiles carry their DeltaBadge (“+12% vs the previous 30 days”). Body: ReadersSummary from @cloudhal/ui/charts, lazy (React.lazy + Suspense with the card’s skeleton lines), so the editor bundle never carries ECharts; channels ranked by visits and named in the person’s language; the old-address line from redirectHits (“Old addresses still bring readers: 42 in the last 30 days”); “Open in analytics” → /sites/:siteId/analytics?filter=node:<id>; a muted line when the envelope is partial. States, in order: left out when features.analytics is missing, off or store: "none", and when the site’s own settings are off (…/analytics/status, read only by people who may) — nothing renders while /api/me and /api/instance are unknown, so nothing flickers; Contributors (useGate("analytics.read")) get a SectionEmpty (LockSimple) “Analytics aren’t part of your role” with the gate’s reason, and no analytics request is made; can’t read yet (reader: "none") a quiet SectionEmpty (Hourglass), no request; never published (status draft/review without publishedAt; pages count archived as published) “Stats start when this article/page is published” (RocketLaunch), no request; no readers (0 page views and 0 redirect hits) SectionEmpty “No readers yet”; errors → ErrorBanner with “Try again” inside the card. Tabs show only while numbers can be shown.
  • Overview card (built, OverviewAnalyticsCard): first in the site overview’s side column, SectionCard “Last 7 days” (ChartLine) with a ghost “View analytics” LinkButton (→ /sites/:siteId/analytics) in its header; body: compact StatTileGroup (Page views, Visits, deltas against the previous 7 days), the 120 px AnalyticsTimeseries line (compact, lazy like the Readers card) and the three most read nodes as BreakdownMeters (top/node?limit=3; shares of all page views; deleted nodes read “No longer exists”). Three requests (summary?compare, series?interval=day, top/node), all range=7d, cached 60 s. Same states as the Readers card; no visits → SectionEmpty “No visits yet”.
  • Views column (built, components/ListViews.tsx + lib/list-views.ts, keys readers.listColumn.*): the Articles and Pages lists end with a right-aligned “Views · 30 days” column before the row menu — page views per row in tabular-nums from one top/node?range=30d&limit=100 query (the API’s maximum) shared by both lists and cached 5 min; never one request per row. A node missing from a short answer shows 0; when the answer is full (100 rows) or folds the tail into (other), a missing node shows a muted “—” with a Tooltip (and screen-reader text) “Not among the 100 most read in the last 30 days”. SkeletonLine cells while loading. Same gating as the cards (useAnalyticsCard): the column is left out — with no analytics request — for Contributors, and left out when features.analytics is off, unreadable (reader: "none"), off for the site, or when the query fails. The lists are not sortable, so neither is the column.
  • Kumo locale (built; components/KumoLocale.tsx at the root inside I18nProvider, calendarLocaleProps in @cloudhal/ui, one pnpm patch). Kumo 2.14 (the latest release) has no locale prop, and its KumoLocaleProvider takes only translations.layerDialog.{close, cancel} (plus translations.label.{optional, moreInformation} from our patches/kumo/label-locale.patch: Label’s “(optional)” suffix, shown by Field, Checkbox and Select with required={false}, and its tooltip button’s name; Studio passes common.optional/common.moreInformation, kumo-upstream-notes.md §8): Studio passes common.close/common.cancel, so a LayerDialog without explicit closeLabel/dismissLabel speaks the Studio language and follows a switch (explicit props still win, and screens keep passing them). Everything else is reached through the props Kumo already has, without forking:
    • Calendars — Kumo DatePicker only (react-day-picker 9 inside, every DayPicker prop passes through), never the deprecated DateRangePicker (it hard-codes weekday initials, “Previous month”, “Next month”, “Edit month and year”, “Timezone: “, “Reset Dates” and en-US day names, and names months in the browser’s locale). Spread calendarLocaleProps(locale, labels, timeZone?) into it: formatters (month caption, weekday names) and labels (grid, weekday, day buttons with today/selected, previous/next, navigation) through Intl in the Studio locale, weekStartsOn from Intl.Locale week info (Sunday for en-US, Monday for tr), lang, and timeZone. No date-fns locale is shipped; the words that are not dates (CalendarLabels) come from useT().
    • Charts — numbers, ticks and table cells through createAnalyticsFormatter (tooltipValueFormat, axis formats); ChoroplethMap’s hard-coded legend “High”/“Low” is replaced with CountryMap’s labels.high/labels.low by merging visualMap.text after Kumo’s options (useChartFrame’s patch, lazily, like Kumo’s own updates); TimeseriesChart‘s loader (aria-label “Loading chart”) is hidden from assistive technology while a role="status" line speaks labels.loading. ECharts’ own locale (init option) would be reachable by passing a wrapped echarts whose init adds it, but nothing ECharts writes is shown (the aria description, ticks and tooltips are ours, the toolbox is hidden), so it is not set.
    • The hand-written patches (patches/kumo/tooltip-timestamp.patch below; label-locale.patch above; rtl-icons.patch, §1 right-to-left readiness; all folded into the generated patches/@cloudflare__kumo@2.14.0.patch by scripts/kumo-patch.ts, which also makes Kumo’s utilities logical, ADR-0033 A1) — TimeseriesChart writes its tooltip’s time with a module-level Intl.DateTimeFormat(undefined) (browser locale, seconds) and has no prop for it. patches/@cloudflare__kumo@2.14.0.patch (patchedDependencies in pnpm-workspace.yaml) adds an optional tooltipTimestampFormat?: (timestamp: number) => string (two lines in dist/chunks/chart-*.js, one in the typings); AnalyticsTimeseries passes the bucket in the Studio locale and UTC. A test in packages/ui/test/analytics.test.tsx fails if the patch is not applied. On every Kumo upgrade: pnpm refuses to install while the patch names a version that is not installed — check whether Kumo now has the prop (or locale), drop or re-create the patch (pnpm patch @cloudflare/kumo@<version>), and re-check every gap here and in docs/design/kumo-upstream-notes.md (the upstream issue texts, not filed).
    • Left English — nothing Studio shows. Kumo strings Studio does not reach: TimeseriesChart’s “+N more” (more than ten series) and “Reference marker” (markers unused), ChoroplethMap’s default valueFormat (CountryMap passes its own).
  • Site settings › Analytics (components/AnalyticsSettings.tsx, model in lib/analytics-settings.ts, words in the lazy analyticsSettings namespace — every locale loads with the route, for the notice tabs; RFC-0006 §10.3, §2k): a SettingsSection #analytics between Domains and Preview links, in the table of contents. Header badge: dot success “On”, neutral “Off”, or neutral “Not running” when the deployment records nothing. Body, top to bottom: a secondary Banner for the deployment (features.analytics from /api/instance, else the settings response): “Analytics aren’t running on this deployment” (enabled: false or store: "none", with the cloudhal.json + deploy hint) or “Visits are recorded, but Studio can’t read them yet” (reader: "none", A1: nothing is lost, daily totals until reading works); then the save error or the 409 Banner variant="alert" “These settings changed somewhere else” + “Load latest” (the form is rebased: fields the server changed take its value, other unsaved edits stay); then the controls inside ReadOnly — Switch “Count visits” (“Adds a 1 KB script… No cookies”), Switch “Count unique visitors” with its cookie-less explanation (disabled while visits are off). Turning unique visitors on first opens a LayerDialog.Alert (alert Banner with ShieldWarning “This is processing of personal data”, the unique-visitors notice paragraph to add to the privacy policy, primary “Count unique visitors”; Cancel leaves it off); turning it off shows an alert Banner size="sm" “Saving deletes today’s random value”, which says the salt is per deployment, so it goes for every site at once and other sites’ unique counts may count someone twice later that day. A container-query grid of Selects (array items, so the order is the RFC’s): “Measure page speed” (Off, 1 in 20, 1 in 10, 1 in 5 (default), 1 in 2, Every page view; the description adds the request cost, “Adds about 20% more requests”), “Keep daily totals” (Forever (default), 5/2/1 years; another stored value shows as “n days”), “Cloudflare plan” (Not sure, Workers Free, Workers Paid; it only drives the quota warning). Outside ReadOnly, so readers can still copy it: “Privacy notice for your site” — segmented Tabs English | Türkçe (the Studio locale first), the §6.5 paragraph in an InsetPanel recessed (with lang) in the variant the switches give now (the hint says so while unsaved), “This is information, not legal advice.”, and a secondary “Copy notice” Button (Copy; heading and paragraphs as plain text; success toast). A paragraph reads badly in ClipboardText’s one-line mono field, hence the button. Last, a dl of SettingRows about the deployment: Recorded in (dot Badge), Read from (warning dot when not yet), Daily totals (“Computed every hour” with the Cron Trigger hint, or “Computed when someone opens Analytics”), Live data (“Last 92 days”). Footer: ghost “Discard” while dirty, primary “Save changes”. The PUT sends rev and only the changed fields; the success toast says when the site follows (“within a minute” after the purge, “as cached pages expire” when it failed) or that today’s random value was deleted. Gating: reading needs analytics.read (Authors and above; a Contributor gets a secondary Banner with LockSimple and the reason, and nothing is fetched), changing needs analytics.settings (Administrators and above): switches and Save are disabled with title={gate.reason}, the selects sit in ReadOnly. A server without the analytics routes (404) gets “This server doesn’t offer analytics” and no form.
  • Agent keys › “Allow analytics” (components/AnalyticsKeyAccess.tsx, RFC-0006 §9.1 and open question 4): keys are never widened on their own, so a key without analytics:read (made before it existed, or customised without it) shows under its scopes an outline Badge “No analytics” (Tooltip: why, and that analytics are totals only, no personal data) and a secondary Button size="xs" “Allow analytics” (ChartLine). One click, no confirmation (it adds one read-only scope and is audited as agent-key.scopes): PATCH /api/agent-keys/:id {addScopes: ["analytics:read"]}, success toast “Analytics allowed · Desk can read site analytics from its next call.”, the list refetches and the hint goes; a refusal is an error toast with the server’s message. Gated by the person’s own analytics.read for their keys and, in the Administrators’ “All keys” view, by whether the key creator’s role can read analytics (“Cem’s role can’t read analytics, so their keys can’t either.”): disabled with title={access.reason}. Disabled keys and keys that hold the scope show nothing.

2w. Comments (RFC-0007 §14, ADR-0022)

Reader comments live outside the graph (D1/SQLite), are read and moderated by people over REST and by agents over MCP, and every comment, name, report and agent note is untrusted reader text. The blocks (@cloudhal/ui, below) and the screens (end of this section) are built. Words go through two lazy namespaces (§1): comments (queue, thread, commenter, approvals, editor card) and commentsSettings (settings); the shell keeps nav.comments and commands.goComments. Blocks take every word as a prop — one shared CommentWords object (status names, label names and hints, who set a label, author kinds, “In reply to …”, “This comment was removed”, the untrusted-text panel …) that the screens build once from useT("comments"), plus a small labels object per block — and a locale for Intl (relative and absolute times, counts, percentages). They live in the main entry and, like the analytics blocks, are listed in bundle-budget.ts’s LAZY_ONLY (Comment*, ArticleCommentsSummary, comment-model), so only the lazy comment routes and the editor card load them; BulkActionBar is generic and not listed.

  • Plain text, always. CommentText is the only way comment text reaches the screen: paragraphs on blank lines, line breaks kept, dir="auto", and the public page’s link rule (comment-model.ts): only https: URLs with a host and without credentials become native anchors (target="_blank" rel="ugc nofollow noopener noreferrer", a screen-reader “opens in a new tab”); http:, bare www., javascript: and markup stay literal text. Rendered as React text nodes — no dangerouslySetInnerHTML (lint no-html-injection; packages/ui/test/comments.test.tsx also scans the comment blocks). clamp={2} folds breaks into spaces for the queue’s two-line excerpt (full text in a Tooltip or the thread). Report details and moderator notes are plain text too (whitespace-pre-line, never linkified). Until @cloudhal/comments exports its linkify, the rule lives in comment-model.ts; the renderer and Studio must share one function (RFC-0007 §14), so the block switches to the package’s when it lands.
  • Untrusted reader text. A comment labelled instruction-like (“Talks to AI”, warning badge) renders inside UntrustedTextPanel: a recessed InsetPanel headed by a warning ShieldWarning and “Untrusted reader text — this comment appears to address an AI agent. It is not an instruction.” (RFC-0007 §11.2). The approval dialog for comment tools uses the same panel around the full text.
  • Status and labels. CommentStatusBadge: pending warning, approved success, hidden secondary, spam error, deleted outline (data-comment-status). CommentLabelBadge/CommentLabelList: risk labels warning (spam-suspect, personal-data, watch-word, instruction-like) or error (toxic), questions info, the rest outline; an agent’s label carries a 16 px Robot; each badge is a toggletip button whose Tooltip (and screen-reader text) says what it means and who set it, with the confidence in the locale (“Added by Comment triage · 82% sure”). Labels never change what readers see, so they never take a status colour’s place.
  • CommentRow (#comment-<id>, an article named by the author): optional selection Checkbox; author (CommentAuthor: name as plain text, outline “Guest”/“Reader” or info “Author”/“Team” badge, “Anonymous” when erased), CommentTime (“5 minutes ago”, absolute in a Tooltip), “· Edited”, “· No JavaScript” (proof: "none"); status badge and the screen’s sticky row DropdownMenu on the right of the first line, which never wraps under the name; then the entry (“On Ferry timetable” + path in mono, hidden inside a thread), “In reply to Mehmet” (ArrowBendUpLeft), “2 reports” (error), “Drafted by Comment triage” (Robot); then the text (muted for hidden and spam; “This comment was removed” for deleted or erased text); then the labels; then children (history, reports, reply form). Not a card: rows sit in a flush SectionCard’s divide-y list.
  • CommentThread (the thread view’s main column, in a flush SectionCard): RFC-0007 §4.3’s two levels, oldest first (buildCommentThread); replies to replies stay on the second level with “In reply to …”, which scrolls (smoothly unless prefers-reduced-motion) to the comment answered, focuses it and tints it for two seconds, unfolding it first if needed; more than collapseAfter (3) replies fold behind a ghost “Show n more replies” (aria-expanded, aria-controls); replies whose top-level comment is missing hang under a “This comment was removed” placeholder. Replies are indented by a hairline border-l (ml-4, sm:ml-8). Slots: renderActions, renderDetails (history, reports, statement of reasons), renderReply (the staff reply form under the comment answered), selection. States: loading (skeleton rows and a role="status" line), empty (SectionEmpty), error (an error Banner with “Try again”).
  • BulkActionBar (replaces the queue’s ListToolbar while rows are selected): a fieldset the toolbar’s height (min-h-9, bg-kumo-tint, ring-kumo-line) so the table does not move — select-all Checkbox (checked, indeterminate or clear), “12 selected” in a polite live region, the actions as size="sm" buttons (the safe one primary, the rest secondary), ghost “Clear” (X); Escape clears; wraps on phones. A disabled action keeps its reason: Kumo’s title tooltip on the wrapper and the button’s accessible description (“Only people can approve comments”).
  • CommentFilters (right of the queue’s ListToolbar, like the media library’s filters): a Kumo Toolbar of Select render={<Toolbar.Button />} — facets in order from status (left out on the queue, whose page tabs are the status), entry (“All articles” + titles, entriesLoading), label, actor (“Hidden or labelled by agents”, RFC-0007 §11.2 point 4) and date (Any time, 24 hours, 7/30/90 days; commentFilterSince gives the API’s since) — and a ghost “Clear filters” that resets only the facets shown. On phones the joined selects scroll inside the toolbar, never the page.
  • CommenterSummary (the commenter page’s identity card, commenters:data only): a dl of rows — names used (outline badges, plain text), email in mono only when canSeeEmail (the block ignores a passed address otherwise), first and last comment (date and relative time), ban (error “Banned until 10 Jan 2027” / “Banned until lifted” with the subjects and reason, or a neutral dot “Not banned”) — then the comments counted by status in an InsetPanel (two columns, five from @md), then the screen’s actions (Ban…, Export their data, Erase…).
  • StatementOfReasons (EU DSA Art. 17; thread details of a hidden, spam or deleted comment and the commenter’s export preview): a labelled InsetPanel dl — decision and what it does (“Hidden — removed from the public page”), grounds (the reason code in words), facts and circumstances (the note, plain text), automated means (yes / a person decided), decided by (ActorBadge + name, “Approved by Ayşe for Comment triage” when a person approved an agent’s request), decided on, and optionally how to contest it. CommentHistory: the comment_events rows in a Collapsible “History (n)”, newest first — action, reason, time, actor, note; no comment text is ever in an event.
  • CommentReports (DSA Art. 16 notices on one comment, thread details and the Reported tab): Flag and “2 of 3 reports open” with an outline badge per reason, then each report — reason badge (error for illegal content, else warning), open/resolved dot badge, time, the reader’s details as plain text in a recessed InsetPanel, the resolution — open reports first, with the screen’s “Resolve…” on open ones. A report never changes a status by itself.
  • ArticleCommentsSummary (the body of the article editor’s “Comments” SectionCard, ChatsCircle, aside after Readers): the thread mode as segmented Tabs size="sm" Default · Open · Closed · Hidden (or, without permission, the mode as an outline badge with the reason, §2s), “Readers see” + the effective state as a dot badge (open success, closed/off neutral, hidden warning), the close line with CalendarBlank and a ghost “Change” (opens the “Close on…” DatePicker through calendarLocaleProps), the counts as badges (approved success; pending warning and reported error only when above zero) and a ghost LinkButton “Moderate” to the thread. empty replaces the body (comments off for the collection with “Comment settings”; never published “Comments open when this article is published”; Contributors’ reason), loading shows skeleton lines.
  • Screens (as built, RFC-0007 step 8). Routes /sites/:siteId/comments (?tab=), /sites/:siteId/comments/:nodeId (?comment= lands on one, ?reply= opens the reply form) and /sites/:siteId/commenters/:commenterId, lazy with the comments namespace; every gated query waits for /api/me, so a role that may not see something never asks for it.
    • Queue routes/CommentsPage.tsx (template moderation: PageLayout, ListToolbar/BulkActionBar, LayerCard p-0 of CommentRows, Empty, Pagination). The rows are CommentRows, not table cells, so the untrusted panel, labels and reports fit at 390 px. Page tabs Pending (n) · Reported (n) · Approved · Spam · Hidden; ListToolbar search (names and text of the loaded page — the API has no text search yet) + CommentFilters (article, label, “Hidden or labelled by agents”, date) + RefreshButton; each row has a selection Checkbox and the CommentMenu (components/Comments.tsx: Approve/Restore, Hide…, Mark as spam…, Back to pending, Delete… for Editors, Resolve report…, Reply, Open thread, Commenter… — each item disabled with its useGate reason). While rows are selected BulkActionBar replaces the toolbar: the tab’s actions (bulkActionsFor), only for the selected comments the action fits (eligibleFor), disabled with the reason (the role, or “none of the selected comments can be …”); no bulk delete (the API deletes one comment at a time). Hide…/Spam…/Delete… open ModerateDialog (reason Select in words, optional note; Delete is a LayerDialog.Alert). A bulk call answers per comment: one toast when all went through, “1 of 2 comments approved” with one line per reason otherwise, an error badge on each row left as it was (FailedBadge), the failed ones stay selected, and conflicts add an alert Banner with “Reload”. Pending agent requests show an info Banner “Comment triage asks to approve 2 comments” → Approvals. Pagination follows the API’s cursor (hasNextPage, sequential). Aside: “How moderation works” (SettingRows) and “Today” (daily-cap Meter, purges waiting with “Purge site cache”, Comment settings, Export comments / with emails for those allowed). Sidebar Content › Comments (ChatsCircle) with the pending MenuBadge from the bell’s comments-pending digest (own articles for Authors), only when features.comments.available and comments.read; palette “Go to comments”; overview CommentsWaitingBanner “4 comments are waiting”.
    • Thread routes/CommentThreadPage.tsx (template editor, EditorLayout without preview): title = the article, description = state dot badge + “Closes on …”, action “Open article”. Main: a flush SectionCard with CommentThread (every status, oldest first, cursor “Load more comments”); per comment the CommentMenu (an Author on someone else’s article gets the reason), and details read with GET …/comments/:id when they matter (reports, a removal, the comment linked to) or on “History”: StatementOfReasons for hidden/spam/deleted, CommentReports with “Resolve…” (LayerDialog, required resolution), CommentHistory. The staff reply form opens under the comment answered: InputArea with a live counter to 5,000, a recessed InsetPanel preview of exactly what the page will show (CommentAuthor with the badge it will carry — Author on one’s own article, else Team — and CommentText), “Publish reply”. Aside: “Thread” card (segmented mode Tabs, mode hint, Readers see, Close date + “Change” → CloseDateDialog with DatePicker + calendarLocaleProps, end of the day UTC, “Remove close date”; Hidden asks first, HideThreadConfirm) and DetailsCard (article id, path, counts). Thread writes send the thread’s rev; a 409 says so and re-reads.
    • Commenter routes/CommenterPage.tsx (template editor; commenters:data only — others get ViewOnlyBanner and no request): main = their latest 50 comments as CommentRows (menu: Reply, Open thread); aside = Identity SectionCard with CommenterSummary (email only here) and its actions: Ban… (LayerDialog: duration, reason, email and/or network Checkboxes with what each does, note, “Also hide their approved comments”) or “Lift ban” (bans found with GET …/comment-bans?commentId=), Export their data (downloads the JSON), Erase… (LayerDialog.Alert, Radio appearance="card" Anonymise / Also delete the text, an InsetPanel of what happens that follows the choice, required reason, destructive “Erase”). Without CLOUDHAL_COMMENTS_KEY (503) a Banner says why there is no email.
    • Settings › Comments (components/CommentSettings.tsx, lib/comment-settings.ts, SettingsSection #comments, words in commentsSettings): Switch Accept comments; collections as Checkboxes (/posts in mono) each with its own “Close threads in /posts” Select (Same as the site, Never, 14…365 days); keep visible while off; close after; bot protection Radio appearance="card" (proof of work with a “Default” badge, or Turnstile → site key Input and the write-only secret: SensitiveInput + “Save secret”, then “Saved {date}” with ghost Replace / Remove — never read back; without the comments key a Banner; the CSP note); JavaScript-less posting; link limit and daily cap (Input type=number, the cap’s D1 writes in its description, today’s Meter); watch and blocked words (TagInput); Workers AI labels (a LayerDialog.Alert first; disabled while features.comments.aiLabels is false); retention Selects (client data at most 30 days); the disclosure snippet with English | Türkçe Tabs that follows the switches, “Copy notice”. One PUT with only the changed fields and rev; a 409 shows “These settings changed somewhere else” + “Load latest” and keeps other edits (rebase). Read with comments.read, changed with comments.settings (ReadOnly otherwise).
    • Editor card components/ArticleCommentsCard.tsx (aside after Readers): ArticleCommentsSummary with the mode tabs (Hidden confirmed first), the close line and “Change” (CloseDateDialog), counts and “Moderate”; nothing at all without comments on the deployment; Contributors see the reason, never-published articles “Comments open when this article is published”, collections that are off “Comments are off for this collection” with “Comment settings” for Administrators; an Author on someone else’s article sees the mode, not a control.
    • Approvals (§2m, components/CommentApprovals.tsx): a comment request reads in words (“Approve 2 comments”, “Publish a reply” with the drafted text quoted in a recessed InsetPanel, plain text) instead of the server’s English prompt, and tool labels name the seven comment tools. Approve opens ApproveCommentsDialog (LayerDialog lg): an info Banner “Single use, for these comments as they are now”, the comments themselves as CommentRows in an outline InsetPanel (untrusted panel included), for a reply the answered comment, the draft and “Publishes as you, Ayşe, with the Team badge”, for a thread the article and the new mode/close date; the primary “Approve 2 comments” / “Publish reply” / “Open thread” carries the call out. The toast says what was done (with “1 of 2 done” from the tool’s results); 409 state_changed says the comments changed after the agent asked and the request closed; 409 carry_out_failed says it was approved but could not be carried out.
    • Notifications (§2n): the bell renders all five comments-* kinds (ChatsCircle info for pending with the oldest’s wait, Flag warning for reported, PauseCircle warning for paused, ClockCountdown warning for purge waiting with “Purge site cache”, Robot info for an agent’s comment request) with Review / Comment settings / Open comments; toasts and browser notifications use the same words; a new comment item refreshes the queue. Unknown kinds from a newer server are left out of the bell. Links with ?/# go through splitHref (search and hash).
    • Agents page: AllowComments (components/CommentKeyAccess.tsx) next to “Allow analytics”: “No comments” + “Allow comments” (addScopes: ["comments:read"]) for keys without it, gated like analytics (the creator’s role for others’ keys); a server that answers scope_not_addable is remembered for the session and only the badge stays, with a hint to create a new key. Keys that read comments and can change content carry the §11.2 point 5 warning badge “Reads comments, edits content”. No triage key template yet.

2x. Schedules (RFC-0008 §15, ADR-0023)

Releases, embargoes, scheduled publishing and automations on one calendar. The blocks are in @cloudhal/ui (below); the screens (RFC-0008 §15.1) are described at the end of this section. Blocks take every word as a prop — one shared ScheduleWords object (kind names and the state names of releases, automations, runs and approvals) that the screens build once from useT("schedules") (a lazy namespace, §1), plus a small labels object per block — and dates as plain values: instants as RFC 3339 strings placed and timed in the site’s timeZone (IANA, Site.timeZone, UTC when absent), calendar days as YYYY-MM-DD, a locale for Intl and weekStartsOn from firstDayOfWeek(locale) (Monday in Turkish). There is no date library: day arithmetic runs on UTC-midnight calendar dates (schedule-model.ts), names and times come from Intl (24-hour where the locale is, hourCycleOf), recurrence is expanded by the server and only shown (NextRuns). The blocks are in bundle-budget.ts’s LAZY_ONLY, so they load with the lazy schedule routes and the lazily loaded “Schedule…” dialog, never with the shell (about 44 kB minified, 14 kB gzip for all twelve, Kumo and icons aside); the calendar inside DateTimeField is Kumo’s DatePicker, React.lazy like the analytics range’s.

  • Visual language (RFC-0008 §15.3): fill = certainty, tone = state, icon = kind and origin; colour is never the only signal (WCAG 1.4.1). scheduleLook maps an entry to one of nine looks: quiet (scheduled by a person or approved: bg-kumo-tint, the kind icon — publish and embargo CalendarCheck, expiry CalendarX, release Package, automation Robot), auto (the same + Lightning, the AutoApprovedBadge language), proposed (draft or waiting for approval: a dashed border-kumo-warning outline with no fill, Robot for an agent’s + HandPalm), struck (denied, expired, canceled, skipped, stopped: line-through, text-kumo-inactive; hidden by default views, isHiddenByDefault), danger (conflict Warning, failed WarningCircle, missed ClockCounterClockwise, broken LinkBreak: bg-kumo-danger-tint), running (bg-kumo-info-tint + a turning CircleNotch, still under prefers-reduced-motion), done (published, ran: bg-kumo-recessed, muted), ghost (a future recurring occurrence: outline ring only, ArrowsClockwise) and paused (the ghost with Pause). The dashed outline (D14) is drawn only inside ScheduleItem, as border border-dashed without any shadow, because §4’s ring edges cannot be dashed. The design lint’s shadow-borders and no-ad-hoc-surface rules apply to screens, not to packages/ui/src/blocks, so the block needs no lint exception — and a screen that draws its own dashed card still fails.
  • ScheduleStatusBadge — every state of Release (RFC §5.2), Automation and ScheduleRun (§5.3) and of an approval, as a Kumo Badge with an icon and the state’s word: draft outline PencilSimpleLine, waiting for approval warning HandPalm (+ an outline ClockCountdown badge with timeLeft), scheduled outline CalendarBlank, publishing/running info with a turning CircleNotch inside role="status", published/ran/active success, conflict/failed/missed/broken error, expired/canceled/skipped/stopped/paused secondary, paused by Cloudhal and over budget warning (PauseCircle, Gauge), ended/due outline; approvals: approved outline CheckCircle, automatic warning Lightning, denied error Prohibit. A reason makes it a toggletip button (keyboard too, and in the accessible name). data-subject/data-state for tests.
  • ScheduleItem — variant="chip": a button at least 24 px tall (SC 2.5.8) with the look’s icons, the time (tabular-nums) and the truncated title; its accessible name is the whole sentence (scheduleItemSentence: “09:00, Publish, Election results, proposed by Desk, Waiting for approval, 3 h left”, or the screen’s own entry.label). variant="row" (the agenda, the “+N more” popover, the phone day list): time, kind icon with its word for screen readers, the title (Link when href, struck through when struck), meta (collection, locale, terms), origin (ActorBadge + ModeBadge subtle), the status badge — none for the quiet “Scheduled”, the approval’s own badge for approved or automatic — and actions (Deny/Approve, the ⋯ menu whose “Move to…” is the single-pointer twin of dragging, SC 2.5.7); it wraps on phones.
  • CalendarMonth (Month view, RFC §15.2, §15.7) — a WAI-ARIA APG date grid on a fixed Kumo Table (role="grid", seven columns from weekStartsOn, short weekday headers with the long name for screen readers) with one tab stop and a roving tabindex: arrows by day and week, Home/End to the week’s ends, PageUp/PageDown by month (Shift: year) — leaving the shown weeks asks onMonthChange(month, day) and focus follows; Enter or F2 into the day’s chips (arrows between them; chips are never tab stops), Escape back to the day. Cells are named by the screen (labels.day: “Monday 12 October, today, 3 items: 2 publishes, 1 waiting for approval”); today has aria-current="date"; past days and weekends are bg-kumo-recessed; entries land on their day in the site zone (23:30 UTC is tomorrow in Istanbul). At most maxChips (3) chips, then a “+N more” Popover with the day’s rows. Moving: with onMove, chips of movable entries (future, not running, not locked, not an occurrence — the screen decides) drag onto another day (native drag and drop on desktop chips, no drag library), and Alt+arrow on a focused chip is the keyboard twin; both only call onMove({entry, fromDay, toDay, via}) — the screen opens RescheduleDialog with the old time on the new day, nothing is optimistic. A polite role="status" region speaks the screen’s announcement. Phones (density="dots", or auto at ≤ 640 px through matchMedia): a cell is the day number and up to three dots in the looks’ tones plus a warning approval dot (decorative; the cell’s name carries the counts), the whole cell is the target and selects the day (aria-selected, Enter or Space), and the chosen day’s rows show under the grid (“Today · Monday 12 October”, or labels.emptyDay).
  • AgendaList (Agenda view, the default on every width) — entries grouped by their day in the site zone under sticky h3 day headings (top-12, under the top bar, border-b border-kumo-line; stick="top" inside other scroll containers), rows in a divide-y list; today gets a “Now” rule (a danger hairline with the word, read by screen readers) between past and future, its finished items fold behind a ghost “3 done today” (aria-expanded), and a day’s recurring runs past foldRecurringAfter (3) fold behind “21 more automation runs”. Loading = SkeletonLines with a status line; nothing = a frameless SectionEmpty (CalendarDots) with the screen’s emptyContents; cursor paging = a secondary “Load more” (loading). The view switch tells screen readers the agenda is a complete equivalent of the month.
  • CalendarNav — a fieldset named by labels.group: secondary square ‹ › buttons (aria-label) around “Today” (disabled while today is shown), the period (“October 2026”) in a polite live region, a goTo slot for the screen’s “Go to date” Popover + DatePicker, and the Agenda | Month segmented Tabs with viewHint. Shortcuts (j/k, g t, a/m, g d) are the screen’s and call the same handlers.
  • ScheduleFilters (the CommentFilters pattern) — a Kumo Toolbar of facet buttons (kind, state, origin, approval, collection, category or tag, language — whichever the screen passes, with worded options), each opening a DropdownMenu of checkbox items (every facet is multi-valued; the menu stays open while ticking), the count as a Badge and in the button’s name (“Kind, 2 selected”); a ghost “Clear filters” while anything narrows; and the view menu (BookmarkSimple: built-in views first — All, Waiting for approval, Proposed by agents, This week, Needs attention, Mine — then saved ones, and “Save view…”) as radio items. Controlled; the URL is the screen’s (lib/schedules-search.ts). A DropdownMenu checkbox list is lighter than RFC §15.4’s Combobox multiple in a Popover and fits the short facets; long ones (terms) may move to a Combobox with the screen.
  • TimezoneNote — GlobeHemisphereWest and “Times are in Istanbul time (UTC+3)” in muted text, both times (“09:00 Istanbul · 08:00 your time, Berlin”) in a toggletip, and an optional “Show in my time” Switch (controlled). It generalises §2v’s “Days are in UTC”.
  • DateTimeField — Kumo has no time picker: a Kumo Field (label, description, error) around a secondary date Button (“Publish at, Date: 2 Nov 2026”) that opens a Popover with the lazy DatePicker (calendarLocaleProps, days in UTC so a picked day is exactly that day, min/max/today) and an InputGroup with the typed time (named by the field’s label) and the zone as an end addon (“Istanbul (UTC+3)”). The time reads “9”, “930”, “09:30”, “9.30” and, in 12-hour locales, the locale’s own AM/PM words (parseTimeOfDay), is written back in the locale’s hour cycle (formatTimeOfDay; 24-hour in Turkish) and emitted as HH:mm; an unreadable time shows labels.invalidTime. Validation is the screen’s (error): past, “less than 5 minutes away”, a DST gap (“02:30 does not exist on 29 March in Berlin; it runs at 03:30”). useTimeOfDayDraft is the shared time-text state.
  • RecurrenceEditor (D3) — the presets as Radio appearance="card" (Every hour, Every day, Weekdays, Every week, Every month, Once, Custom), then only what the choice needs: the time (minutes for hourly), the weekday Select, the day-of-month Select (1–31, last day), and for Custom every N days/weeks/months, weekday Checkboxes in weekStartsOn order (short names, long for screen readers), monthly “on day N” / “on the second Tuesday”, and the end (never / on a date / after N runs). A muted hint asks for a minute off the hour (RFC §9.5) when the time is on it. It emits a structured RecurrenceValue; recurrenceRule turns it into the RRULE subset (hourly = DAILY with every BYHOUR, never HOURLY or BYSETPOS; UNTIL in floating local time) and recurrenceProblems lists what to word. The sentence (“Weekdays at 09:07”, “Her ayın ikinci salısı 09:07”) is the screen’s, from its own templates, shown in an InsetPanel and spoken politely.
  • NextRuns — the next five runs from the server (POST …/automations/preview-rule) as an outline InsetPanel dl: the day and the time in the site zone, a warning ClockClockwise “Clock change” badge on DST days, the screen’s note for a moved run; loading lines with a status, an empty line.
  • RescheduleDialog (“Move to…”, D4, D12) — LayerDialog size="base", always rendered and controlled by open: the item as the description, an outline InsetPanel “Now → New”, the DateTimeField (from today), an alert Banner for a clash, an info Banner “The approval is kept” (approvalEffect="kept": a person who may approve moves it) or an alert Banner “Moving voids the approval; it asks again” (voided: an agent’s move or a content change), the screen’s error Banner for a 409 (“This schedule changed somewhere else” + “Load the latest”); primary “Move” (loading, disabled until a valid, different time), dismissal disabled while busy. After it, the screen’s toast offers Undo for 10 s.
  • ScheduleRunRow (History tab) — a Table.Row under ScheduleRunHeader (same column order): time (site zone, date below), kind (outline Package “Release” / ArrowsClockwise “Built-in job” or “Agent task”, the trigger below — Cron, Clock, Request, Timer, Run now), subject (linked), origin (the screen’s ActorBadge), state (ScheduleStatusBadge subject run, plus the warning ShieldWarning “Read visitor text” taint marker, D10, whose toggletip names the tool and time) and outcome (outline badge) in separate columns, lateness (“On time” under a minute, “3 min late” from Intl units, the DST or zone-rules note), budget use (“Calls 60/50 · Writes 2/10 · Requests 1/5”, tabular-nums, error text and a warning “Over budget” badge when a limit is passed) and a sticky last column for the snapshot and audit links. An empty cell is “—” with labels.none for screen readers. The table scrolls sideways on phones (LayerCard p-0 overflow-x-auto).
  • Turkish (RFC §15.8): weeks start on Monday, times are 24-hour, dates read “12 Ekim Pazartesi”, “Bugün”/“Yarın” come from Intl.RelativeTimeFormat (relativeDay, sentence case), counts through the screen’s Intl.PluralRules.

Screens (Studio; words in the lazy namespaces schedules and scheduleSettings; helpers in lib/schedules.ts, the client in lib/schedules-api.ts, shared parts in components/Schedules.tsx — all LAZY_ONLY; the shell keeps only api.scheduler, the nav item and four route registrations, and the routes check their own URL with lib/schedules-search.ts):

  • Header (useSchedulesHeader): PageLayout with CalendarDots 24 px, “Schedules”, the tabs Calendar · Automations · History (one route each: /sites/:id/schedules, …/automations, …/history), a secondary square Keyboard button (from md) and the primary split “New schedule ▾” (Schedule a publish… · New release… · New automation…, the last gated on automations.manage). Under the tabs SchedulesBanners: an alert “n schedules need attention” with Review (→ the Needs attention view), a secondary “Schedules are paused” with the reason, and the error “Scheduled work is not running” (SchedulerStalledBanner, also on the site overview, lazily, when /api/scheduler is unhealthy and work is due).
  • Calendar (routes/SchedulesPage.tsx, template calendar): ListToolbar (search) with ScheduleFilters (kind, state groups, origin, approval; the view menu: All, Waiting for approval, Proposed by agents, This week, Needs attention, Mine, then views saved in this browser with “Save view…”), CalendarNav with “Go to date” (Popover + DatePicker), TimezoneNote with “Show in my time” when the viewer’s zone differs, then a LayerCard p-0 with AgendaList (default; the card is overflow-visible so the day headings stick under the top bar) or CalendarMonth (full width: the aside — Waiting for approval, Scheduler, From the terminal — goes with the agenda). The URL is the state: view, date, kind, state, origin, approval, q, v (view), new (opens a dialog). Rows carry the creator (ActorBadge + name), the items count, overlaps, and for an agent’s gate-only reduction an outline ShieldCheck “Edits mode reduction” instead of “Automatic” (metadata["cloudhal.mode"]). Waiting releases show Deny / Approve (Approve confirms in ApprovalDialog); the ⋯ menu has Open release, Move to…, Publish now, Unschedule. Move (useMoveDialog) is one path for the menu, a desktop drag and Alt+arrow: RescheduleDialog prefilled with the old time on the new day, approval kept (a person who may approve) or voided, a clash with other releases at the same minute, 409 → “Load the latest”; the move is sent as wall-clock localTime + zone, then a 10 s toast with Undo. Shortcuts (only outside fields, dialogs and menus): j/k next/previous month, a/m Agenda/Month, g t today, g d go to date, c schedule a publish, f focuses the filters, ? lists them in a dialog; Studio’s own ⌘K and / are untouched and t alone is not used.
  • Automations (list): name with the job Badge and a state badge when not on, “Runs as” (ActorBadge Cloudhal for built-ins; agent + subtle ModeBadge of the cap), “When” in the server’s words (preview-rule, the rule and zone in a Tooltip), next run, last run (ScheduleStatusBadge + RelativeTime), an On Switch (pause / resume), the ⋯ menu (Run now — 409 “already running this minute” in words —, Edit, History, Duplicate, Delete through DeleteResource). Editor (AutomationDialog, LayerDialog lg): name; the job as Radio cards (five built-ins, Agent task); for agent tasks the key note, ModePicker (role-capped) with AutoModeConfirm, “Reads visitor text” Switch, instructions, run window, budget; RecurrenceEditor with the server’s sentence and NextRuns; an InsetPanel “What this automation can do”; a key minted on create is shown once (ClipboardText); a cap above the key’s mode (409 mode_cap_above_key) says to create the automation again.
  • History (list): ScheduleRunRows with state, kind and origin Selects in the URL, cursor paging, and a ghost “Details” opening ScheduleRunDialog — state, outcome, trigger, error, the summary as plain text, counts and the built-in jobs’ findings by code in words (broken links with redirect proposals, stale articles, feed problems, digest sections), visitor or fetched text marked “From visitors”.
  • A release (routes/ReleasePage.tsx, editor template): title, state badge and “Publishes … · Istanbul (UTC+3)”; Move to… and Publish now in the header; outcome banners (published with lateness and snapshot, missed, expired, failed, canceled); after a conflict “Changed since approval” with Re-pin and approve / Cancel; Items (ReleaseItems: kind, path, pinned vs live version, closure, overlaps, locks, Compare → ReleaseDiffDialog, three columns after a conflict); Approval; the aside When (schedule a draft or “Publish when approved”), Title, Details and the danger zone (Unschedule, Cancel → ConfirmDialog).
  • Editors: a secondary “Schedule…” next to Publish (ScheduleAction, on servers with schedules; the dialog loads on hover or click) opens ScheduleDialog — the items, “At a time” (DateTimeField, site zone, at least 5 minutes ahead) or “As soon as it’s approved”, the closure note, whether the person approves as they schedule or sends it for approval; unsaved edits are saved and the article marked Published first. The aside’s Schedule card (ScheduleCard): “Visible from” and “Hide after” as DateTimeFields converted to UTC instants on change (clock-change gaps worded), the releases the node is in, gates_unsupported / invalid_availability in words, and when features.schedules.gates is false a secondary banner with the fields off. Held content gets LockedBanner (“Locked: publishes …”, Unschedule and edit, Edit and re-approve / Edit and send for approval, Open release) and a read-only form.
  • Elsewhere: Settings › General “Time zone” (TimeZoneField, Combobox, the browser’s zone offered; invalid_time_zone in words) and Settings › Schedules (ScheduleSettingsSection: scheduler health, the kill switch confirmed with a reason, the daily automation write cap with a Meter); Approvals render release requests as the release (ReleaseRequestDetails, lazily) and lift_embargo with the agent’s reason; the bell words the eleven schedule kinds (lib/schedule-notices.ts, loaded with the first one) and the sidebar item counts conflicts, misses, failures and approvals due.

2y. Body editor (RFC-0014 phase 1)

Markdown bodies (article bodies, page richText sections in markdown) are edited with MarkdownEditor from @cloudhal/ui/editor — its own subpath, like /charts, because Tiptap and ProseMirror are about 620 kB minified (190 kB gzip) with the menus. Studio reaches it only through components/BodyEditor.tsx, which React.lazy-loads it with the first body on screen and shows the plain Markdown InputArea until then; the editor’s modules are in bundle-budget.ts’s LAZY_ONLY. Words come from the lazy namespace bodyEditor (§1), loaded with the article and page editor routes. HTML bodies keep the plain InputArea.

  • Markdown is the only stored format. The editor never stores HTML or its own JSON. Loading splits the body into top-level blocks with marked (GFM, as the site renders); saving compares each block with what the editor would write for the original and keeps the original bytes of every untouched block — opening and saving changes nothing, and editing one paragraph rewrites that paragraph only (editor/markdown-blocks.ts, tests in markdown-editor-codec.test.tsx).
  • Nothing is silently rewritten. A block the editor cannot keep exactly — raw HTML, link definitions, a ```surface fence (RFC-0020 §5, whose info string a code block would cut), or anything whose rendered HTML would change after a trip through the editor (isLossy) — is a source block: a read-only InsetPanel variant="recessed" with a secondary Badge “Markdown”, the hint “Kept exactly as written. Edit it in Markdown mode.” and the source in font-mono. It saves as written.
  • Layout: a Kumo Toolbar (size="sm", groups split by border-kumo-hairline): Bold, Italic, Strikethrough, Inline code, Link · Heading (h2), Subheading (h3) · Bulleted, Numbered, Checklist, Quote, Code block, Insert table, Divider, Image (when the host passes onPickImage) · Undo, Redo. Pressed formats are aria-pressed with bg-kumo-tint; buttons keep the selection (onMouseDown prevents focus loss). Then segmented Tabs Rich text | Markdown. The editing surface is bg-kumo-control with ring ring-kumo-line (ring-kumo-focus while focused), rounded-lg, at least 256 px tall (128 px for page sections), with content styles in ui/styles.css (.ch-md-content: 14px text, headings 20/18/16 px semibold, Kumo tokens only). Markdown mode is CodeMirror 6 (editor/SourceEditor.tsx, MIT) on the same text: GFM highlighting in Kumo tokens (headings strong, links text-kumo-link, code and markers subtle), line wrapping, its own undo; it is a further lazy chunk (about 300 kB minified, 99 kB gzip) loaded on the first switch, with the InputArea as fallback. Only the GFM grammar is bundled, not the HTML/CSS/JS ones markdown() would add. The rich editor stays mounted, so its undo survives a switch.
  • Links go through a Popover with an “Address” Input: only https:, http:, mailto:, tel:, asset: and site-relative addresses (the renderer’s safeHref rule); anything else shows the field error and is not applied. Pasted and typed links follow the same rule.
  • Images keep their stored src; asset:<id> shows through Studio’s private content route (drafts included), other addresses as the SEO preview resolves them.
  • Read-only forms (roles below Editor, a release lock) pass readOnly: a fieldset cannot turn off contenteditable.
  • Slash menu: / at the start of a block or after a space (never in code) opens a listbox (“Insert block”) under the caret — Text, Heading, Subheading, the lists, Checklist, Quote, Code block, Insert table, Divider, Image — filtered by what follows the / against the label, an English key and synonyms, with the UI locale’s case rules (Turkish “ı”/“İ”). Arrows move, Enter or Tab inserts, Escape dismisses until the next /; focus never leaves the text. bg-kumo-elevated, shadow-lg ring ring-kumo-line, rounded-lg, options rounded-md with the chosen one bg-kumo-tint; Floating UI (through @tiptap/suggestion) keeps it attached while the page scrolls and flips it above near the bottom.
  • Selection toolbar: a non-empty text selection gets a small Toolbar above it (@tiptap/react/menus BubbleMenu; same surface as the slash menu): Bold, Italic, Strikethrough, Inline code, Link. It stays while its link popover is open. Not on node selections or in code blocks.
  • Images from the library: the host’s onPickImage returns { src, alt? }; Studio opens AssetPickerDialog (§2u) and writes ![](asset:<id) — an empty alt, so the site uses the library’s alt text, and it stays current when the library’s changes.
  • Paste: text that reads as markdown (block syntax, **…**, […](https://github.com/cloudhal-dev/cloudhal/blob/main/docs/design/…), inline code) arrives as blocks through the same codec as loading, so anything the editor cannot model becomes a source block. HTML from rendered pages (headings, lists, links, emphasis present) is left to the editor’s schema; HTML that is only styled spans (code editors such as VS Code) yields to its text.
  • Source blocks have a ghost “Edit as Markdown” button: Markdown mode opens with that block’s text selected.
  • Outline: a ghost Button “Outline” (ListDashes) next to the mode tabs, with a warning dot Badge counting heading problems, opens a Popover with the headings as a nav of ghost buttons indented by level; choosing one moves the caret there. The page already has its H1 (the article title, the hero), so an H1 in the body and a heading more than one level below the previous one get a warning Badge (“Use H2: the page has its H1”, “Skips a level”). Nothing is changed automatically.
  • Preview: the editor’s preview follows unsaved edits (§2f).
  • Suggestions (RFC-0014 phase 4): an agent’s open proposals for a body (edit_body with mode: "propose") sit above its editor — the article body, or each markdown rich-text section of a page — under a Text heading “Suggestions” with an info Badge “2 open” (components/BodyProposals.tsx). Each is an InsetPanel: an ActorBadge “{name} suggests”, RelativeTime, the agent’s note, then a BodyDiff of what accepting would change now (computed by GET /api/sites/:siteId/proposals, content:read), then a secondary “Reject” and a primary “Accept” (POST …/:id/accept|reject; deciding needs what updating the node needs — own limits for articles, content:write for pages). Accepting writes at once, so while the form has unsaved changes “Accept” is disabled with the hint “Save or discard your changes first.” beside it — a hint, not a Tooltip, which would wrap the button in its own. A proposal whose blocks changed since it was filed shows a warning Badge “Out of date” (its Tooltip names the block) and the hint text in place of the diff; it can only be rejected. Accepting one that went stale meanwhile returns 409 and a danger toast. Accepted blocks keep the agent as their author with via: "proposal". A suggestion of several operations lists them as a Checkbox.Group “Changes to accept”, one Checkbox.Item per operation in words (“Change “First” to “Opening””, “Remove “Second.””; “Rewrite “…”” for a replaced block, whose new text the diff shows; the agent’s text as plain text, one line, at most 80 characters), all ticked; unticking one re-fetches the diff for the rest (POST …/:id/preview, a SkeletonLine meanwhile) and Accept becomes “Accept 1 of 2”; with none ticked the hint “Choose at least one change.” replaces the diff and Accept is disabled.
  • Blocks (RFC-0015 phase 6b, §2aa): body directives (::button{…}, :::callout{…} … :::) show as an InsetPanel (outline) per block (editor/directives.tsx), named for assistive tech by the type’s title:
    • a header that is not editable text: the type’s icon, its title in bold, a secondary one-line summary of its first text props, a warning Badge “Check properties” when the props do not fit, and a ghost xs “Properties” button (SlidersHorizontal) at the end;
    • “Properties” opens a Popover (320 px, scrolls past 70 % of the viewport) with the type’s title and the generated BlockPropsForm; edits apply as they are typed and are disabled when the editor is read-only;
    • a container’s inner text sits under the header and is edited in place, like the rest of the body; its untouched blocks keep their bytes;
    • a type the deployment does not list shows “Unknown block · {name}” with its attributes as the summary, the hint that it is kept and not shown on the site, and the ghost “Edit as Markdown” button; it saves unchanged;
    • the / menu lists the body types after the built-in blocks, with their icons and titles in the UI language (found by the English name too), and inserts the type’s example.
  • Approvals (§2m): a pending edit_body request shows “What approving changes in “{title}”” and the same BodyDiff under its prompt (data-approval-changes), computed when the list loads; a request that no longer applies shows none.
  • Ask AI (RFC-0016 §3): when the site has AI on, the person holds ai.invoke and the server reaches Workers AI, the selection toolbar ends with a Sparkle button “Ask AI”. It hands the whole top-level blocks the selection touches to AiCommandDialog (MarkdownEditor’s onAskAi, selectionBlocks).
  • Focus mode (docs/design/body-focus-mode.md): a ghost ArrowsOut “Focus” button at the end of the header row (and Ctrl/⌘+Shift+\ from inside the editor) turns the editor into a full-screen modal layer (dialog named after the editor, the rest of Studio inert): a max-w-3xl column, a sticky top bar with the toolbar, the host’s focusActions (the article editor’s save state and Save), a “Spotlight” Switch (dims every block but the current one, remembered per browser) and “Leave focus”; Esc leaves and focus returns to the button. Never entered on page load.
  • Interactive blocks in the body (```surface): the source block draws the block with the site’s static preview (previewSource → api.entries.previewFragment, a frame with sandbox="allow-same-origin" and no scripts — same origin only so it can be sized to its content, the second exception after §2ac — cached per text) and keeps the code behind a ghost “Show code” toggle; “Edit as Markdown” still opens it. Invalid ones show the preview’s problem note instead.
    • The LayerDialog has a “What to do” Select with the seven commands. “Change tone” adds a tone Select, “Translate” a language Select named through Intl.DisplayNames, “Your own instruction” an InputArea of at most 500 characters. A secondary “Run” button sits beside them.
    • While the answer streams: “Writing…”, with the text as plain text in a bg-kumo-recessed box, and a “Stop” button.
    • Then a BodyDiff of selection → answer under “Suggested text”. A code fence around the whole answer is dropped. “Try again” reruns.
    • The primary Replace puts the answer into the editor in one undo step. It is refused, with an error Banner, when the selected blocks changed meanwhile. Cancel discards.
    • Server refusals are worded: ai_budget, ai_busy, ai_off, ai_model_unavailable, ai_rate_limited, ai_unavailable, ai_failed. Nothing is saved until the person saves the form.

2z. AI settings (RFC-0016)

Site › Settings gets an AI section (components/AiSettings.tsx, lazy namespace aiSettings), after Schedules:

  • a success Badge “On” (Sparkle) in the header while AI is on;
  • a secondary Banner “This deployment can’t reach Workers AI yet” when /ai/settings reports transport: "none", saying what to add ("ai": {} in cloudhal.json, or the two environment variables on Node). The rows stay editable, so an owner can choose settings first.
  • Rows (SettingRows):
    • “AI in the editor” — a Switch, off until the owner turns it on.
    • “Model” — a Combobox over GET …/ai/models. Recommended models come first, labelled “· Recommended”, then the account’s catalogue. Under it: the recommended model’s note, its context window, and where the text goes (“Selected text goes to Workers AI in this Cloudflare account” or “…to {provider} through your AI Gateway”).
    • “Daily limit” — a number Input, 0 to 10,000, with a Meter “7 of 100 used today”.
  • A Collapsible “AI Gateway and other providers”, open when either is set:
    • “AI Gateway” — an id Input;
    • “Model from another provider” — provider/model. While it is filled, it is the model used and the Combobox is disabled; it needs a gateway, and the field says so.
  • An alert Banner warns when the chosen Workers AI model is missing from the live catalogue: “…no longer offered by Workers AI…”.
  • Below Administrator (ai.settings = site:settings) the controls are disabled with the gating reason, and the model shows as text.

2aa. Component sections (RFC-0015 phase 6a, ADR-0030)

Pages can hold component sections: a block type from the registry (GET /api/sites/:siteId/block-types, content:read) with its props (components/BlockSection.tsx).

  • Adding: the page editor’s add-section DropdownMenu lists the section kinds, then a DropdownMenu.Separator and a group “Blocks” with every type allowed in sections, each with its icon (the type names a Phosphor icon; Cube otherwise) and its title in the UI language. A new section starts from the type’s example.
  • The card: a SectionCard titled with the type’s title and icon, like the other kinds.
  • The form: BlockPropsForm from @cloudhal/ui, generated from the type’s props schema — no code per type, for Cloudhal’s types and a theme’s alike:
    • a string is an Input (type="url" for links); format: "markdown" or x-widget: "textarea" an InputArea; an enum a Select, or a horizontal Radio.Group with x-widget: "radio" (enum values worded through editor.blocks.values.* when Studio knows them, else the value);
    • number/integer a number Input; boolean a Checkbox;
    • a flat object a fieldset with its fields; a list a bold Text label, then one InsetPanel per item headed “Item {n}” with ghost square buttons Move up (ArrowUp), Move down (ArrowDown) and Remove item (Trash), then a secondary sm “Add item” (Plus), disabled at maxItems;
    • labels and hints come from the schema’s title/description (per locale when given), else the prop name in words;
    • an optional field emptied is removed from the props, not stored as “”;
    • problems from the same interpreter the store runs (validateProps) show on their field as it is edited (for a link: “a safe link”); saving props that do not fit is refused with 422 invalid_props.
  • Unknown types (a theme type this deployment no longer has): the card is titled “Unknown block · {name}”, with an alert Banner saying it is kept but not shown on the site, and the props as read-only JSON in a font-mono InputArea. It saves unchanged.
  • Read-only roles and release locks disable the form like any section.
  • On the site and in previews: built-ins render escaped, framework-free markup (.ch-callout, .ch-button, .ch-embed, .ch-faq in the starter’s CSS); a theme can map a type to its own Astro component in theme.ts blocks. A section whose props do not fit, or whose type no one renders, renders nothing on the site and a labelled placeholder in previews.

2ab. Appearance (RFC-0017 §1, ADR-0031)

Site › Settings gets an Appearance section after Domains (components/AppearanceSettings.tsx): the theme’s design settings from its src/settings.ts (GET/PUT /api/sites/:siteId/theme-settings).

  • The form is BlockPropsForm over the theme’s schema (§2aa).
    • Colors are a Field with an InputGroup: a swatch addon (ring ring-kumo-line, the value or the default) and a font-mono input of at most 7 characters.
    • Enums are worded by the schema’s x-labels, per locale.
    • Each field’s description comes from the theme.
  • Checks as typed: the same interpreter the store runs, contrast rules included (“contrast with surface is 1.9:1; needs 4.5:1”). “Save changes” is disabled while a problem shows. The server answers misfits with 422 invalid_props, and a stale site rev with 409 and the conflict Banner.
  • The description names the theme. A secondary hint under the form says saved changes show on the site after the next publish.
  • A theme without settings shows only “The {theme} theme has no design settings to change.”
  • Below Administrator (settings.write = site:settings) the fields are disabled, with the gating reason on the button.

2ac. Build view (RFC-0020 §6, phase 20b)

/sites/:siteId/pages/:pageId/build (routes/BuildPage.tsx, template editor, lazy namespace build): a page while agents build it. The page editor’s header has a secondary “Build live” LinkButton (Lightning, Tooltip) that opens it.

  • Header. The title is “Build · {page title}”. The actions are a connection Badge (success dot “Live”, warning dot “Connecting…” / “Reconnecting…”) and a secondary “Back to editor” LinkButton.
  • Body. EditorLayout with the preview always shown.
    • Main, first: SectionCard “Build with AI” (components/BuildChat.tsx, phase 20c).
      • Transcript: BuildChatLog, oldest first. It shows the person’s brief in bold, the model’s notes with a Sparkle, a live Loader line (“Writing Band, Heading…”), the outcome with CheckCircle and repairs in mono, and errors with WarningCircle.
      • Composer: an InputArea “What should the page have?” and a primary “Build” button, which becomes a secondary “Stop” while the turn runs.
      • When AI is off: a SectionEmpty with “Open settings”. When the deployment cannot reach Workers AI: a SectionEmpty saying how to connect it.
    • Main: SectionCard “Activity” holding BuildActivity (@cloudhal/ui). The list is newest first. Each entry shows the actor icon and name, the tool in mono, an “Applied” (success) or “Refused” (warning) dot Badge and a relative time. Below that: a summary line (“2 sections changed · 3 operations”), then Cloudhal’s repairs or the refused parts with their JSON Pointers, in mono. The list is aria-live="polite". With nothing yet it shows a SectionEmpty.
    • Aside: SectionCard “Connect an agent”, with the MCP endpoint and the page id as InlineCopyText and three numbered steps (get_catalog, patch_draft, fix VALIDATION_FAILED).
  • Preview. PreviewPanel live morph:
    • The server sends the whole preview document with each change (GET …/build/events, server-sent events). The panel merges it into the shown document: sections are matched by data-section-id, unchanged ones are kept, so scroll position and open tabs survive. Changed sections are outlined for 1.6 s and the last one scrolls into view.
    • To merge, the frame is sandbox="allow-same-origin". Scripts stay off. The document is Cloudhal’s own escaped preview HTML under the preview CSP.
  • Picking (click-to-scope).
    • The preview header has a secondary “Pick” button (Target, aria-pressed). While picking it turns primary and reads “Click a part of the page”.
    • PreviewPanel pick lays a transparent button over the frame: the part under the pointer gets a ring-kumo-brand outline with a light brand tint, a click picks it, and Escape stops.
    • The picked part keeps a ring-kumo-brand outline that follows scrolling and new versions.
    • Build chat shows the scope as an info Badge (Target) above the brief, with an icon-only “Change the whole page instead” button.
  • No editing here. People edit in the page editor; agents change the draft through MCP.

2ad. Corrections in the article editor (RFC-0019 §2, phase 19b)

components/ArticleCorrections.tsx, in the article editor’s main column after the body. It is absent until the article has been published.

  • SectionCard “Corrections” with Megaphone. Its description says readers see these at the end, newest first, and that a published correction is reworded, never removed.
  • The list is CorrectionList (@cloudhal/ui): rows in a divide-y list, newest first. Each row has a kind Badge (correction warning, clarification info, update neutral), the time or “Not saved”, and the note as text. Only unsaved rows have an icon-only “Remove correction” button. With none, it shows a SectionEmpty.
  • The form: a Select “Kind”, an InputArea “What changed” (≤ 2,000) and a secondary “Add correction” button that appends to the draft. A line says it goes live with the next publish. The store stamps the time on save, and the auto mode check makes that publish wait for a person.

2ae. Design rules in Site settings (RFC-0021 §5, phase 21a)

components/DesignRulesSettings.tsx, a SettingsSection after Appearance (#design, “Design rules”).

  • Editor. An InputArea in mono for the site’s DESIGN.md, with a character count against 24,000. While empty, a secondary “Start from the headings” button fills in the recommended headings.
  • Theme rules. The theme’s DESIGN.md sits read-only inside a Collapsible (“The blog theme’s rules”), with a line saying the site’s rules win.
  • Feedback. design_lint warnings show as an alert Banner (“Worth a look”). A conflict on save shows the shared conflict wording.
  • Hints. A secondary line says agents propose changes that wait in Approvals. Below Administrator the section is read-only, with the gate’s reason.

2af. Playground session (RFC-0036 §5, ADR-0038)

  • Only when /api/instance reports features.playground (try.cloudhal.com). Studio has no sign-in there: requireSession accepts a 403 from /api/auth when the instance is a playground.
  • Session bar: PlaygroundBar under the top bar on every screen — a compact secondary Banner (Timer), title “Playground”, the minutes left as an ICU plural with the privacy note, a ghost “Reset” (POST resetUrl, then the landing page) and a primary “Deploy to Cloudflare” LinkButton (external). At zero minutes the browser returns to the landing page.
  • Left out: the account surface’s Agents group and Team/Account items (and their palette commands), the user menu’s Account, Team and Sign out. A direct visit to /agents, /team or /account shows PlaygroundUnavailable: a bare Empty (LockSimple) “Available in your own install” with the Deploy button. The site sidebar leaves out Analytics, Activity, Approvals and the Automation group (Agents, Schedules). Elsewhere site screens are gated as everywhere else, by features.* and the principal’s abilities; a 403 playground answer counts as a feature that is off (isNotEnabled).

3. Sidebar specification

<Sidebar.Provider defaultOpen collapsible="icon" peekable mobileBreakpoint={768}>
<Sidebar>
<Sidebar.Header>{/* switcher: DropdownMenu listing sites + "All sites" */}</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group><Sidebar.GroupLabel>Overview</Sidebar.GroupLabel><Sidebar.Menu>
<Sidebar.MenuButton icon={House} href active tooltip>Overview</Sidebar.MenuButton>
</Sidebar.Menu></Sidebar.Group>
<Sidebar.Group><Sidebar.GroupLabel>Content</Sidebar.GroupLabel><Sidebar.Menu>…Pages, Articles, Categories & tags (proposal count), Media…</Sidebar.Menu></Sidebar.Group>
<Sidebar.Group><Sidebar.GroupLabel>History</Sidebar.GroupLabel><Sidebar.Menu>…Snapshots, Activity, Approvals (pending count)…</Sidebar.Menu></Sidebar.Group>
<Sidebar.Group><Sidebar.GroupLabel>Automation</Sidebar.GroupLabel><Sidebar.Menu>…Agents (collapsible: Keys and MCP → /agents, Skills → /agents/skills), Schedules "Soon"…</Sidebar.Menu></Sidebar.Group>
<Sidebar.Group><Sidebar.GroupLabel>Settings</Sidebar.GroupLabel><Sidebar.Menu>…Settings → site settings, Navigation (menus, §2t), Redirects (account surface: Team → /team with the member count, Account → /account)…</Sidebar.Menu></Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer className="z-2"><Sidebar.Trigger /></Sidebar.Footer>
<Sidebar.Rail />
</Sidebar>
<main className="flex-1 bg-kumo-overlay">…</main>
</Sidebar.Provider>

Composition rules verified against Kumo’s own SidebarDemo.tsx:

  • The header switcher is not a MenuButton (that auto-wraps in <li> and renders a bullet outside a Menu); it is a plain row trigger for DropdownMenu with w-(--anchor-width) content and a check on the current item — @cloudhal/ui ships it as SidebarSwitcher.
  • Sidebar.Loading replaces Sidebar.Content until navigation data resolves; never render an empty menu.
  • Counts and states go in Sidebar.MenuBadge (4, Soon, Beta); unavailable items are disabled, never hidden.
  • Sub-navigation uses Sidebar.MenuItem → Sidebar.Collapsible → CollapsibleTrigger render={<Sidebar.MenuButton>… <Sidebar.MenuChevron /></Sidebar.MenuButton>} → CollapsibleContent → MenuSub/MenuSubButton.
  • Sidebar.Footer holds only Sidebar.Trigger, as in Kumo’s SidebarFullDemo, so it stays inside the 57 px rail when collapsed; the user menu is the avatar at the end of the top bar (UserMenu), reachable whether the sidebar is expanded, collapsed or a sheet. The footer is z-2: Sidebar.Rail (z-1, full height, inside the peek zone) used to cover the trigger, so hovering it started a peek, the rail slid away with the widening sidebar, the peek stopped and the rail came back — the sidebar flickered under the cursor. Sidebar.Rail toggles the sidebar from its edge.
  • Below the mobile breakpoint the sidebar is a sheet; choosing a page closes it (CloseSheetOnNavigate, like Kumo’s NavTree demo).

Rules from sidebar.tsx: MenuButton auto-wraps in <li>; MenuItem only around Collapsible; tooltip shows only when collapsed and not peekable; GroupLabel becomes a divider when collapsed; sub-menus via Sidebar.Collapsible + MenuSub/MenuSubButton; account↔site via Sidebar.SlidingViews; below mobileBreakpoint the sidebar is a sheet. Group names follow capabilities (Overview, Content, History, Automation, Settings), never product names.

4. Typography, spacing, colour

  • Text only through Text: body 14 px (size="base"), xs 12 / sm 13 for captions, lg 16 only for headings; variant="heading" (16 px) and size="lg" (20 px) for page-level; page titles come from the blocks. Sentence case; product names title-cased; bold prop for emphasis; never font-bold, never tracking-*, never uppercase.
  • Spacing: gap-1.5 between a heading and its description, gap-6 between blocks, card padding px-5 py-4, page container p-6 md:p-8 lg:px-10 lg:py-9, max-w-[1400px].
  • Colour: semantic tokens only — surfaces bg-kumo-canvas|base|elevated|recessed|tint|overlay|contrast, brand bg-kumo-brand, status kumo-info|success|warning|danger (+-tint), text text-kumo-default|strong|subtle|inactive|placeholder|inverse|link. No raw Tailwind colours, no dark: (Kumo themes via light-dark(); toggle with data-mode="dark"). The person chooses Light, Dark or System (the default, following prefers-color-scheme live) in the top bar’s ThemeSwitch; the choice lives in localStorage (cloudhal.studio.theme, lib/theme.ts, synced across tabs) and an inline script in index.html applies it as data-mode (and color-scheme) on <html> before first paint, so a reload never flashes the other theme. The only fixed colours allowed are inside the preview frame, which shows the visitor-facing site (its own theme) on a light canvas.
  • Semantic badge colours: decisions and outcomes use the status variants (success / warning / error); actor kinds and scope groups use tints (info, secondary, outline, warning for publish/rollback scopes) — never the saturated purple/blue/neutral fills, which out-shout the status column.
  • Edges: ring ring-kumo-line (elevated) / ring-kumo-hairline (flat); never border + shadow; concentric radii (rounded-xl p-1 → rounded-lg); sticky elements separate with border-b border-kumo-line; no transition-colors on hover.
  • Icons (Phosphor): 16 px in nav, inline text, toolbars, card headers; 20 px icon-only buttons; 24 px top-level list-page title (PageLayout icon); 48 px Empty; weight="fill" only for status (banners, toasts) and the brand mark. Studio ships three weights only — regular, bold, fill (apps/studio/phosphor-weights.ts drops thin, light and duotone from every icon definition; a test refuses them in source). Icon-only buttons need aria-label and shape="square".

5. States

State Implementation
Loading Prefer each component’s own loading affordance: Sidebar.Loading, Breadcrumbs.Current loading, Select loading, Button loading, InputGroup.Addon with Loader for async validation; SkeletonLine rows only where no component owns the state (table bodies, text cards). Never a spinner-only page.
Empty Empty with icon (48), title, description, commandLine for CLI parity, contents for the CTA.
Error Banner variant="error" with Banner.Action retry; field errors via Input error; conflicts (409) explain “someone else changed this” and offer reload.
Success useKumoToastManager().add({ title, description, type: "success" }); long jobs via promise().
Confirm ApprovalDialog, DeleteResource, LayerDialog.Alert; controlled by open.
Busy Button loading, dismiss disabled while pending.
Not available A feature the server does not run (404 from /activity, /agent-keys) is not an error: Banner variant="secondary" with an info icon explaining what to start or enable; create actions are disabled.
More data Cursor APIs (nextBefore): footer with “Showing n of m loaded” and a secondary “Load more” (loading); client-side Pagination stays for fully loaded lists.
Polling Counts that must stay current (pending approvals) use refetchInterval (15 s) on a query owned by the surface shell, so polling stops when the surface unmounts; a 404 stops it for good. The bell’s notifications poll every 60 s (15 s while an elevation ends), also in the background, and never again after a 404 (§2n).
Re-authenticate Actions the auth server allows only after a recent sign-in (the session list) show Banner variant="secondary" with a “Sign in again” action, never an error.
Time Relative in lists and detail cards (“5 minutes ago”, Intl.RelativeTimeFormat in the active locale, re-rendered every 30 s) with the absolute timestamp in a Tooltip; absolute in audit detail dialogs.

6. Component usage matrix (all 54)

Studio must use each component where it fits; “later” means the surface does not exist yet (analytics, scheduling, agents) and the component is reserved for it.

Component Studio use
Autocomplete the tag picker in the article editor (TagPicker, §2p); author suggestions (later)
Badge status pills; Sidebar.MenuBadge for “Soon/Beta”; scopes, change counts, diff kinds; Cloudhal wrappers StatusBadge, DecisionBadge, OutcomeBadge, ActorBadge carry data-* for tests
Banner page/section errors (with Banner.Action retry), limits, warnings, one-time-secret notice, policy reasons
Breadcrumbs every detail/editor page, Clipboard for ids
BubbleMap / Chart / ChoroplethMap / GlobeMap / SankeyChart / TimeseriesChart analytics (§2v): TimeseriesChart inside AnalyticsTimeseries, ChoroplethMap inside CountryMap (never with tooltipFormatter), both through @cloudhal/ui/charts; ChartLegend for series totals; BubbleMap was considered and rejected for country totals (RFC-0006 §10.1); Chart, GlobeMap, SankeyChart unused
Button all actions; 6 variants; loading
ButtonGroup split actions (the top bar’s Publish ▾)
Checkbox bulk select (Table.CheckCell, BulkActionBar, CommentThread selection, §2w), non-instant settings, agent-key scopes (grouped)
ClipboardText ids and keys in settings; MCP endpoint/header; the one-time key reveal (size="lg")
CloudflareLogo not used (Cloudflare brand)
Code one-line CLI parity in usage panels
CodeHighlighted (@cloudhal/ui/code) client config snippets, tool input JSON (showCopyButton)
Collapsible approval arguments, a comment’s moderation history (CommentHistory, §2w), the skills tools/call example, the reasons of the possible-bot-traffic banner (§2v), “Advanced: scopes” when creating an agent key, the page SEO panel’s “Advanced” (canonical URL, §2o); run step details (later)
Combobox the category picker (multi-select with chips), merge/move/redirect targets (TermCombobox, §2p); the grouped menu target picker (§2t); site/page pickers
CommandPalette global ⌘K / /
DatePicker / DateRangePicker DatePicker mode="range" in the analytics custom range (AnalyticsRangePicker, UTC, lazy, §2v), mode="single" in DateTimeField (scheduled publish, “Move to…”, §2x), always with calendarLocaleProps; history filters (later). DateRangePicker is deprecated and English-only: never
Dialog small decisions (ApprovalDialog)
DropdownMenu row menus, switcher, user menu, “More” actions
Empty every list
Field wraps controls that lack a label prop
Grid detail/settings layouts
InlineCopyText ids in dense cells
Input / InputArea / InputGroup forms; InputGroup.Addon for slug/URL/host prefixes (/, https://, the preview host) and async validation state; InputGroup.Button for the password show/hide toggle (PasswordField)
Label standalone labels only
LayerCard section cards (never nested)
LayerDialog create/edit forms (create agent key, create snapshot), detail sheets (activity event), snapshot diff, destructive alerts
Link / LinkButton in-table titles, inline links, “View changes”, “Open in new tab” (external); hrefs with ?query are split for TanStack Router by AppLink
Loader inline busy only
MenuBar editor formatting toolbar (later)
Meter password strength under a new password (NewPasswordFields, §2r); per-file upload progress (§2u); analytics shares, devices, channels and the Free-plan estimate (BreakdownTable, BreakdownMeters, AnalyticsQuotaBanner, §2v)
Pagination every table
Popover the notification panel (NotificationBell, §2n); filters, help
Radio exclusive settings; appearance="card" for the preview-link target, the agent mode picker (§2g) and the “after creating” choice of the create-snapshot dialog
Select filters, enums (never for status mutation)
SensitiveInput API keys, secrets
Sidebar app navigation (§3)
SkeletonLine loading rows
Slider agent parameters (later)
Surface standalone neutral surfaces (AuthLayout card)
PageLayout / Columns / SectionCard / SectionEmpty / InsetPanel / AuthLayout / NotificationBell / NotificationItem / TermChip (Cloudhal blocks) page anatomy, container-aware two-column bodies (instead of Grid 2-1/1-2), section cards, frameless empty states inside cards, inset summaries, auth screens, the top bar’s notification bell and panel rows, removable term chips outside a Combobox (§1, §2, §2n, §2p)
LanguageChips (Cloudhal block) the Languages cell of Pages and Articles on a multilingual site: one Badge chip per site language linking to that version (published success, draft secondary, outdated warning, needs review info), a missing language as an outline Button size="xs" with a “Write by hand” DropdownMenu; more than four languages collapse to “n/m” with the chips in a Popover (§2a)
Avatar (Cloudhal block) a person’s initials in a round tint (sm 24 / base 32 / lg 48 px): the Team table, dialog headers, the all-keys owner column, the Account profile; never an image
MenuTree / NavigationPreview (Cloudhal blocks) the keyboard-first navigation tree (WAI-ARIA tree, live-region announcements, drag and drop as an enhancement) and the header/footer sketch of the menu editor (§2t); MenuTree readOnly for derived menus
SurfaceOutline (Cloudhal block) a generated section (RFC-0020 surface) as an outline in the page editor: readable component names, what each shows (its text, then “shows /path” or “computed (fn)”), hairline rows with depth guides like MenuTree, an outline badge with the id, parts not on the page marked; text fields open per row on demand (pencil, aria-expanded); row actions from the screen (“Change with AI” links to Build with ?section=&component=). The data stays behind a Collapsible “Show JSON”
ColorRolePreview (Cloudhal block) the color roles a theme derives from its tokens (RFC-0023) under the Appearance form: per scheme (light, and dark once a seed is set) an InsetPanel with a small sample drawn in the site’s colors (headline, body text, link, brand button, accent tag) and a swatch list of every role with its hex value; the contrast repairs below as plain sentences. The site’s colors are inline styles; the frame, type and spacing are Kumo’s
ImagePreview / SearchResultPreview / SocialCardPreview (Cloudhal blocks) cover image frame and list thumbnails (size card / thumb / tile 4:3, fit cover / contain), search result and share card previews (§2o, §2u); the only <img> in Studio
MediaDropZone / useFileChooser (Cloudhal blocks) where files come in: drag and drop, paste and the file button (variant="area"), or a drop overlay around the library (variant="overlay"); the only raw file <input> in Studio; useFileChooser opens the system chooser from any button (§2u)
FocalPointPicker (Cloudhal block) where an image’s subject is (RFC-0028): the picture with a crosshair, set by click or the arrow keys (Shift for bigger steps, Home for the centre), announced as a slider whose value names both coordinates, a ghost “Clear focal point”, and wide, square and tall crop previews; on the file’s page in place of the preview frame (§2u)
MediaGrid / MediaTile / MediaKindFrame (Cloudhal blocks) the media library’s and the picker’s tiles: auto-fill columns of at least 10 rem, a tile that links (library) or toggles (picker, aria-pressed, brand ring and check), and the 4:3 kind-and-extension frame for files that are not images (§2u)
StatTile / StatTileGroup / DeltaBadge / Sparkline (Cloudhal blocks) key figures with a delta badge against the previous period (success up, warning down, neutral within ±2 %), an optional info tooltip and sparkline, laid out as a container-query dl grid of two to four columns (§2v)
AnalyticsRangePicker (Cloudhal block) the analytics range: preset Tabs, “Custom…” Popover with a lazy DatePicker mode="range" (UTC, Studio locale), optional compare Switch; controlled by {preset} or {from, to} (§2v)
BreakdownTable / BreakdownMeters (Cloudhal blocks) ranked analytics breakdowns — top content, channels, referrers (never linked), campaigns, countries — with share Meters and one folded “(other)” row; small breakdowns (devices, top channels) as a Meter list (§2v)
AnalyticsTimeseries / CountryMap / ReadersSummary (Cloudhal blocks, @cloudhal/ui/charts) traffic over time with a legend and a data-table toggle (UTC throughout); the country choropleth with its top-countries table and no HTML tooltips; the editors’ Readers card body (§2v)
PlaygroundBar (Cloudhal block) the playground session bar: time left, Reset and Deploy to Cloudflare in a compact secondary Banner (§2af)
AnalyticsQuotaBanner (Cloudhal block) the Workers Free request warning with its estimate as a Meter (shouldWarnFreePlan, §2v)
AnalyticsBotBanner (Cloudhal block) the possible-bot-traffic warning on the Analytics screen with its reasons in a Collapsible (§2v)
CommentText / CommentRow / CommentThread (Cloudhal blocks) comment text as plain text with the public page’s https:-only link rule (never HTML), one comment with its author, status, labels, entry, reply context and untrusted-text panel, and the two-level thread with folded replies and in-reply-to jumps (§2w)
CommentStatusBadge / CommentLabelList / CommentAuthor / CommentTime / UntrustedTextPanel (Cloudhal blocks) the parts of a comment, also for queue table cells (§2w)
BulkActionBar (Cloudhal block) the bar that replaces a list’s toolbar while rows are selected: select all/some, count, actions with disabled reasons, Clear (§2w)
CommentFilters / CommenterSummary / StatementOfReasons / CommentHistory / CommentReports / ArticleCommentsSummary (Cloudhal blocks) the moderation queue’s filters, a commenter at a glance (email only when allowed), the DSA Art. 17 statement of reasons and moderation history, DSA Art. 16 reports, and the article editor’s Comments card body (§2w)
CalendarMonth / AgendaList / CalendarNav (Cloudhal blocks) the schedules calendar: the APG month grid (one tab stop, chips with “+N more”, drag and Alt+arrow moves through onMove, dots and a day list on phones), the agenda grouped by day under sticky headings with a “Now” rule and folds, and the ‹ Today › / period / Agenda–Month row (§2x)
ScheduleItem / ScheduleStatusBadge / ScheduleFilters / TimezoneNote (Cloudhal blocks) one scheduled thing as a chip or row in the schedules visual language (proposals dashed, D14), every release/automation/run/approval state as an icon + word badge, the calendar’s multi-value filters and views, and the site-zone note with “Show in my time” (§2x)
DateTimeField / RecurrenceEditor / NextRuns / RescheduleDialog / ScheduleRunRow (Cloudhal blocks) date + 24-hour time in the site zone (the lazy DatePicker and a typed time), recurrence presets and the RRULE subset, the server’s next runs with clock-change badges, “Move to…” with the approval kept or voided, and the run history row with lateness, budget use and the taint marker (§2x)
Switch instant settings; browser notifications (Account › Notifications); page “Hide from search engines” (§2o)
Table all lists
TableOfContents settings sections (SettingsLayout, driven by useTableOfContentsActiveId)
Tabs page-header tabs (controlled from the route), segmented switches (device width, snippet format, diff base, elevation and grant duration presets), underline Edit/Preview on narrow editors
TagInput legacy article tags on sites not yet migrated; a term’s synonyms (§2p)
Text all text
Toasty root provider; notice toasts (useToast().notice: warning/info, the item’s id, up to two actions) for notifications
Toolbar search/filter rows (Select render={<Toolbar.Button />} for facets)
Tooltip icon buttons, truncated cells, absolute time behind relative time, scope descriptions (TooltipProvider at root)
RefreshButton reload a live list (activity) or the preview frame
PreviewPanel (Cloudhal block) every editor (§2f); the only place an <iframe> may appear

7. Anti-patterns (CI-enforced where mechanical)

Enforcement. apps/studio/scripts/design-lint.ts holds the per-file rules (raw elements, class rules, entry points, ad-hoc surfaces, page anatomy, dialog rendering, card nesting, i18n, route templates). It runs three ways: in CI through apps/studio/test/design-rules.test.ts (pnpm check), by hand with pnpm design:lint [files…], and on every file an agent writes through the Claude Code hook in .claude/settings.json (exit 2 sends the violations back to the agent at once). Biome additionally forbids @cloudflare/kumo, @base-ui/react and other icon sets in apps/**. When a rule blocks something Kumo cannot express, the answer is a new block in packages/ui/src/blocks (with the same rules), never an exception in a screen.

Raw <table>/<h1>/<button>/<input>/<select>/<a>/<ul>/<p> in screens; hand-rolled sidebars, headers, cards; raw Tailwind colours; dark:; font-bold, tracking-*, uppercase headings; hover colour transitions; border + shadow; nested LayerCard; conditionally rendered dialogs; status mutation through Select; DANGEROUS_className to restyle Kumo; a Tooltip used as a menu/popover trigger (Trigger render={<Tooltip …/>} swallows the trigger’s props — nest it the other way, <Tooltip render={<DropdownMenu.Trigger render={<Button/>}/>}/>); hard-coded English (every string through useT(); Kumo’s own labels through KumoLocaleProvider, calendarLocaleProps and the block labels, §2v Kumo locale); spinner-only pages; uniform p-5 card padding; a raw <iframe> in a screen or any iframe without sandbox=""; fragments or conditionals directly inside LayerDialog.Content; a <form> around Kumo Checkbox/Switch (they are buttons and submit it); past-tense labels for audit events (“Deleted page” for a call that was denied) — use the tool’s imperative name; a top bar that can shrink (shrink-0 on sticky bars inside flex columns); rendering agent- or server-supplied text (approval prompts, skill descriptions) as HTML — always plain Text, never dangerouslySetInnerHTML; a chart or map tooltipFormatter / dangerousHtmlFormatter (Kumo inserts the result as HTML; lint no-html-tooltip, §2v); letting a self-approved action skip the person (approvals are decided only in Studio, ADR-0014); offering or sending bypass for an API key, a mode list that is not KEY_MODES, or letting a key become auto without the explicit consent checkbox (ADR-0015); offering an elevation to a mode that is not above the key’s own, or elevating to auto without that same checkbox (ADR-0016); extending with any mode but the one in force, re-elevating a key whose elevation has ended from a notification, or changing an elevation outside useExtendElevation/useEndElevation; announcing a notification twice, or announcing what was there on the session’s first load (§2n).

8. Review checklist

Before merging UI: pnpm design:lint; look up components with pnpm kumo ls / pnpm kumo doc <Name> / pnpm kumo blocks before writing markup; screenshot every touched screen at 1440 and 390 px with pnpm design:shots -- <routes> (headless Chrome against your dev server; fails on sideways scrolling or console errors; SHOTS_SCHEME=dark|light and SHOTS_LOCALE=tr for both themes and Turkish; for role-gated screens, one run per role’s test account) and compare against this document; run pnpm --filter @cloudhal/studio test (design rules); confirm every new string exists in en.ts and every <locale>.ts (test/i18n.test.tsx); confirm the kumo-design rules (14px, sentence case, tokens) by reading the diff.