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 fromGET /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’sApprovalDialog; 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 setmx-auto/max-w-*themselves (lintpage-container). | | Breadcrumbs | Built only byCrumbs/SiteCrumbs(components/Crumbs.tsx): “Cloudhal Studio › ui-audit › Pages › About”,Breadcrumbs.Current loading,Breadcrumbs.Clipboardfor the current resource id. Passed toPageLayout crumbs, which portals them into the top bar — never rendered in the content. | | Page header | Kumo’sPageHeaderblock (owned copy: every row flush with the content edge, 14 px description,icon,actions, controlledtab, loading title), rendered byPageLayoutfrom itstitle,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) andtabs. 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(+asidefor usage/help: theResourceListPagetwo-column body, 380 px sticky once the content is ≥ 64 rem) +ListToolbar(KumoToolbar, default size so it lines up withSelect/Button) +LayerCard className="p-0"+Table+Pagination; empty → a bareEmptyin the table’s place. | | Sections |SectionCard(@cloudhal/ui): KumoLayerCardwith theSecondaryheader row (title, optional 16 px icon,actionsright: countBadge, ghostLinkButton“View all”, small buttons) and thePrimarybody (description,childreningrid gap-5 px-5 pt-4 pb-5,flushfor aTable,footerrow for Save/Discard). Never nested (lintlayer-card-nesting); never a bareLayerCardwith 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);Surfacefor standalone neutral surfaces. Hand-maderounded-* ringdivs are forbidden in screens (lintno-ad-hoc-surface). | | Feedback |Banner(page/section),Field/Input error(inline),Toasty+useKumoToastManager(transient). | | Confirmation |ApprovalDialog(ours, onDialog) for publish/rollback;DeleteResource(block) for typed-name deletes;LayerDialog.Alertfor other destructive alerts. Always rendered, controlled byopen. | | Forms & detail sheets |LayerDialog.Root open onOpenChange→LayerDialog.Contentwhose direct children areTitle, optionalDescription,Body, optionalActions(Kumo throws otherwise; branch inside each part). Desktop dialog, bottom sheet on phones. | | Code & config |CodeHighlightedfrom@cloudhal/ui/code(Shiki, lazy,showCopyButton) for snippets and JSON payloads;ShikiProviderlives at the app root (CodeProvider, localised copy labels).Codestays 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 attyped 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) usesuseMessages([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")inrouter.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.tsfails the build ifi18n/lazy/reaches the initial load. - No hard-coded words (AGENTS.md hard rule 10). Nothing a person reads is a literal in
apps/studio/srcorpackages/ui/src: JSX text and string children,aria-label,title,placeholder,alt,label,description,contentand every…Label/…Text/…Title/…Description/…Placeholder/…Message/…Hintattribute or property, tables of labels ({ draft: "Draft", … }), default values and??/||fallbacks, toasts andconfirm(),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 byapiErrorText(t, error)(lib/api-errors.ts:errors.api.<code>, then the server’s message, then the status), nevererror.messagedirectly. Search folds text with the Studio locale (usePaging’sfold,toLocaleLowerCase(locale)), nevertoLowerCase(). Thei18nrule of the design lint (apps/studio/scripts/i18n-lint.ts) parses every.ts/.tsxof both trees with oxc and failspnpm 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 itsALLOWEDlist (product and format names) with a reason, or carries ani18n-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}.otheris required; a literal brace is quoted,'{}'.@cloudhal/messagescompiles the catalogs at build time (thecatalogs()Vite plugin invite.config.tsandvitest.config.ts), so no parser ships.t()is typed per key fromsrc/i18n/generated/params.ts(pnpm gen; a plural argument is anumber, a select one of its values); a message a block fills in itself keeps its placeholders withkeep("name"). A count is onepluralmessage called with the number (t("editor.words", { count })), never a…Onekey or acount === 1 ?ternary (the test rejects…One/…Manykeys). 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 informs(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 aselectover 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’sdropped, with the reason; the test rejects any other missing argument and any stale entry. A list a person reads goes throughlistOf(items)(Intl.ListFormatin the Studio language: “a, b, and c”, “a, b ve c”;formatList(locale, …)from@cloudhal/messages/formatoutside React), neveritems.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_TIMEfor whattoLocaleDateString/toLocaleStringshowed), always with the Studio locale (or a fixed one for machine formats such as theen-CAday keys), nevernew Intl.*ortoLocaleString()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) andi18n-concat(a sentence assembled from severalt()results, or a list joined with", "), inpnpm check,pnpm design:lintand the agent hook; a reviewed exception carriesi18n-ignore: <reason>.test/i18n-messages.test.tschecks 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.tsand each<locale>.ts; a lazy namespace: eachlazy/<ns>.<locale>.ts, never back into the core catalogs;test/i18n.test.tsxchecks keys and placeholders for every language). A branch made before a namespace moved (e.g.analytics.bots.*added underanalyticsinen.ts/tr.ts) merges by moving its lines unchanged intolazy/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.tsis the registry: one entry per language, keyed by its BCP 47 tag (also<html lang>, everyIntlformatter andcalendarLocaleProps, so dates and numbers follow the Studio language, never the browser’s), with its nativename,dir(set on<html dir>),fallbackchain (always ending in English)reviewedand, where formatting needs it, anintltag (Arabic and Persian…-u-nu-latn, Western digits, ADR-0033;setIntlTagsmakes every formatter of@cloudhal/messagesuse 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.globfindsi18n/<locale>.ts(export const messages: Messages) andi18n/lazy/<ns>.<locale>.ts;bundle-budget.tskeeps 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 (matchLocaleremains 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)
?pseudoorlocalStorage["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.tsalso fails on an unused key:apps/studio/scripts/i18n-usage.tscollects 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.tslists 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/uiblocks use logical utilities only —ms/me,ps/pe,start/end,text-start/text-end,border-s/border-e,rounded-s/rounded-e/rounded-ss… — neverml/mr,pl/pr,left/right,text-left/text-right,border-l/border-r,rounded-l/rounded-r(lintrtl-logical;scripts/i18n-codemod/logical-utilities.tsconverts; an element placed by Floating UI in physical coordinates keepsleft-0with ani18n-ignorereason).<html dir>and Base UI’sDirectionProvider(inKumoLocale, through@cloudhal/ui) follow the locale registry’sdir.SHOTS_PSEUDO=rtl pnpm design:shots -- …(or?pseudo=rtlon 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 throughDirectionalIcon(<DirectionalIcon icon={ArrowRight} />, ordirectional(ArrowRight)where a prop or table takes an icon component), which sets Phosphor’smirroredfrom Base UI’s direction (ADR-0033 C1, lintrtl-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 ani18n-ignorereason. Kumo’s own ten directional icons (submenu, sidebar, pagination, date range, command palette) and the breadcrumb separator mirror throughrtl:-scale-x-100from the hand-writtenpatches/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 throughreadingKey(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 bypnpm i18n:importfrom translation files — nested ICU JSON,.i18n/<locale>/<core|namespace>.json, written bypnpm i18n:exportwith.i18n/descriptions.json(English text and the note above each key) — and carries a hash of its messages: a hand edit failspnpm check(node scripts/i18n-exchange.ts check, run bytest/i18n-messages.test.ts). The import checks keys and ICU syntax before writing anything, keeps a catalog’s own exports after its messages (Turkishdropped), 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 thetranslateskill,.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 withreviewed: false; (3)pnpm i18n:export, write.i18n/<locale>/*.json(thetranslateskill 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 generatesi18n/<locale>.tsand everylazy/<ns>.<locale>.ts; (4)pnpm --filter @cloudhal/studio typecheckandtest/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:shotsin the language at 1440 and 390 px (German and Finnish run long); (7) a speaker reviews in context, thenreviewed: 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, heightcalc(100svh - 6rem), filling its column (PreviewPanel fill). A secondaryPreviewTogglelabelled “Hide preview” / “Show preview” (Tooltip,aria-pressed) in the header actions hides it; the choice is remembered per browser and the layout falls back toColumns 2-1. - content < 64 rem: underline
Tabs“Edit | Preview” above the content; Edit shows the 2bColumns 2-1(stacked on phones). PreviewPanel:LayerCardwith aSecondarytoolbar — heading, status slot (Badge warning dot“Unsaved changes” +Tooltipwhen the form is dirty), segmentedTabs size="sm"for Desktop/Tablet/Mobile (icon + label, label hidden on phones),RefreshButton,LinkButton external“open in new tab” — and aPrimaryviewport onbg-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 withring ring-kumo-line. Loading =SkeletonLinerows in an overlay withrole="status".- The frame is always
<iframe sandbox="">(no scripts, no same-origin, no forms), except in the Build view (§2ac) on the same-origin/previewendpoint; it reloads when the document’srevchanges (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 toPOST …/preview(scopecontent:read, nothing written) after each 400 ms pause in typing, andPreviewPanel live srcDocswaps 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/previewas before. A failed draft render shows an errorBadgewith the reason in aTooltip.
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 viaRadio.Legend className="sr-only"), oneRadio.Itemper key mode, iteratingKEY_MODES(never a literal list; CI checks).- Card
label: mode icon (Phosphor: planEye, manualUserCheck, editsPencilSimple, autoLightning, bypassLockSimpleOpen, 16 px,text-kumo-subtle), the name, a riskBadge: plan/manualsuccess+ShieldCheck“Safest”, editsinfo+Star“Recommended”, autowarning+Warning“Use with care”; in the change dialog the key’s current mode adds an outline “Current”. - Card
description: one line on behaviour, then aHandPalmline “Waits for you: …”. BypassHintunder the cards;editspreselected.- Choosing
auto(where it is not already the mode) showsAutoModeConfirm:Banner variant="alert"with aLightningicon explaining the low-risk rule (articles only, nothing removed, ≤ 20 changes, marked auto-approved, one-click rollback), and aCheckbox“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 renderingModePickeralso rendersAutoModeConfirm.
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):
- Mode (step 1 of 2): the mode picker (+ auto consent, bypass hint). Primary “Continue”.
- Name and scopes (step 2 of 2): an
InsetPanel layout="row"summary (Mode +ModeBadge, “Waits for you: …”, secondary “Change” back to step 1),Inputname (+description, inlineerrorafter a submit attempt), thenCollapsible“Advanced: scopes” — closed by default, a “Customised”Badge infoin 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 scopeCheckboxes grouped in labelledInsetPanels (Read / Write / Publish and roll back, hint per group, raw scope in mono), andBanner variant="secondary"when scopes the mode needs are missing. Changing the mode re-fills the scopes. The request sends{name, mode}and addsscopesonly when customised (the server prefills from the mode). Primary “Create key” (loading); dismissal disabled while pending. No<form>element around KumoCheckbox(it is a<button>and would submit) — Enter in the name field submits. - Reveal:
Banner variant="alert"“You won’t see this key again”, the key inClipboardText 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
autonothing is above it (bypass is local), so the item isdisabledwith aTooltip“Already in Auto…” (the tooltip hangs on arender={<div />}wrapper because a disabled Kumo menu item takes no pointer events). Never hidden. - Elevate dialog (
ElevateAgentKeyDialog,LayerDialog size="base"), top to bottom: an infoBanner 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 overModePicker only={modesAbove(own)}— cards with nothing to offer are absent, the next mode up is preselected;AutoModeConfirmwhenever the target isauto(also when replacing an Auto elevation); Duration as afieldsetwith segmentedTabspresets 15 min / 1 hour (default) / 4 hours / 1 day / Custom — presets outside the server’sagentModes.elevationbounds are dropped; Custom showsInput type="number"(label, “Between 5 minutes and 1 day.” description, inlineerror“At least …”/“At most …” while typing, “Enter a number” after a submit attempt) next to aSelectMinutes/Hours (hours accept a decimal comma, rounded to whole minutes); ReasonInput(optional, ≤ 200 characters, “Shown in the audit log.”). Right above the action, theInsetPanelsummary: “Now [own] → Elevated [target]”, thenClockCountdown+ 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 asBanner 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 elevatedModeBadge, then an outlineBadgewithClockCountdown“42 min left” (short units, nearest minute, at least 1) whoseTooltipreads “Elevated from Edits until 14:30 by you” plus the reason in quotes; under themText 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”).alerttone when any key is elevated to Auto,defaultotherwise. Keys inside the notice window (agentModes.elevation.noticeMinutes, 10) add a warningBadgewithClockCountdown“Ends in 8 min” to their line, and the banner addsBanner.Action“Extend 1 hour” (several: “Extend all 1 hour”) before “End now” — the sameuseExtendElevationas the bell (§2n). The actions wrap under the text on phones. In the key’s Mode cell the timer badge turnswarninginside 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 dotBadge warning“Asks for Auto” under its mode, aLinkto that site’s Approvals with aTooltip(“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 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 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 showsNodeTextChanges: short fields as “old → new” (<del>/<ins>), then the body — or each rich-text section, with other sections as Added/Removed/Changed badges — as aBodyDiff(@cloudhal/ui).BodyDiffworks on the markdown source: a success/dangerBadgepair with the words added and removed; added blocks onbg-kumo-success-tint(<ins>), removed ones struck through onbg-kumo-danger-tint(<del>), edited ones onbg-kumo-recessedwith 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 sameBodyDifffor 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) withdivide-yrows (no nested cards):ToolCell(localised label + raw name),ActorCell(ActorBadge+ name), Deny (secondary) / Approve (primary) withloading, the server’s prompt as plain text (it can quote agent-written titles; never HTML — CI checks there is nodangerouslySetInnerHTML), requested / expires asRelativeTimewith a warningBadgeunder an hour, andCollapsiblearguments inCodeHighlighted. Ordered by expiry. Empty:SectionEmpty“Nothing is waiting for you”. -
Approve on
publish,rollback,delete_page,delete_entryopensApproveRequestDialog(LayerDialog): the prompt in anInsetPanel; 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 infoBanner“Single use, for the site as it is now” (void if anything changes before the agent calls again).request_elevationhas 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 likeToolCell: a 16 pxClockCountdownicon with “Elevation request” and the rawrequest_elevationin 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’smode(requestOwnMode); - a localised sentence, “Desk asks to work in Auto mode for 1 hour.”;
- the agent’s reason as
AgentReason(recessedInsetPanel) 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.”; AutoModeConfirmwhen the mode isauto;- Duration: “Requested: 1 hour. Shorter is fine, longer isn’t possible.”, then segmented
Tabswith 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
InsetPanelsummary “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, rendersAutoModeConfirmwith its guard, never sends more than requested, and that the page routes elevation approvals to it first. - the change as two full
-
History: flush
SectionCard(ClockCounterClockwise icon) with aTable(orSectionEmpty): request (tool + truncated prompt in aTooltip), status dotBadge(approved success, denied error, used neutral, expired warning), agent, requested, decided (time + “by you” or the deciding actor id). Elevation requests readClockCountdown“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 andBanner.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 (
NotificationBellblock,@cloudhal/ui): a ghost squareButton size="sm"with a 16 pxBellin the top bar, between the surface’s actions and the language select, on every surface and at every width. A countBadge 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 countBadge), a scrolling list ofNotificationItems divided bydivide-kumo-hairline(max 32 rem / 70 svh), and a footer row with a ghost “Notification settings” that opens Account › Notifications. Empty:SectionEmptywithBellSimple“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 (warningClockCountdownfor an ending elevation,infoRobotfor a request), a bold title with optional meta on the right, the body, then one row ofsize="sm"actions that wraps on phones.- Elevation ending soon:
ModeBadgeof the elevated mode + warningBadgeClockCountdown“8 min left” (recomputed fromuntil), then “Desk’s Auto mode ends in 8 min (14:30), then back to Edits.” Actions: aButtonGroup— secondary “Extend 1 hour” and a ▾DropdownMenuwith 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”; ghostLinkButton“Open Agents”. Extend and End disable each other while pending. - Agent asks: “Planner asks for Edits · 30 min” with
RelativeTimeas meta, the reason inAgentReason(recessedInsetPanel, plain text: agent-controlled), and a secondaryLinkButton“Review” to that site’s Approvals.
- Elevation ending soon:
- 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 isuseEndElevation. Both toast, update the key list and invalidateagent-keys,notificationsandapprovals; deciding an approval invalidatesnotificationstoo. 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 inlocalStorage(cloudhal.studio.notifications.shown, last 200, every access intry/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 inlocalStorage(cloudhal.studio.notifications.browser). For each newly announced id, when the person opted in, permission isgrantedanddocument.visibilityState !== "visible", Studio raisesnew 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 (card1.91:1, the Open Graph crop;thumb16:9, 3.5 rem) with four states,data-image-statusempty / loading (Loader) / loaded / error (ImageBroken+ label), acaptionunder the frame (the size) that anerrorHintreplaces while broken, andonLoadwith the natural size.SearchResultPreview— a neutral sketch of a search result built from tokens (globe tile, site name,host › pathbreadcrumb, title intext-kumo-link, description; a muted italic placeholder when nothing is set; optional badgefooter).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
srcstraight toImagePreview; it goes throughpreviewImageUrl(thevalidateImageSrcrule:https://…or a site path starting with/, never//,http:,data:or other schemes — stricter than the renderer’ssafeSrc), which resolves site paths against the primary domain (primaryHostname). CI checks both. - Article › Cover image (
CoverImageCard, aSectionCardwith anImageicon after the Article card): the description says the image comes from the media library. Body isImageRefField(§2u), a@containergrid — 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 aCollapsible“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 (editImageRefdropsassetId). 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 removescoverImagetoo (the contract has no emptysrc). Dimensions are not form fields: a library file brings its own; when a newly typed address loads, its naturalwidth/heightare 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): segmentedTabs size="sm"in the header — “Search result” (SearchResultPreviewin an outlineInsetPanel: site name,host › collection › slug, title, summary cut bytruncateForSearch) 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 adlofSettingRowchecks with dotBadges: 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”, countern / 60,maxLength300), “Meta description”InputArea(guidance “Aim for 50–160”, countern / 160,maxLength500), “Hide from search engines” KumoSwitchwith a hint (adds noindex; the page stays reachable) — on, it addsBanner variant="alert"with anEyeSlashicon explaining what noindex does (CI checks the switch never appears without it);Collapsible“Advanced” (aCustominfoBadgein the trigger when set, open on load when set) with “Canonical URL” (monoInput, absolutehttp(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 sameSearchResultPreviewin anInsetPanel, showing the canonical’s host and path when valid and a warningBadge“Hidden from search” while noindex is on. Empty strings andnoindex: falseare removed, an emptyseois dropped (applySeo/cleanSeo); limits come from the contract (CI compares them). - Counters (
CharacterCount):Text-sizedn / recommendedat the right end of the field’s description,tabular-nums,text-kumo-warningpast the recommended length (the hard limit is the input’smaxLength). - Saving: every save path of both editors (Save, lifecycle buttons, Publish before the
ApprovalDialogopens) runsvalid()first; invalid fields show their errors, an error toast “Fix the highlighted fields before saving.” appears and the card scrolls into view. CI checks nosave.mutateis unguarded. - Lists: Pages shows an outline
BadgewithEyeSlash“noindex” next to the title of hidden pages (Tooltipexplains it). Articles lead the title cell with athumbImagePreviewof 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>):PageLayouttabs, one per taxonomy (categories, tags, then custom keys); header actions, left to right: a secondary squareGearSixbutton (Tooltipandaria-label“Tag settings”) opening the settings dialog for the current tab, secondaryFolderSimplePlus“New taxonomy”, primary “New category”/“New tag”. Toolbar: search (names, synonyms, slugs) and a segmentedTabsfilter All / Public / Internal / Proposed (warning countBadge) / Retired — it scrolls inside the toolbar on phones. While proposals wait, an alertBanner“Proposals waiting for review: n” withBanner.Action“Review”.LayerCard p-0+Table: Name (TermNameCell: bold name in the default locale linked to the editor, state badges — warningHourglass“Proposed”, outlineEyeSlash“Internal”, secondary “Retired”/“Merged into X” — the archive path in mono; hierarchical taxonomies render a tree, depth-first inpositionorder, each level indented with anArrowElbowDownRight; 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” withRelativeTimebelow — 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 aTooltip; categories count their sub-tree, as archives do), Visibility (info “Public” / outlineEyeSlash“Internal”), and a sticky rowDropdownMenu— 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” withActorCell(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: bareEmptyper filter; a site without taxonomies getsEmptywithcommandLinecloudhal migrate tags-to-terms --dry-runand “Preview the move”. Aside: “Vocabulary health” (TaxonomyHealthCard, fromtaxonomyReportin@cloudhal/core-ts— the one report: MCPtaxonomy_reportonly 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, asSettingRows, with a ghostGearSix“Edit” in its header. A site without taxonomies offers “New taxonomy” next to “Preview the move” in itsEmpty. The sidebar item carries the proposal count (Sidebar.MenuBadge). - Term editor (
routes/TermEditorPage.tsx, editor template,EditorLayoutwith the §2f preview column): header title = name, description = taxonomyBadge, state badges and the archive path; actions =PreviewToggle(its tooltip says the preview shows unsaved changes), Approve (proposals), a secondary ⋯DropdownMenuwith 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,archiveHeadtitle and description) is swapped into the samesandbox=""frame throughsrcdoc, 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: theIndexBadge(indexed / “Not indexed: 2 of 3” / noindex / empty / not public yet …, the renderer’sarchiveStatetable) and an outlineHTTP 200|404|410badge with aTooltip; it renders the page even for a proposed, internal or empty term. “Open in new tab” opens the saved version (GET …/preview). A segmentedTabslocale switch appears above the cards on multi-locale sites (an errorBadgemarks a locale with errors after a save attempt). Main: “Names and synonyms” (Inputname, required in the default locale; synonyms asTagInput,maxValues50), “Archive introduction” (markdownInputArea, 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, noindexSwitch+ itsBanner, canonical under “Advanced”, search preview of the archive path). Aside: “Indexing” (theIndexBadgeand a sentence: “/tag/js has 2 of the 3 published articles it needs…”), “Placement” (parent + “Move…”,positionnumber input), “Slug history” (read-onlyInsetPanelof earlier archive paths with the redirect each left;SectionEmptywhen 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 secondaryBanner. - 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: slugInputGroupwith the archive base as addon, validated like the store (taken, reserved by a history → aCheckbox“Reclaim it”), earlier slugs listed; merge: a tree-awareTermComboboxof 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 andStatus(“These articles change/lose it in the same operation. One change can touch up to 400 articles.”), “Redirects n” asfrom → tomono rows with an outline “301 permanent”Badgeand “Pages and feeds below the old address follow too.”, existing redirects that follow along. Consequences asBanners: error “Too large for one change” with the count and the 400 limit (too_large; the action stays disabled — also when a 409too_largecomes 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 mutedCameraline “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 inlib/taxonomy-settings.tsmirrors core-ts#checkTaxonomy/updateTaxonomy): four sections separated by hairlines, each aText boldheading with a muted line. Name: keyInput(mono; lower-cased as typed; reserved words of the filter grammar —RESERVED_FIELDSfrom@cloudhal/core-ts/filter— and taken keys refused while typing; disabled with “Keys never change” when editing), then singular/pluralInputs 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 errorBannerwhile terms still have parents and it is turned off);Radio.Group appearance="card"“Who adds new terms” — People only (successShieldCheck“Safest”), Agents propose (infoStar“Recommended”), Agents add freely (warningWarning“Use with care”) — each card saying exactly what agents may do (an errorBannerwhile proposals wait and “People only” is chosen). On articles:SwitchRequired, numberInput“At most per article” (empty: 50),Switch“Applies to every collection”; off shows anInsetPanelofCheckboxes, one per collection in mono (/posts). Archive pages:Switchpublic archives, the address as anInputGroupwith 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 → /topicswith an outline “301 permanent” badge in anInsetPanel, “Pages and feeds below…”, re-pointed redirects), turning archives off is an alertBanner“Archive pages will answer 404”, turning them on a defaultBanner—,SwitchRSS feed (disabled without archives), numberInput“Articles needed for search engines”. TheCameraone-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 (InputGroupwith the base path), parent for categories (TermComboboxwith “Top level”), an “Internal”Switchwith its hint, and an alertBanner 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-selectCombobox(TriggerMultipleWithInput,Combobox.Chip) whose options are the tree — children indented with their path (“in News”), proposals marked — matched on names, synonyms and slugs; at mostmaxPerEntry(other options disabled, the description says so); one choice is the primary automatically, several show aSelect“Primary category” (and a star on the primary chip). Required taxonomies say “Needed to send it to review or publish it”; the editor’svalid(status)refuses review/publish without a primary (field error + toast “Choose a category first”, the card scrolls into view). Flat taxonomies get the tagAutocomplete(filter={null}, suggestions fromtagSuggestions): 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 isPlus“Create “x”” with a warning line “Looks like LLM…” when near-duplicates exist (they are listed as options too). Chosen tags areTermChips (@cloudhal/ui) with a remove button; proposals are quieter with anHourglassand aTooltip. The article savesterms(canonical: no empty assignment) andtags: []; legacytagson a migrated site show as a secondaryBanner“… become tags when you save”. A site without taxonomies keeps the legacyTagInputontagsand shows the migrationBanner. - Migration (
TagsMigrationBanner+TagsMigrationDialog): an infoBanner“Move your tags to categories and tags” withBanner.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 anInsetPanelsummary (tags found, articles rewritten, tags new/reused, taxonomies created), skipped articles as an alertBanner, 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,
+nwith the rest in aTooltip; a warningBadge“No category” for review/published articles of a required taxonomy) and a Tags column (two outlineBadges and+n). Facets in the toolbar: aToolbarofSelect render={<Toolbar.Button />}— category (tree, indented) and tag (by usage) — plus a ghost “Clear filters”; they become?category=&tag=and the API filtercategory:<slug>>*+tag:<slug>(sub-categories included,+sent as%2B). Filtered-empty is its ownEmpty. - Redirects (site › Settings › Redirects, list template,
routes/RedirectsPage.tsx): toolbar search + a sourceSelect;Tablecolumns Old address (mono,/…for prefix redirects), Sends to (mono with an arrow; an outline “External”Badgewith the host in aTooltip), Type (301/302badge with aTooltip, “and below” for prefix), Source (slug change infoTextAa, merge secondaryArrowsMerge, retired warningArchive, manual outlineUser; the source node linked to its editor; the note), Updated, row menu (Edit, Delete →ConfirmDialogthat says the old address answers 404 after the next publish, and that an automatic one breaks links).RedirectDialog(LayerDialog): old address in anInputGroupwith the primary host (normalised: a pasted URL keeps its path; read-only when editing), target, “Applies to” and “Kind” as cardRadios (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: alertBanner“Visitors will leave for help.example.org”, the host in anInsetPanel, 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”), fromGET …/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:
UsersThree24 px, title “Team”, primary “Invite people” (useGate("members.invite"): disabled with the reason for Authors and Contributors),PageLayouttabs Members | Activity (sub-views of one page). - Members table (
LayerCard p-0+Table,ListToolbarsearch,Paginationover 25): Member — theAvatarblock (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, aTooltipwith what the role may do: Owner/Administratorinfo, Editor/Authorsecondary, Contributoroutline); Joined —RelativeTime; sticky row menu. Owner first, then by rank and name. Belowsmthe 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, nomembers:manage) and stays in place disabled with a tooltip naming why (GatedItem, the tooltip on arender={<div />}wrapper). Someone withoutmembers:managegets one disabled ⋯ trigger with the reason instead of a menu of no’s. - Change role (
ChangeRoleDialog,LayerDialog): the member in anInsetPanel(identity + currentRoleBadge), aSelectof every role but Owner (ownership moves only by transfer) whose description is the chosen role’s summary, anInsetPanel“Now [Editor] → [Author] · Applies at once”, and for a demotion an alertBanner“Their agent keys narrow too”. A 403rankstays in the dialog in words (members.rank.<problem>). When the server returnskeysNarrowed, the dialog turns into its result before closing: an infoBanner“Agent keys narrowed: n” and one row per key (name, new mode asModeBadges when it changed, the scopes it works with now asScopeBadges, “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 fromGET /api/agent-keys?all=1when the caller hasagents: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: alertBanner“You won’t see this link again”,ClipboardText size="lg", expiry in anInsetPanel(“Expires in 1 hour (absolute)”). Primary “I’ve copied the link”; closing drops it. - Transfer ownership (
TransferOwnershipDialog,LayerDialog.Alert): strong words — alertBannerwithCrown“You can’t undo this yourself”, “Ada becomes the Owner and you become an Administrator. Only Ada can give ownership back.”; the change asRoleBadges Administrator → Owner; three consequence lines;PasswordField“Your password” (re-entry;wrong_passwordlands on the field,not_administratorin aBanner); destructive primary “Transfer ownership”. - Pending invites: flush
SectionCard(EnvelopeSimple, countBadge) shown to people withmembers:invite;Tableof email (with “Link”/“Sent by email”, a warningBadge“Not completed” for an accepted invite without an account), role, expiry (“in 7 days”, rounded byexpiresIn, 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:SectionEmptywith 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 undertools.member*), without the skill and snapshot columns. Withoutaudit:reada secondaryBannersays who sees it. - Invite dialog (
InviteDialog,LayerDialog, two steps in the same parts):- Who and in which role:
Inputemail (Enter submits; errors after a submit attempt), aSelectofinvitableRolesfrom/api/me(an Editor sees Author and Contributor only), the expirySelectof 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 compactTableof the offered roles × Articles / Publish / Structure / Team, cells as dotBadges (green all, amber limited, grey none) with aTooltipeach, worked out frominstance.roles.abilities— and, when the server can mail (invites.deliveryincludesemail), cardRadios “Copy a link” / “Send by email”. API refusals (rank,invalid_expiry,delivery_unavailable,already_member,too_many_invites,rate_limited) are words in an errorBanner. - 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", anInsetPanelwith 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 successBanner“Sent to …”.
- Who and in which role:
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}”, anInsetPanelwith theRoleBadge, the role’s summary and the expiry; the email as a read-onlyInput(“The invite is for this address”). A new account adds Name andNewPasswordFields(new + repeat, a KumoMeterwith “Strength: fair” and one line of advice, tones by strength); an existing one (a removed person invited back) gets a secondaryBannerand 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 → alertBanner“You’re signed in as …” withBanner.Action“Sign out” and the primary disabled with the reason;already_member→Bannerwith sign in;weak_password,wrong_password,name_requiredland 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, templaterecover, RFC-0011): the sign-in tab shows a small “Forgot password?”Linkunder the password (always: the sign-in screen cannot read/api/instance). One emailInputand “Email me a link”; the answer is the same for every address — a defaultBannerEnvelopeSimple“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 (404email_unavailable) shows a secondaryBanner“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, thecanability 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 (minimumRoleover/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
Buttonisdisabledwithtitle={gate.reason}(Kumo wraps a disabled titled button in aTooltip; CI checks everydisabled={!x.allowed}carries its reason); a gated menu item stays in place, disabled, itsTooltipon arender={<div />}wrapper; a screen the person may read but not change getsViewOnlyBanner(secondary,LockSimple, “You can view this” + the reason) and its controls go insideReadOnly(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 showsViewOnlyBannerand a disabled fieldset, and the articles list disables Delete). - Publishing (
usePublishMode,usePublishSiteincomponents/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 useSnapshotTimeline’spublishDisabledReason/rollbackDisabledReason. - Approvals:
canDecide === falsedisables 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 403cannot_decidetoasts the same words. A person’s own request (causerole) shows a warningBadgeHourglass“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”); 409state_changedsays the site moved on. People’s names carry theirRoleBadge(fromGET /api/members) here and in Activity. - Agent keys:
useAgentModeRulescarries the person’s cap —keyModesstops at it (a Contributor’s picker shows Plan, Manual, Edits andRoleCapHint“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 whoseeffectiveScopesare narrower than its stored scopes shows the effective ones with a warningBadge“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:reada secondaryBanner“Your activity — Editors and above see everyone’s”. - Notifications (§2n):
member-joined(successUserPlus, “Ada joined”, the role badge, “Open Team”) androle-changed(infoShieldStar, “Your role is now Author”, Editor → Author badges, who changed it); a newrole-changedrefetches/api/me, the members and the keys, so gating follows at once. - Account: the profile shows the person’s role as a
RoleBadgerow; 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>):PageLayouttabs per menu — “Header” (primary), “Footer”, then custom keys; the header actions arePreviewToggle, secondary “New menu”, ghost “Discard” while dirty and primary “Save menu” (disabled until dirty). Switching tabs with unsaved changes asks first (ConfirmDialog).EditorLayoutwith the preview column: main — banners (changed elsewhere, save problem, “n items are hidden on your site”), then the itemsSectionCard(flush,TreeStructureicon, countBadge“12 / 500” in the header that turnswarningfrom 80 % of a limit, secondary small “Add item”); aside — “Settings” (Select“Levels” 1–6 with an inline error when items already sit deeper, thenSettingRows for items, deepest level and fullest level with the same badges),DetailsCard(key, id, revision, updated) andDangerZoneCard“Delete this menu” (ConfirmDialogthat says the theme’s derived default returns with the next publish and earlier snapshots keep the menu). MenuTree(@cloudhal/uiblock; Kumo has no tree): the WAI-ARIA tree pattern on flat rows —role="tree"witharia-describedbykeyboard help, rowsrole="treeitem"witharia-level,aria-setsize,aria-posinset,aria-expandedon parents, a name from the label and a description from the chips; one tab stop (rovingtabIndex). 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) beyondmaxDepth, pastperLevelor into the item’s own subtree. Rows: aDotsSixVerticalhandle, 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 reportsMenuTreeMoves (applyMenuOps“move” semantics);readOnlydrops 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 aTooltipon arender={<div />}wrapper explaining the limit. Removing an item with children confirms first (ConfirmDialogwith the count); removing a leaf only changes the draft. - Target chips (
TargetChip, outlineBadgewith 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,secondaryfor “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 aTooltip. 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’smenuLookup+resolveMenu), so Studio, MCP and the site agree. - Item dialog (
MenuItemDialog,LayerDialog lg): the target picker — a groupedCombobox(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 statusBadge(Draft, In review, Archived, Proposed, Internal). A target that will stay hidden adds a secondaryBanner(“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), anInsetPanel“Visitors leave for status.example.com.” and theCheckbox“Open in a new tab”. Groups explain themselves in anInsetPanel. Then one labelInputper 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 anInsetPanel— 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 alertBannerwhen added or edited items stay hidden, and theCameraline “One change, one snapshot. It goes live with the next publish.” Before it opens,draftProblemcatches what the store would refuse (an empty group, a missing label, too deep, too many) as an errorBannerand focuses the item. Confirming sends onePUTwith the menu’srev. A 409 keeps the edits and shows an alertBannerwith “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 (
NavigationPreviewblock in aSectionCardwith segmented Desktop/MobileTabsand 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 languageSelectappears on multi-locale sites. Custom keys get a secondaryBanner: 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 anInsetPanel(hidden ones with their badge), aCheckbox“Include top-level categories (n)” re-runs it, and “Create menu” is the only write. New menu (CreateMenuDialog): cardRadios for the well-known places the site lacks plus “Custom key” (a slugInput, immutable later), and “Levels”. - Elsewhere: the pages list marks linked pages with an outline
TreeStructureBadge“In menu” (the menus in aTooltip); page, article and term editors carry “In menus” (UsedInMenusCard: “Header (primary) · About” linking to?menu=primary&item=…, orSectionEmpty“Not in any menu”); every page and article delete (DeleteResource notice) shows an alertBannernaming 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 secondaryBanner, no handles, no Save, no Add, no row actions (useStructureWritereads/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):Images24 px, primary “Upload” (useGate("assets.upload"), disabled with the reason; it opens the system chooser throughuseFileChooser).ListToolbarsearch (titles, alt text, captions, credits, file names; 250 ms pause) and, on the right, a ghost “Clear filters” while filtered, aToolbarofSelect 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 segmentedTabsGrid | List (icon + label, the label hidden on phones; remembered per browser). The body is wrapped inMediaDropZone variant="overlay": dropping files anywhere on the library opens the upload dialog with them. Grid:MediaGridofMediaTiles 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), stickyRowMenu. Loading:SkeletonLines (grid) orTableSkeleton; empty and filtered-empty are bareEmptys (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; oneUploadPanel): aMediaDropZone(“Drop files here”, the limits, “Choose files”; paste works while it has focus), then anInsetPanel“Files” with “n of m uploaded” and one row per file —ImagePreview thumbof the person’s own file (an object URL) orMediaKindFrame, name, typeBadge, size and dimensions, and the state in words: “Checking the file…”, “Ready to upload”, aMeterwith 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 aCheckbox“Decorative — it adds nothing a reader would miss”; a photo with GPS adds an alertBanner“This photo contains the place it was taken” and theCheckbox“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 streamedPUTper 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 = kindBadge,UsageBadge, state badges and the public path in mono; actions = secondaryLinkButton“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 getsBanner.Action“Allow it again”), secondary “This file is retired”,ViewOnlyBannerwhen someone else uploaded it and the person lacksassets.manage(the server’seditable; the form sits inReadOnly). Main: “Preview” (AssetFrame size="card",fit="contain"), “Description and credit” (a segmented localeTabson multi-locale sites; Title; for images “Alt text”InputAreawith aCharacterCountagainst 125 and a “Decorative image”Switchwith its hint — alt is required in the default locale unless decorative, checked byvalid()before every save like the server’s rule; Caption; Credit and License side by side in a container grid — the licenseSelectoffers common SPDX licences, “Another SPDX license…” with an identifierInput, and “Link to a license…” with address and name), “Address” (InputGroupwith/media/<16 hex>/addon and the extension as suffix; old links redirect), “Used in” (flushTableof the nodes with kindBadge, title linked to its editor, path in mono, and the slot — cover, share image, section, archive image, text — orSectionEmpty“Not used anywhere”). Aside: “File” (dlofSettingRows: type and MIME, size, dimensions, SHA-256 and the as-uploaded SHA-256 asInlineCopyTextin 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) andDangerZoneCard 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.” →DeleteResourcenaming the file) and Take down (Owner only,TakedownDialog). Every disabled action carries its reason. - Take down (
TakedownDialog,LayerDialog.Alert): alertBanner“You can’t undo this”, anInsetPanel“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 aCheckbox“This is a legal requirement (answer 451)”; destructive primary “Take down”. - Picker (
AssetPickerDialog,LayerDialog lg): segmentedTabsLibrary | Upload. Library: a searchInputand aMediaGridof active images as toggleMediaTiles (brand ring and check when chosen; the slot’s current file preselected), “Load more”,SectionEmptywith 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).ImageRefFieldis the image slot everywhere — the article cover and the term image (insideCoverImageCard, §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’sasset.*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,useChartPaletteand the range (analytics-range.ts) and formatting (analytics-format.ts) helpers live in the main entry;@cloudhal/uideclaressideEffects(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 withlib/analytics-search.tsand 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(inStatTileGroup, adl): label (Text smsecondary) with an optional infoButton+Tooltip(hint: “Daily unique visitors, summed”), the value as a 20 px heading intabular-nums(“—” plusemptyLabelfor screen readers when unknown), aDeltaBadgeagainst the previous period —success+TrendUpup,warning+TrendDowndown,secondary+Minuswithin ±2 % (higherIsBetter={false}flips the tones), its words (describeDelta: “+12% vs previous 30 days”) in aTooltipand for screen readers — and an optionalSparkline(token stroke, decorative). Loading =SkeletonLinein the value’s place. Each tile is an outlineInsetPanel. The group is a container-query grid: two columns, three from@lgfor three tiles, four from@2xlfor four;density="compact"stays at two (side columns, Readers card).AnalyticsRangePicker(page headeractions): segmentedTabs size="sm"Today · 7 days · 30 days · 90 days · 12 months; a secondary “Custom…”Button(CalendarBlank) opening aPopoverwith Kumo’sDatePicker(mode="range", two months,timeZone="UTC", the range in force preselected with its last month on the right,today/endMonth/disabledso 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 throughcalendarLocaleProps(§2v Kumo locale), a “Days are in UTC” line withGlobeHemisphereWest, 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 passesonCompareChange, a small “Compare”Switch. Controlled through{preset}or{from, to}(UTC days,toexclusive — 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) isReact.lazyfrom Kumo’s deepcomponents/date-pickerentry, preloaded on hover or focus of “Custom…”, withSkeletonLines until it arrives. Kumo’sDateRangePickeris deprecated and hard-codes English: never use it. A custom range shows on the button (“Sep 1 – 30, 2026”,aria-pressed).resolveAnalyticsRangeturns a range into UTC days and the bucket size (hourly ≤ 2 days, daily otherwise);previousAnalyticsRangegives the comparison period. The two months wrap onto two rows when the popover is narrower than both (react-day-picker’s.rdp-monthswraps), so it fits a phone; the block also takestrailingcontrols (the screen’s refresh button) that wrap with it.AnalyticsTimeseries(the “Traffic” card, 280 px; the Readers card, 120 pxtype="bar"compact): KumoTimeseriesChart—gradientlines, series colours fromuseChartPalette(brand, purple, teal, orange tokens),yAxisMinInterval={1},Intlticks (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,isDarkModefromdata-mode,ariaDescriptionwith 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 anaria-hiddenwrapper and arole="status"line speakslabels.loadinginstead. 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, focusableTableof every bucket — the chart’s text alternative. Overlapping axis labels are hidden.BreakdownTable(flushSectionCards: Top content, Sources › Channels | Referrers | Campaigns, the countries list): item (16 px channel icon, title as aLinkto its editor or plain text — referrer hosts are never linked, a mono detail line such as the path, abadgeslot for the kind or “No longer exists”), the metric and an optional second metric intabular-nums, and the share as a KumoMeterwhose label is visually hidden (the row names it) with “12%” beside it.limitfolds the rest, server-markedotherrows and whatever the period’stotalleaves 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’sempty(SectionEmpty).BreakdownMeters: the same rows as a list of labelledMeters (“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”SectionCardin the aside): KumoChoroplethMap(aspectRatio1.6,showLegend; the legend’s ends arelabels.high/labels.low, merged into ECharts’visualMap.textbyuseChartFrameafter 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 pulsingbg-kumo-recessedplaceholder until they arrive), joined onISO_A2_EH(Natural Earth’sISO_A2is-99for some countries). NevertooltipFormatter— 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 useslog1pso 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-recessedvanishes on a dark card). Under the map, the top eight countries as a compactBreakdownTable(XX“Unknown”,T1“Tor”, the rest “Other countries”) — the map’s text alternative; the map itself is arole="img"with a description.AnalyticsQuotaBanner: the Free-plan warning, an alertBanner(Warningfill) with the explanation, a warning-tonedMeter“Requests yesterday” (“62,400 of 100,000 a day”) and the screen’sBanner.Actions (“Lower vitals sampling”, “Analytics settings”), wrapping under the text on phones. Shown whenshouldWarnFreePlan(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 alertBanner(Robotfill) 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 aCollapsible“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’sBanner.Action(“Show that day”) wraps under the text on phones. Words are props; the screen words each reason from itscode.ReadersSummary: the body of the editors’ “Readers” card (§10.2) — compactStatTiles (Page views, Visits), the 120 px daily bar chart, the top three channels asMeters, the slug-history line (“Old addresses still bring readers: 42 this month”,ArrowBendDownRight) and a ghostLinkButton“Open in analytics” (/analytics?filter=node:<id>).emptyreplaces the body with the card’s own state: never published, no readers yet, or — for Contributors — the reason (§2s).- Colour, theme and motion.
useChartPaletteresolves Kumo’s tokens to hex through the browser (a probe element and a 1×1 canvas) and again whendata-modeor the OS scheme changes; without a canvas it falls back to Kumo’sChartPalette, the same family. Charts are destroyed and redrawn in the new theme. Withprefers-reduced-motionthe 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, templatedashboard, lazy route/sites/:id/analytics, RFC-0006 §10.1; cards incomponents/AnalyticsSections.tsx, words and shared hooks incomponents/Analytics.tsx, URL and vitals helpers inlib/analytics.ts): sidebar Overview › Analytics (ChartLine) after Overview and “Go to analytics” in the palette.PageLayout(crumbsSiteCrumbs› Analytics,ChartLine24 px, “Analytics”, “Visits to your site, counted without cookies or personal data.”, no page tabs);actions=AnalyticsRangePickerwith the compareSwitch(on by default) and theRefreshButtonpassed as itstrailingcontrol, 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=0turns deltas and the previous-period line off,&filter=node:<id>|channel:…|country:XX|device:…filters every report that takes one (a secondaryBannerwithFunnelnames 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 aSectionEmpty“This could not be loaded” with “Try again”; the summary failing showsAnalyticsErrorBannerabove the cards (“Cloudflare’s analytics service is busy” for a retryable 503). Reads wait for/api/me(useAnalyticsAccess), so a Contributor gets only the secondaryLockSimpleBanner“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.analyticsoff orstore: "none"), can’t read yet (secondary,reader: "none"), the Free-plan warning (AnalyticsQuotaBannerwith “Analytics settings” whenshouldWarnFreePlan(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 iswatch— title “Unusual spike in page views” — orlikely— “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
Emptyin 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” withcommandLinecloudhal 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 openssettings#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;AnalyticsTimeseries280 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,BreakdownTablelimit 10 with visits as the second metric, kind outlineBadge, titles linked to the article, page or term editor, “No longer exists” muted; “Show all” →LayerDialog lgwith 100 rows,Paginationof 20 andshowOther={false}), “Sources” (flush; headerTabsChannels | Referrers | Campaigns, campaigns with their own Campaign | Source | MediumTabsrow; by visits; channel icons; referrer hosts plain text with a note), “Errors and redirects” (flush; headerTabsBroken links | Redirects; statusBadge404 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 ghostSignpostbutton with a tooltip opensRedirectDialogprefilled with the path (initialFrom), disabled with the reason withoutstructure.write; a note “Includes every error, cached or not…”). - Aside: “Countries” (flush
CountryMapover the Natural Earth shapes insrc/assets/geo, imported with?urland fetched once, never cached out), “Devices” (TabsDevice | Browser | OS +BreakdownMeters, top 5 + other), “Core Web Vitals” (TabsAll | Mobile | Desktop; per metric the short name and full name, a ratingBadge, aMeterof good samples toned success/warning/danger with “p75 2.1 sec · 80% good”; “From 1 in 5 page views…”; sampling off →SectionEmptywith “Analytics settings” for those who may change it), “AI and search crawlers” (flushTable, outlineBadge“Cache misses only”, classBadgeAI 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 returnsnull), the Free-plan requestMeter“198 of 100,000 a day on Workers Free”, the live query budgetMeterwhen budgeted, “Daily totals updated”RelativeTime; anInsetPanelsaying 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 timeTabsaroundReadersSummary) and the site settings Analytics section (§2k, RFC-0006 §10.3) are separate screens.
- Banners, above the cards, at most one of each: not running (secondary,
- Readers card (built,
components/Readers.tsx+lib/readers.ts, keysreaders.*, a lazy namespace loaded with the editors, the overview and the lists, §1):ReadersCardin 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 segmentedTabs size="sm"7 days | 30 days (default) | All time in its header; “All time” asksfrom= the first publish (publishedAt, elsecreatedAt) cut to the API’s two years, and is never compared; 7 and 30 days askcompare=previous, so the tiles carry theirDeltaBadge(“+12% vs the previous 30 days”). Body:ReadersSummaryfrom@cloudhal/ui/charts, lazy (React.lazy+Suspensewith 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 fromredirectHits(“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 ispartial. States, in order: left out whenfeatures.analyticsis missing, off orstore: "none", and when the site’s own settings are off (…/analytics/status, read only by people who may) — nothing renders while/api/meand/api/instanceare unknown, so nothing flickers; Contributors (useGate("analytics.read")) get aSectionEmpty(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 quietSectionEmpty(Hourglass), no request; never published (statusdraft/review withoutpublishedAt; pages countarchivedas 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 →ErrorBannerwith “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: compactStatTileGroup(Page views, Visits, deltas against the previous 7 days), the 120 pxAnalyticsTimeseriesline (compact, lazy like the Readers card) and the three most read nodes asBreakdownMeters(top/node?limit=3; shares of all page views; deleted nodes read “No longer exists”). Three requests (summary?compare,series?interval=day,top/node), allrange=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, keysreaders.listColumn.*): the Articles and Pages lists end with a right-aligned “Views · 30 days” column before the row menu — page views per row intabular-numsfrom onetop/node?range=30d&limit=100query (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 aTooltip(and screen-reader text) “Not among the 100 most read in the last 30 days”.SkeletonLinecells while loading. Same gating as the cards (useAnalyticsCard): the column is left out — with no analytics request — for Contributors, and left out whenfeatures.analyticsis 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.tsxat the root insideI18nProvider,calendarLocalePropsin@cloudhal/ui, onepnpm patch). Kumo 2.14 (the latest release) has nolocaleprop, and itsKumoLocaleProvidertakes onlytranslations.layerDialog.{close, cancel}(plustranslations.label.{optional, moreInformation}from ourpatches/kumo/label-locale.patch:Label’s “(optional)” suffix, shown byField,CheckboxandSelectwithrequired={false}, and its tooltip button’s name; Studio passescommon.optional/common.moreInformation,kumo-upstream-notes.md§8): Studio passescommon.close/common.cancel, so aLayerDialogwithout explicitcloseLabel/dismissLabelspeaks 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
DatePickeronly (react-day-picker 9 inside, every DayPicker prop passes through), never the deprecatedDateRangePicker(it hard-codes weekday initials, “Previous month”, “Next month”, “Edit month and year”, “Timezone: “, “Reset Dates” anden-USday names, and names months in the browser’s locale). SpreadcalendarLocaleProps(locale, labels, timeZone?)into it:formatters(month caption, weekday names) andlabels(grid, weekday, day buttons with today/selected, previous/next, navigation) throughIntlin the Studio locale,weekStartsOnfromIntl.Localeweek info (Sunday for en-US, Monday for tr),lang, andtimeZone. No date-fns locale is shipped; the words that are not dates (CalendarLabels) come fromuseT(). - Charts — numbers, ticks and table cells through
createAnalyticsFormatter(tooltipValueFormat, axis formats);ChoroplethMap’s hard-coded legend “High”/“Low” is replaced withCountryMap’slabels.high/labels.lowby mergingvisualMap.textafter 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 arole="status"line speakslabels.loading. ECharts’ ownlocale(init option) would be reachable by passing a wrappedechartswhoseinitadds 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.patchbelow;label-locale.patchabove;rtl-icons.patch, §1 right-to-left readiness; all folded into the generatedpatches/@cloudflare__kumo@2.14.0.patchbyscripts/kumo-patch.ts, which also makes Kumo’s utilities logical, ADR-0033 A1) —TimeseriesChartwrites its tooltip’s time with a module-levelIntl.DateTimeFormat(undefined)(browser locale, seconds) and has no prop for it.patches/@cloudflare__kumo@2.14.0.patch(patchedDependenciesinpnpm-workspace.yaml) adds an optionaltooltipTimestampFormat?: (timestamp: number) => string(two lines indist/chunks/chart-*.js, one in the typings);AnalyticsTimeseriespasses the bucket in the Studio locale and UTC. A test inpackages/ui/test/analytics.test.tsxfails 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 (orlocale), drop or re-create the patch (pnpm patch @cloudflare/kumo@<version>), and re-check every gap here and indocs/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 defaultvalueFormat(CountryMappasses its own).
- Calendars — Kumo
- Site settings › Analytics (
components/AnalyticsSettings.tsx, model inlib/analytics-settings.ts, words in the lazyanalyticsSettingsnamespace — every locale loads with the route, for the notice tabs; RFC-0006 §10.3, §2k): aSettingsSection#analyticsbetween Domains and Preview links, in the table of contents. Header badge: dotsuccess“On”,neutral“Off”, orneutral“Not running” when the deployment records nothing. Body, top to bottom: a secondaryBannerfor the deployment (features.analyticsfrom/api/instance, else the settings response): “Analytics aren’t running on this deployment” (enabled: falseorstore: "none", with thecloudhal.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 409Banner 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 insideReadOnly—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 aLayerDialog.Alert(alertBannerwithShieldWarning“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 alertBanner 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 ofSelects (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). OutsideReadOnly, so readers can still copy it: “Privacy notice for your site” — segmentedTabsEnglish | Türkçe (the Studio locale first), the §6.5 paragraph in anInsetPanel recessed(withlang) 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 inClipboardText’s one-line mono field, hence the button. Last, adlofSettingRows about the deployment: Recorded in (dotBadge), Read from (warningdot 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 sendsrevand 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 needsanalytics.read(Authors and above; a Contributor gets a secondaryBannerwithLockSimpleand the reason, and nothing is fetched), changing needsanalytics.settings(Administrators and above): switches and Save aredisabledwithtitle={gate.reason}, the selects sit inReadOnly. 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 withoutanalytics:read(made before it existed, or customised without it) shows under its scopes an outlineBadge“No analytics” (Tooltip: why, and that analytics are totals only, no personal data) and a secondaryButton size="xs"“Allow analytics” (ChartLine). One click, no confirmation (it adds one read-only scope and is audited asagent-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 ownanalytics.readfor 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.”):disabledwithtitle={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.
CommentTextis 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): onlyhttps: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:, barewww.,javascript:and markup stay literal text. Rendered as React text nodes — nodangerouslySetInnerHTML(lintno-html-injection;packages/ui/test/comments.test.tsxalso scans the comment blocks).clamp={2}folds breaks into spaces for the queue’s two-line excerpt (full text in aTooltipor the thread). Report details and moderator notes are plain text too (whitespace-pre-line, never linkified). Until@cloudhal/commentsexports itslinkify, the rule lives incomment-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 insideUntrustedTextPanel: a recessedInsetPanelheaded by a warningShieldWarningand “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: pendingwarning, approvedsuccess, hiddensecondary, spamerror, deletedoutline(data-comment-status).CommentLabelBadge/CommentLabelList: risk labelswarning(spam-suspect, personal-data, watch-word, instruction-like) orerror(toxic), questionsinfo, the restoutline; an agent’s label carries a 16 pxRobot; each badge is a toggletip button whoseTooltip(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>, anarticlenamed by the author): optional selectionCheckbox; author (CommentAuthor: name as plain text, outline “Guest”/“Reader” or info “Author”/“Team” badge, “Anonymous” when erased),CommentTime(“5 minutes ago”, absolute in aTooltip), “· Edited”, “· No JavaScript” (proof: "none"); status badge and the screen’s sticky rowDropdownMenuon 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; thenchildren(history, reports, reply form). Not a card: rows sit in a flushSectionCard’sdivide-ylist.CommentThread(the thread view’s main column, in a flushSectionCard): 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 unlessprefers-reduced-motion) to the comment answered, focuses it and tints it for two seconds, unfolding it first if needed; more thancollapseAfter(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 hairlineborder-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 arole="status"line),empty(SectionEmpty),error(an errorBannerwith “Try again”).BulkActionBar(replaces the queue’sListToolbarwhile rows are selected): afieldsetthe toolbar’s height (min-h-9,bg-kumo-tint,ring-kumo-line) so the table does not move — select-allCheckbox(checked, indeterminate or clear), “12 selected” in a polite live region, the actions assize="sm"buttons (the safe oneprimary, the restsecondary), ghost “Clear” (X); Escape clears; wraps on phones. A disabled action keeps its reason: Kumo’stitletooltip on the wrapper and the button’s accessible description (“Only people can approve comments”).CommentFilters(right of the queue’sListToolbar, like the media library’s filters): a KumoToolbarofSelect render={<Toolbar.Button />}—facetsin 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;commentFilterSincegives the API’ssince) — 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:dataonly): adlof rows — names used (outline badges, plain text), email in mono only whencanSeeEmail(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 anInsetPanel(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 labelledInsetPaneldl— 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: thecomment_eventsrows in aCollapsible“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):Flagand “2 of 3 reports open” with an outline badge per reason, then each report — reason badge (errorfor illegal content, elsewarning), open/resolved dot badge, time, the reader’s details as plain text in a recessedInsetPanel, 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 segmentedTabs 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 (opensuccess, closed/offneutral, hiddenwarning), the close line withCalendarBlankand a ghost “Change” (opens the “Close on…”DatePickerthroughcalendarLocaleProps), the counts as badges (approvedsuccess; pendingwarningand reportederroronly when above zero) and a ghostLinkButton“Moderate” to the thread.emptyreplaces the body (comments off for the collection with “Comment settings”; never published “Comments open when this article is published”; Contributors’ reason),loadingshows 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 thecommentsnamespace; every gated query waits for/api/me, so a role that may not see something never asks for it.- Queue
routes/CommentsPage.tsx(templatemoderation:PageLayout,ListToolbar/BulkActionBar,LayerCard p-0ofCommentRows,Empty,Pagination). The rows areCommentRows, not table cells, so the untrusted panel, labels and reports fit at 390 px. PagetabsPending (n) · Reported (n) · Approved · Spam · Hidden;ListToolbarsearch (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 selectionCheckboxand theCommentMenu(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 itsuseGatereason). While rows are selectedBulkActionBarreplaces 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… openModerateDialog(reasonSelectin words, optional note; Delete is aLayerDialog.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 alertBannerwith “Reload”. Pending agent requests show an infoBanner“Comment triage asks to approve 2 comments” → Approvals.Paginationfollows the API’s cursor (hasNextPage, sequential). Aside: “How moderation works” (SettingRows) and “Today” (daily-capMeter, purges waiting with “Purge site cache”, Comment settings, Export comments / with emails for those allowed). Sidebar Content › Comments (ChatsCircle) with the pendingMenuBadgefrom the bell’scomments-pendingdigest (own articles for Authors), only whenfeatures.comments.availableandcomments.read; palette “Go to comments”; overviewCommentsWaitingBanner“4 comments are waiting”. - Thread
routes/CommentThreadPage.tsx(templateeditor,EditorLayoutwithout preview): title = the article, description = state dot badge + “Closes on …”, action “Open article”. Main: a flushSectionCardwithCommentThread(every status, oldest first, cursor “Load more comments”); per comment theCommentMenu(an Author on someone else’s article gets the reason), and details read withGET …/comments/:idwhen they matter (reports, a removal, the comment linked to) or on “History”:StatementOfReasonsfor hidden/spam/deleted,CommentReportswith “Resolve…” (LayerDialog, required resolution),CommentHistory. The staff reply form opens under the comment answered:InputAreawith a live counter to 5,000, a recessedInsetPanelpreview of exactly what the page will show (CommentAuthorwith the badge it will carry — Author on one’s own article, else Team — andCommentText), “Publish reply”. Aside: “Thread” card (segmented modeTabs, mode hint, Readers see, Close date + “Change” →CloseDateDialogwithDatePicker+calendarLocaleProps, end of the day UTC, “Remove close date”; Hidden asks first,HideThreadConfirm) andDetailsCard(article id, path, counts). Thread writes send the thread’srev; a 409 says so and re-reads. - Commenter
routes/CommenterPage.tsx(templateeditor;commenters:dataonly — others getViewOnlyBannerand no request): main = their latest 50 comments asCommentRows (menu: Reply, Open thread); aside = IdentitySectionCardwithCommenterSummary(email only here) and its actions: Ban… (LayerDialog: duration, reason, email and/or networkCheckboxes with what each does, note, “Also hide their approved comments”) or “Lift ban” (bans found withGET …/comment-bans?commentId=), Export their data (downloads the JSON), Erase… (LayerDialog.Alert,Radio appearance="card"Anonymise / Also delete the text, anInsetPanelof what happens that follows the choice, required reason, destructive “Erase”). WithoutCLOUDHAL_COMMENTS_KEY(503) aBannersays why there is no email. - Settings › Comments (
components/CommentSettings.tsx,lib/comment-settings.ts,SettingsSection #comments, words incommentsSettings):SwitchAccept comments; collections asCheckboxes (/postsin 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 protectionRadio appearance="card"(proof of work with a “Default” badge, or Turnstile → site keyInputand the write-only secret:SensitiveInput+ “Save secret”, then “Saved {date}” with ghost Replace / Remove — never read back; without the comments key aBanner; the CSP note); JavaScript-less posting; link limit and daily cap (Input type=number, the cap’s D1 writes in its description, today’sMeter); watch and blocked words (TagInput); Workers AI labels (aLayerDialog.Alertfirst; disabled whilefeatures.comments.aiLabelsis false); retentionSelects (client data at most 30 days); the disclosure snippet with English | TürkçeTabsthat follows the switches, “Copy notice”. One PUT with only the changed fields andrev; a 409 shows “These settings changed somewhere else” + “Load latest” and keeps other edits (rebase). Read withcomments.read, changed withcomments.settings(ReadOnlyotherwise). - Editor card
components/ArticleCommentsCard.tsx(aside after Readers):ArticleCommentsSummarywith 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 recessedInsetPanel, plain text) instead of the server’s English prompt, and tool labels name the seven comment tools. Approve opensApproveCommentsDialog(LayerDialog lg): an infoBanner“Single use, for these comments as they are now”, the comments themselves asCommentRows in an outlineInsetPanel(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); 409state_changedsays the comments changed after the agent asked and the request closed; 409carry_out_failedsays it was approved but could not be carried out. - Notifications (§2n): the bell renders all five
comments-*kinds (ChatsCircleinfo for pending with the oldest’s wait,Flagwarning for reported,PauseCirclewarning for paused,ClockCountdownwarning for purge waiting with “Purge site cache”,Robotinfo 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 throughsplitHref(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 answersscope_not_addableis 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.
- Queue
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).
scheduleLookmaps an entry to one of nine looks:quiet(scheduled by a person or approved:bg-kumo-tint, the kind icon — publish and embargoCalendarCheck, expiryCalendarX, releasePackage, automationRobot),auto(the same +Lightning, theAutoApprovedBadgelanguage),proposed(draft or waiting for approval: a dashedborder-kumo-warningoutline with no fill,Robotfor an agent’s +HandPalm),struck(denied, expired, canceled, skipped, stopped:line-through,text-kumo-inactive; hidden by default views,isHiddenByDefault),danger(conflictWarning, failedWarningCircle, missedClockCounterClockwise, brokenLinkBreak:bg-kumo-danger-tint),running(bg-kumo-info-tint+ a turningCircleNotch, still underprefers-reduced-motion),done(published, ran:bg-kumo-recessed, muted),ghost(a future recurring occurrence: outline ring only,ArrowsClockwise) andpaused(the ghost withPause). The dashed outline (D14) is drawn only insideScheduleItem, asborder border-dashedwithout any shadow, because §4’sringedges cannot be dashed. The design lint’sshadow-bordersandno-ad-hoc-surfacerules apply to screens, not topackages/ui/src/blocks, so the block needs no lint exception — and a screen that draws its own dashed card still fails. ScheduleStatusBadge— every state ofRelease(RFC §5.2),AutomationandScheduleRun(§5.3) and of an approval, as a KumoBadgewith an icon and the state’s word: draft outlinePencilSimpleLine, waiting for approvalwarningHandPalm(+ an outlineClockCountdownbadge withtimeLeft), scheduled outlineCalendarBlank, publishing/runninginfowith a turningCircleNotchinsiderole="status", published/ran/activesuccess, conflict/failed/missed/brokenerror, expired/canceled/skipped/stopped/pausedsecondary, paused by Cloudhal and over budgetwarning(PauseCircle,Gauge), ended/due outline; approvals: approved outlineCheckCircle, automaticwarningLightning, deniederrorProhibit. Areasonmakes it a toggletip button (keyboard too, and in the accessible name).data-subject/data-statefor 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 ownentry.label).variant="row"(the agenda, the “+N more” popover, the phone day list): time, kind icon with its word for screen readers, the title (Linkwhenhref, 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 — andactions(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 KumoTable(role="grid", seven columns fromweekStartsOn, short weekday headers with the long name for screen readers) with one tab stop and a rovingtabindex: arrows by day and week, Home/End to the week’s ends, PageUp/PageDown by month (Shift: year) — leaving the shown weeks asksonMonthChange(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 hasaria-current="date"; past days and weekends arebg-kumo-recessed; entries land on their day in the site zone (23:30 UTC is tomorrow in Istanbul). At mostmaxChips(3) chips, then a “+N more”Popoverwith the day’s rows. Moving: withonMove, chips ofmovableentries (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 callonMove({entry, fromDay, toDay, via})— the screen opensRescheduleDialogwith the old time on the new day, nothing is optimistic. A politerole="status"region speaks the screen’sannouncement. Phones (density="dots", orautoat ≤ 640 px throughmatchMedia): 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”, orlabels.emptyDay).AgendaList(Agenda view, the default on every width) — entries grouped by their day in the site zone under stickyh3day headings (top-12, under the top bar,border-b border-kumo-line;stick="top"inside other scroll containers), rows in adivide-ylist; 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 pastfoldRecurringAfter(3) fold behind “21 more automation runs”. Loading =SkeletonLines with a status line; nothing = a framelessSectionEmpty(CalendarDots) with the screen’semptyContents; cursor paging = a secondary “Load more” (loading). The view switch tells screen readers the agenda is a complete equivalent of the month.CalendarNav— afieldsetnamed bylabels.group: secondary square ‹ › buttons (aria-label) around “Today” (disabled while today is shown), the period (“October 2026”) in a polite live region, agoToslot for the screen’s “Go to date”Popover+DatePicker, and the Agenda | Month segmentedTabswithviewHint. Shortcuts (j/k,g t,a/m,g d) are the screen’s and call the same handlers.ScheduleFilters(theCommentFilterspattern) — a KumoToolbarof facet buttons (kind, state, origin, approval, collection, category or tag, language — whichever the screen passes, with worded options), each opening aDropdownMenuof checkbox items (every facet is multi-valued; the menu stays open while ticking), the count as aBadgeand 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). ADropdownMenucheckbox list is lighter than RFC §15.4’sCombobox multiplein aPopoverand fits the short facets; long ones (terms) may move to aComboboxwith the screen.TimezoneNote—GlobeHemisphereWestand “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 KumoField(label, description, error) around a secondary dateButton(“Publish at, Date: 2 Nov 2026”) that opens aPopoverwith the lazyDatePicker(calendarLocaleProps, days in UTC so a picked day is exactly that day,min/max/today) and anInputGroupwith 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 asHH:mm; an unreadable time showslabels.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”).useTimeOfDayDraftis the shared time-text state.RecurrenceEditor(D3) — the presets asRadio 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 weekdaySelect, the day-of-monthSelect(1–31, last day), and for Custom every N days/weeks/months, weekdayCheckboxes inweekStartsOnorder (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 structuredRecurrenceValue;recurrenceRuleturns it into the RRULE subset (hourly = DAILY with every BYHOUR, never HOURLY or BYSETPOS; UNTIL in floating local time) andrecurrenceProblemslists 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 anInsetPaneland spoken politely.NextRuns— the next five runs from the server (POST …/automations/preview-rule) as an outlineInsetPaneldl: the day and the time in the site zone, a warningClockClockwise“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 byopen: the item as the description, an outlineInsetPanel“Now → New”, theDateTimeField(from today), an alertBannerfor a clash, an infoBanner“The approval is kept” (approvalEffect="kept": a person who may approve moves it) or an alertBanner“Moving voids the approval; it asks again” (voided: an agent’s move or a content change), the screen’s errorBannerfor 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) — aTable.RowunderScheduleRunHeader(same column order): time (site zone, date below), kind (outlinePackage“Release” /ArrowsClockwise“Built-in job” or “Agent task”, the trigger below — Cron, Clock, Request, Timer, Run now), subject (linked), origin (the screen’sActorBadge), state (ScheduleStatusBadgesubjectrun, plus the warningShieldWarning“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” fromIntlunits, 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 “—” withlabels.nonefor 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’sIntl.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):PageLayoutwithCalendarDots24 px, “Schedules”, the tabs Calendar · Automations · History (one route each:/sites/:id/schedules,…/automations,…/history), a secondary squareKeyboardbutton (frommd) and the primary split “New schedule ▾” (Schedule a publish… · New release… · New automation…, the last gated onautomations.manage). Under the tabsSchedulesBanners: 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/scheduleris unhealthy and work is due). - Calendar (
routes/SchedulesPage.tsx, templatecalendar):ListToolbar(search) withScheduleFilters(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…”),CalendarNavwith “Go to date” (Popover+DatePicker),TimezoneNotewith “Show in my time” when the viewer’s zone differs, then aLayerCard p-0withAgendaList(default; the card isoverflow-visibleso the day headings stick under the top bar) orCalendarMonth(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 outlineShieldCheck“Edits mode reduction” instead of “Automatic” (metadata["cloudhal.mode"]). Waiting releases show Deny / Approve (Approve confirms inApprovalDialog); the ⋯ menu has Open release, Move to…, Publish now, Unschedule. Move (useMoveDialog) is one path for the menu, a desktop drag and Alt+arrow:RescheduleDialogprefilled 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-clocklocalTime+ zone, then a 10 s toast with Undo. Shortcuts (only outside fields, dialogs and menus):j/knext/previous month,a/mAgenda/Month,g ttoday,g dgo to date,cschedule a publish,ffocuses the filters,?lists them in a dialog; Studio’s own ⌘K and/are untouched andtalone is not used. - Automations (list): name with the job
Badgeand a state badge when not on, “Runs as” (ActorBadgeCloudhal for built-ins; agent + subtleModeBadgeof the cap), “When” in the server’s words (preview-rule, the rule and zone in aTooltip), next run, last run (ScheduleStatusBadge+RelativeTime), an OnSwitch(pause / resume), the ⋯ menu (Run now — 409 “already running this minute” in words —, Edit, History, Duplicate, Delete throughDeleteResource). Editor (AutomationDialog,LayerDialog lg): name; the job asRadiocards (five built-ins, Agent task); for agent tasks the key note,ModePicker(role-capped) withAutoModeConfirm, “Reads visitor text”Switch, instructions, run window, budget;RecurrenceEditorwith the server’s sentence andNextRuns; anInsetPanel“What this automation can do”; a key minted on create is shown once (ClipboardText); a cap above the key’s mode (409mode_cap_above_key) says to create the automation again. - History (list):
ScheduleRunRows with state, kind and originSelects in the URL, cursor paging, and a ghost “Details” openingScheduleRunDialog— 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) opensScheduleDialog— 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” asDateTimeFields converted to UTC instants on change (clock-change gaps worded), the releases the node is in,gates_unsupported/invalid_availabilityin words, and whenfeatures.schedules.gatesis false a secondary banner with the fields off. Held content getsLockedBanner(“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_zonein words) and Settings › Schedules (ScheduleSettingsSection: scheduler health, the kill switch confirmed with a reason, the daily automation write cap with aMeter); Approvals render release requests as the release (ReleaseRequestDetails, lazily) andlift_embargowith 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 inmarkdown-editor-codec.test.tsx). - Nothing is silently rewritten. A block the editor cannot keep exactly — raw HTML, link definitions, a
```surfacefence (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-onlyInsetPanel variant="recessed"with a secondaryBadge“Markdown”, the hint “Kept exactly as written. Edit it in Markdown mode.” and the source infont-mono. It saves as written. - Layout: a Kumo
Toolbar(size="sm", groups split byborder-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 passesonPickImage) · Undo, Redo. Pressed formats arearia-pressedwithbg-kumo-tint; buttons keep the selection (onMouseDownprevents focus loss). Then segmentedTabsRich text | Markdown. The editing surface isbg-kumo-controlwithring ring-kumo-line(ring-kumo-focuswhile focused),rounded-lg, at least 256 px tall (128 px for page sections), with content styles inui/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, linkstext-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 theInputAreaas fallback. Only the GFM grammar is bundled, not the HTML/CSS/JS onesmarkdown()would add. The rich editor stays mounted, so its undo survives a switch. - Links go through a
Popoverwith an “Address”Input: onlyhttps:,http:,mailto:,tel:,asset:and site-relative addresses (the renderer’ssafeHrefrule); 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: afieldsetcannot turn offcontenteditable. - Slash menu:
/at the start of a block or after a space (never in code) opens alistbox(“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, optionsrounded-mdwith the chosen onebg-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
Toolbarabove it (@tiptap/react/menusBubbleMenu; 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
onPickImagereturns{ src, alt? }; Studio opensAssetPickerDialog(§2u) and writes— 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 dotBadgecounting heading problems, opens aPopoverwith the headings as anavof 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 warningBadge(“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_bodywithmode: "propose") sit above its editor — the article body, or each markdown rich-text section of a page — under aTextheading “Suggestions” with an infoBadge“2 open” (components/BodyProposals.tsx). Each is anInsetPanel: anActorBadge“{name} suggests”,RelativeTime, the agent’s note, then aBodyDiffof what accepting would change now (computed byGET /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:writefor 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 aTooltip, which would wrap the button in its own. A proposal whose blocks changed since it was filed shows a warningBadge“Out of date” (itsTooltipnames 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 withvia: "proposal". A suggestion of several operations lists them as aCheckbox.Group“Changes to accept”, oneCheckbox.Itemper 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, aSkeletonLinemeanwhile) 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 anInsetPanel(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 ghostxs“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 generatedBlockPropsForm; 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.
- 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
- Approvals (§2m): a pending
edit_bodyrequest shows “What approving changes in “{title}”” and the sameBodyDiffunder 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.invokeand the server reaches Workers AI, the selection toolbar ends with aSparklebutton “Ask AI”. It hands the whole top-level blocks the selection touches toAiCommandDialog(MarkdownEditor’sonAskAi,selectionBlocks). - Focus mode (
docs/design/body-focus-mode.md): a ghostArrowsOut“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 (dialognamed after the editor, the rest of Studioinert): amax-w-3xlcolumn, a sticky top bar with the toolbar, the host’sfocusActions(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 withsandbox="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
LayerDialoghas a “What to do”Selectwith the seven commands. “Change tone” adds a toneSelect, “Translate” a languageSelectnamed throughIntl.DisplayNames, “Your own instruction” anInputAreaof 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-recessedbox, and a “Stop” button. - Then a
BodyDiffof 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.
- The
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/settingsreportstransport: "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
ComboboxoverGET …/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 aMeter“7 of 100 used today”.
- “AI in the editor” — a
- 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 theComboboxis disabled; it needs a gateway, and the field says so.
- “AI Gateway” — an id
- An alert
Bannerwarns 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
DropdownMenulists the section kinds, then aDropdownMenu.Separatorand a group “Blocks” with every type allowed in sections, each with its icon (the type names a Phosphor icon;Cubeotherwise) and its title in the UI language. A new section starts from the type’s example. - The card: a
SectionCardtitled with the type’s title and icon, like the other kinds. - The form:
BlockPropsFormfrom@cloudhal/ui, generated from the type’s props schema — no code per type, for Cloudhal’s types and a theme’s alike:- a
stringis anInput(type="url"for links);format: "markdown"orx-widget: "textarea"anInputArea; anenumaSelect, or a horizontalRadio.Groupwithx-widget: "radio"(enum values worded througheditor.blocks.values.*when Studio knows them, else the value); number/integera numberInput;booleanaCheckbox;- a flat object a
fieldsetwith its fields; a list a boldTextlabel, then oneInsetPanelper item headed “Item {n}” with ghost square buttons Move up (ArrowUp), Move down (ArrowDown) and Remove item (Trash), then a secondarysm“Add item” (Plus), disabled atmaxItems; - 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 422invalid_props.
- a
- Unknown types (a theme type this deployment no longer has): the card is titled “Unknown block · {name}”, with an alert
Bannersaying it is kept but not shown on the site, and the props as read-only JSON in afont-monoInputArea. 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-faqin the starter’s CSS); a theme can map a type to its own Astro component intheme.tsblocks. 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
BlockPropsFormover the theme’s schema (§2aa).- Colors are a
Fieldwith anInputGroup: a swatch addon (ring ring-kumo-line, the value or the default) and afont-monoinput of at most 7 characters. - Enums are worded by the schema’s
x-labels, per locale. - Each field’s description comes from the theme.
- Colors are a
- 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 siterevwith 409 and the conflictBanner. - 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(successdot “Live”,warningdot “Connecting…” / “Reconnecting…”) and a secondary “Back to editor”LinkButton. - Body.
EditorLayoutwith 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 aSparkle, a liveLoaderline (“Writing Band, Heading…”), the outcome withCheckCircleand repairs in mono, and errors withWarningCircle. - 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
SectionEmptywith “Open settings”. When the deployment cannot reach Workers AI: aSectionEmptysaying how to connect it.
- Transcript:
- Main:
SectionCard“Activity” holdingBuildActivity(@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) dotBadgeand 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 isaria-live="polite". With nothing yet it shows aSectionEmpty. - Aside:
SectionCard“Connect an agent”, with the MCP endpoint and the page id asInlineCopyTextand three numbered steps (get_catalog,patch_draft, fixVALIDATION_FAILED).
- Main, first:
- 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 bydata-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.
- The server sends the whole preview document with each change (
- 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 picklays a transparent button over the frame: the part under the pointer gets aring-kumo-brandoutline with a light brand tint, a click picks it, and Escape stops.- The picked part keeps a
ring-kumo-brandoutline that follows scrolling and new versions. - Build chat shows the scope as an
infoBadge(Target) above the brief, with an icon-only “Change the whole page instead” button.
- The preview header has a secondary “Pick” 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” withMegaphone. 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 adivide-ylist, newest first. Each row has a kindBadge(correctionwarning, clarificationinfo, updateneutral), the time or “Not saved”, and the note as text. Only unsaved rows have an icon-only “Remove correction” button. With none, it shows aSectionEmpty. - The form: a
Select“Kind”, anInputArea“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 theautomode 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
InputAreain 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_lintwarnings show as analertBanner(“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/instancereportsfeatures.playground(try.cloudhal.com). Studio has no sign-in there:requireSessionaccepts a 403 from/api/authwhen the instance is a playground. - Session bar:
PlaygroundBarunder the top bar on every screen — a compact secondaryBanner(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,/teamor/accountshowsPlaygroundUnavailable: a bareEmpty(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, byfeatures.*and the principal’s abilities; a403 playgroundanswer 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 aMenu); it is a plain row trigger forDropdownMenuwithw-(--anchor-width)content and a check on the current item —@cloudhal/uiships it asSidebarSwitcher. Sidebar.LoadingreplacesSidebar.Contentuntil navigation data resolves; never render an empty menu.- Counts and states go in
Sidebar.MenuBadge(4,Soon,Beta); unavailable items aredisabled, never hidden. - Sub-navigation uses
Sidebar.MenuItem→Sidebar.Collapsible→CollapsibleTrigger render={<Sidebar.MenuButton>… <Sidebar.MenuChevron /></Sidebar.MenuButton>}→CollapsibleContent→MenuSub/MenuSubButton. Sidebar.Footerholds onlySidebar.Trigger, as in Kumo’sSidebarFullDemo, 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 isz-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.Railtoggles the sidebar from its edge.- Below the mobile breakpoint the sidebar is a sheet; choosing a page closes it (
CloseSheetOnNavigate, like Kumo’sNavTreedemo).
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"),xs12 /sm13 for captions,lg16 only for headings;variant="heading"(16 px) andsize="lg"(20 px) for page-level; page titles come from the blocks. Sentence case; product names title-cased;boldprop for emphasis; neverfont-bold, nevertracking-*, never uppercase. - Spacing:
gap-1.5between a heading and its description,gap-6between blocks, card paddingpx-5 py-4, page containerp-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, brandbg-kumo-brand, statuskumo-info|success|warning|danger(+-tint), texttext-kumo-default|strong|subtle|inactive|placeholder|inverse|link. No raw Tailwind colours, nodark:(Kumo themes vialight-dark(); toggle withdata-mode="dark"). The person chooses Light, Dark or System (the default, followingprefers-color-schemelive) in the top bar’sThemeSwitch; the choice lives inlocalStorage(cloudhal.studio.theme,lib/theme.ts, synced across tabs) and an inline script inindex.htmlapplies it asdata-mode(andcolor-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,warningfor publish/rollback scopes) — never the saturatedpurple/blue/neutralfills, which out-shout the status column. - Edges:
ring ring-kumo-line(elevated) /ring-kumo-hairline(flat); neverborder+shadow; concentric radii (rounded-xl p-1→rounded-lg); sticky elements separate withborder-b border-kumo-line; notransition-colorson 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 pxEmpty;weight="fill"only for status (banners, toasts) and the brand mark. Studio ships three weights only — regular,bold,fill(apps/studio/phosphor-weights.tsdrops thin, light and duotone from every icon definition; a test refuses them in source). Icon-only buttons needaria-labelandshape="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.