RFC-0025: Site chrome and overlays
- Status: Accepted (2026-10-16; the directions were decided in the companion R&D report)
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-16
- Related:
- RFC-0003 (menus), RFC-0004 (media and
ImageRef), RFC-0017 (theme settings), RFC-0020 (surfaces, the interaction runtime), RFC-0021 (render_page), RFC-0023 (tokens and surfaces), RFC-0024 (motion, the pause control); docs/design/site-chrome-and-overlays.md, the R&D report: platform status, accessibility patterns and the decisions this RFC takes over (sources there).
- RFC-0003 (menus), RFC-0004 (media and
Summary
Every Cloudhal site has the same top and bottom. The blog theme’s Base.astro hard-codes:
- a text site name, a menu and a search box;
- a “Built with Cloudhal” footer;
and agents cannot change any of it. There are no overlays either: no dialog, drawer, popover, tooltip or lightbox.
This RFC makes the header and footer structured site data:
- layouts, behaviors and mobile styles as enums;
- slots for the logo, menus, actions, footer columns, social links and the legal row;
- an announcement bar and a consent bar.
The theme renders it with accessibility built in. It also adds overlay components to the catalog, opened by a button through the platform’s Invoker Commands and working without JavaScript.
Agents choose; the renderer guarantees landmarks, focus handling and contrast.
Motivation
- Sameness and lost brand. A site with a logo, a call to action in the header and a footer of link columns looks like a business; ours look like a blog template. Builders offer header and footer variants as a first choice (Squarespace, Wix, Webflow, Framer; see the report).
- Agents cannot do it. The header and footer are theme code. An agent building a landing page cannot add a “Start free” button to the header, a transparent header over the hero, or a newsletter footer.
- Phones. The header wraps; there is no menu button.
- Overlays are a common need done badly. Video lightboxes, terms in a sign-up flow, filters in a drawer and definitions in a popover are done badly by hand: focus traps, Esc and scroll locking.
- The platform now provides most of it:
<dialog>is widely available, the Popover API is Baseline 2024, Invoker Commands Baseline 2025 and anchor positioning Baseline 2026. - A small set of catalog components can use the platform and cover the gaps.
- The platform now provides most of it:
Proposal
1. Data: Site.brand, Site.chrome, Page.chrome
New optional fields (a contract change; schema subset; enums and references only).
// Site"brand": { "logo": ImageRef, // with assetId (RFC-0004); the site name as text when absent "logoDark": ImageRef, // for dark mode, when the logo needs one "logoHeight": "s" | "m" | "l", "social": [ { "network": "x" | "bluesky" | "mastodon" | "github" | "linkedin" | "instagram" | "facebook" | "youtube" | "tiktok" | "threads" | "rss" | "email", "href": "…" } ] // ≤ 8},"chrome": { "header": { "layout": "logo-left" | "logo-left-nav-center" | "centered" | "split" | "minimal", "behavior": "static" | "sticky" | "hide-on-scroll" | "fixed", "shrink": false, "overHero": "solid" | "transparent", "width": "content" | "wide" | "full", "surface": "plain" | "muted" | "raised" | "inverse" | "brand", "mobileStyle": "dropdown" | "drawer-start" | "drawer-end" | "fullscreen", "collapseAt": "sm" | "md" | "lg" | "always", "nav": "primary", // a menu key (RFC-0003) "utility": "utility", // an optional second menu, shown small "actions": [ { "kind": "button" | "search" | "locale" | "theme-toggle" | "motion-toggle", "label": "…", "href": "…", "variant": "primary" | "secondary" } ] // ≤ 3 }, "footer": { "layout": "simple" | "columns" | "cta-band" | "minimal", "surface": "plain" | "muted" | "inverse", "tagline": "…", "columns": [ { "heading": "…", "menu": "footer-product" } ], // ≤ 5 "ctaHeading": "…", "ctaLabel": "…", "ctaHref": "…", // for cta-band "social": true, // Site.brand.social as labelled icon links "copyright": "© {year} {site}", // the only placeholders "legal": "legal", // a menu key "extras": [ "locale" | "theme-toggle" | "motion-toggle" ], "credit": true // "Built with Cloudhal"; the owner may turn it off }, "announcement": { "id": "spring-2026", "text": "…", "label": "…", "href": "…", "tone": "info" | "brand" | "warning", "dismissible": true }, "consent": { "mode": "none" | "notice" | "choices", "policy": "/privacy" }}// Page"chrome": { "header": "default" | "transparent" | "hidden", "footer": "default" | "minimal" | "hidden" }- Flat shapes. Header and footer fields are flat (
mobileStyle,ctaLabel) rather than nested, so Studio’s generated form (BlockPropsForm) and agents edit them the same way. AchromeSchemain@cloudhal/core-ts/chromemirrors the contract as a form schema. - Menus stay where they are. The header and footer name menu keys; items are not copied.
- Defaults reproduce today. Without
chrome, the theme renders today’s header and footer: the site name, the primary menu, search, the footer menu or RSS, and the credit.
2. Rendering (the blog theme; any theme through @cloudhal/astro-renderer/chrome)
- Always emitted:
- a skip link to
#mainas the first focusable element; - one
<header>,<nav aria-label>named from the menu, one<main id="main">and one<footer>; - disclosure buttons (never
role=menu) for groups; html { scroll-padding-top: var(--ch-header-h) }for sticky and fixed headers;- a reserved header height, so nothing shifts.
- a skip link to
- Header layouts arrange the logo, menu and actions. Header and footer colors come from RFC-0023 surfaces.
- Transparent over a hero is allowed only when the page’s first band has a loud surface (
inverse,brand,accent,gradient) or an image with a scrim. The header then takes that surface’s text role, so contrast holds, andrender_pagechecks it. - Behaviors:
stickyisposition: sticky, with a “stuck” style throughscroll-state()where supported.hide-on-scrolluses the motion module’s scroll direction (decision 3 of the report). It is off at motionnoneand under reduced motion.shrinkanimates padding through custom properties, never the layout box.
- Mobile. A drawer or fullscreen menu is a modal
<dialog>opened bycommand="show-modal";dropdownis a disclosure. Without Invoker Commands the button is a link to/menu, a server-rendered page of the same menus. - Footer. Columns are labelled
navs. Social links are icon links with accessible names.{year}and{site}are filled by the renderer. The pause control (RFC-0024) and other extras go in the legal row. - Announcement. Rendered on the server above the header. A dismissal sets the first-party cookie
ch_dismiss=<id>, so the server leaves it out next time and nothing shifts. - Consent. A non-modal bottom bar (
popover="manual"), or a modal dialog forchoices. It is padded so it never covers the focused element (WCAG 2.4.11), and the choice is kept in a first-party cookie.
3. Overlay components
New catalog components. Their children are their content, and they render in the top layer.
| Component | Props | Native | Fallback |
|---|---|---|---|
Dialog |
title (required), size, dismiss (any/closerequest/explicit), fallbackHref |
<dialog> + command="show-modal", closedby |
trigger is a link to fallbackHref without JavaScript; the runtime closes on backdrop click where closedby is missing (Safari) |
Drawer |
title, placement (start/end/bottom), size |
modal <dialog> at the edge |
as Dialog |
Popover |
placement, width |
popover="auto" + popovertarget/toggle-popover, anchored to its trigger |
centered popover |
Tooltip |
text (≤ 160, no interactive content) |
popover="hint" + interestfor |
the runtime opens it on focus and hover (Safari); never the only way to the information |
Lightbox |
items (assets, alt required), start |
modal <dialog> with --next/--prev commands |
thumbnails link to the full images |
- Opening.
ButtongainsonClick: {action: "open" | "close", target}. The renderer maps it tocommand/commandfor(pluspopovertarget) witharia-controls, keepinghrefas the fallback. - The checks:
- every target exists in the same surface and is an overlay;
- dialogs and drawers have titles;
- tooltips hold no interactive content;
- at most 3 overlays per surface;
- no overlay opens without a user action (not on load, on a timer or on exit intent).
- The runtime’s part (decided in the report):
- a first-party shim for the three built-in commands on browsers before December 2025, loaded only on pages with overlays;
- focus returned to the trigger;
- backdrop-click closing in Safari;
aria-expandedon triggers;- lightbox navigation;
- focus and hover tooltips in Safari.
- Scroll lock uses
html:has(dialog:modal)in CSS. - Motion. Entrances and exits use
@starting-style, at the RFC-0024 levels. - A2UI.
Modalnow maps toDialog.
4. Agents, MCP and CLI
- MCP tools:
get_chrome(read): the brand, chrome and page overrides with their allowed values;set_chromeandset_brand: changes with a person’s approval, likeset_theme_settings;get_designsummarizes them.
- Catalog.
catalogPromptteaches the overlays and theopenaction. render_pagechecks:skip-link;landmarks;focus-obscured(sticky header and bars never cover the focused element);header-contrast(transparent headers);overlay-open: each overlay opened through its trigger, focus moves in, Esc closes it, focus returns, and its contrast passes.
- CLI.
cloudhal chrome getandcloudhal chrome set --file chrome.json. - Studio. A “Header and footer” section in Site settings (the generated form from
chromeSchema, plus the logo picker), next to Navigation. - The site-design skill gains chrome recipes per kind of site.
Invariants
- Agents and people choose enums, menu keys, assets and short texts. Markup, CSS and scripts in the chrome never come from them.
- Every page has a skip link, one header, one main, one footer and labelled navigation, whatever the settings.
- A sticky or fixed header and every bar leave the focused element visible; a transparent header keeps its contrast.
- The site works without JavaScript: menus, overlays (through their fallback links) and dismissals.
- No overlay opens without the reader asking.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
| Header and footer as surfaces | Full freedom | No guaranteed landmarks, focus or contrast; every agent reinvents navigation | The report’s decision 4: structured, with a free region in the footer later |
| Theme settings (RFC-0017) for the chrome | No contract change | Scalars only: no lists (actions, columns, social), no assets | The chrome needs lists and images |
Page-level overlays list |
One place per page | A second content model beside surfaces | Overlays as catalog components reuse checks, streaming and the outline |
Hand-rolled dialogs (div + JavaScript) |
Works everywhere | Focus traps and Esc by hand, inert backgrounds missing | The platform does it now, with small fallbacks |
Compatibility & migration
- Contract.
Site.brand,Site.chromeandPage.chromeare optional new fields (schemaVersionunchanged);pnpm genupdates TypeScript and Rust. The overlay components and theopenaction are catalog features. - Existing sites keep today’s header and footer until someone sets
chrome. The defaults render the same markup, plus the skip link and themainid. - Rollback. A renderer that does not know
chromeignores it, and the store keeps it.
Security & policy impact
- Scopes and approvals.
set_chromeandset_brandneedsite:writeand a person’s approval: the chrome is on every page. Menus keep their own tools and approvals. - No free-form markup. Texts are escaped; links pass the existing safe-link check; logos are assets (RFC-0004 scanning).
- Consent is a legal choice. Agents may propose it (
get_designsuggestsnoticewhen a page embeds third-party content), and a person sets it. - The Invoker Commands shim is first-party and loaded only where needed, so the CSP is unchanged.
Rollout
| Phase | Scope |
|---|---|
| 25a | Site.brand, Site.chrome.header/footer, Page.chrome (contract); the blog theme’s header and footer from data (layouts, sticky, logo, actions, columns, social, legal, credit), skip link and landmarks; get_chrome/set_chrome/set_brand; cloudhal chrome; Studio section |
| 25b | Mobile menu (drawer, fullscreen, dropdown, /menu fallback), popover submenus, hide-on-scroll, shrink, transparent over hero with the contrast rule; skip-link, landmarks, header-contrast, focus-obscured checks |
| 25c | Overlay components, the open/close actions, the runtime additions and the shim, the overlay-open check |
| 25d | Announcement and consent bars, toasts, mega menu items (RFC-0003 amendment), the footer’s optional surface region |
Open questions
None; the report decided them:
- the shim with a link fallback;
- Safari fallbacks now;
- hide-on-scroll by script with CSS where supported;
- an optional footer surface region;
- consent
noneby default, suggested when needed.
Status
- 25a shipped.
- Contract.
Site.brand,Site.chrome(header, footer; announcement and consent are in the contract, rendered from 25d) andPage.chromeare optional;pnpm genupdated TypeScript and Rust, with fixtures. - Core.
@cloudhal/core-ts/chrome:resolveChrome(defaults reproduce the former header and footer, so sites that never setchromerender the same);checkChrome(menus that exist, buttons with label and link, an action kind once, a complete cta-band, only{year}/{site});chromeSchemas(en/tr form schemas);brandFromForm/brandToForm/logoAsset(logos asasset:<id>in forms,ImageRefs with the asset’s URL and size on the site);applyChromeForm, shared by the API and the CLI.
- Theme. The blog theme draws its header and footer from the chrome:
- five header layouts and three widths,
sticky(hide-on-scrollandfixedact as sticky until 25b), surfaces, a logo with a dark-mode variant, button and search actions; - four footer layouts with labelled column menus, social links as text, copyright, the legal menu and the credit;
- a skip link,
main#mainandscroll-padding-topunder sticky headers. - A page’s
chromehides the header or footer, thins the footer, or marks the header transparent (its fill on scroll comes in 25b).
- five header layouts and three widths,
- Agents.
get_chrome(site:read);set_chromeandset_brand(site:write, approval; merges, null removes);update_pageacceptschromeandmotion(null removes).- 96 MCP tools.
- People.
- Studio › Settings › Header and footer: header, footer and brand segments from the same schemas, logos from the media library; its words live in the
appearancelazy namespace (formerlyappearanceColors), so the first load stays at 475.0 kB. - REST
GET/PUT /api/sites/:id/chrome(content:read / site:settings). cloudhal chrome get|set --file.
- Studio › Settings › Header and footer: header, footer and brand segments from the same schemas, logos from the media library; its words live in the
- Gap. Logos are not yet counted as asset uses (deleting a logo asset does not warn); this lands with 25b. (Closed in 25b.)
- Contract.
- 25b shipped.
- Phone menu. Below
collapseAt(always for theminimallayout) the bar’s menus and search move behind a Menu control:dropdownis a<details>disclosure, with no script;drawer-start,drawer-endandfullscreenare a modal<dialog>opened from a link to/menu.- Without the chrome module, or before it loads, the link opens
/menu: a server-rendered, noindex page with the same menus (any site page at/menuwins). - The dialog closes with Escape, its Close button (
form method="dialog", no script), a click on the backdrop or a link; the browser returns focus. - We kept a link rather than
command="show-modal". It degrades to/menueverywhere, and the module addsrole=button,aria-haspopupandaria-expanded.
- Submenus. A group in the bar is a native popover (
popover="auto"+popovertarget) placed by CSS anchor positioning (the invoker is the implicit anchor). The module places it where that is missing. Groups in the phone menu stay disclosures. - Chrome module.
/_cloudhal/chrome.1.jsis about 1.1 kB gzipped and loads only when the header needs it:- it keeps
--ch-header-h; - it sets
data-ch-scrolled; hide-on-scrollhides the header on the way down, but never at motionnone, under reduced motion, or with keyboard focus in the header;- it handles the menu dialog;
- it closes an open disclosure on Escape or a click elsewhere;
- it places popovers where anchor positioning is missing.
- it keeps
- Shrink. It moves the header and its row with
translate, never their box, so nothing below shifts.fixedbehaves likesticky. - Transparent over a hero.
heroSurface(page)finds a first band paintedinverse,brand,accentorgradient(the root or its first child, three levels at most; an oldtonecounts).resolveChromekeeps the header solid anywhere else.- The header takes that surface’s text role (
data-over), and the primary button swaps to stay visible on a brand hero. - It fills with the page surface once scrolled. The first band starts under it and keeps its own room.
- An image with a scrim does not count yet.
- Checks.
render_pageadds four checks:skip-link,landmarks,header-contrast(a transparent header’s text against the band at the top) andfocus-obscured(tabbing back up, a sticky header never covers the focused element). - Asset uses. The brand’s logos are asset uses (
asset_refs, node kindsite, fieldsbrand.logoandbrand.logoDark): canonicalImageRefs, delete refused while used, retired assets refused. Studio’s file page lists “Site logo” and links to the settings section. - Footer contrast. On the muted surface the footer’s text is
text-muted, the role checked againstsurface-muted; the old zinc-500 was 4.4:1.
- Phone menu. Below
- 25c shipped.
- Components.
Dialog(title, size, dismiss, fallbackHref),Drawer(title, placement, size),Popover(placement, width),Tooltip(text,fora Button or Link) andLightbox(up to 24 items with asset and alt, start) join the catalog.- A
Button’sonClickgainsopen(target) andclose(its own overlay, or target). catalogPromptteaches them.- A2UI
Modalimports as aDialogwith the Button that opens it, and aDialogorDrawerexports as aModal.
- A
- Checks.
- Targets are overlays of the same surface.
- A close button is inside an overlay or names one.
- Dialogs and drawers have titles.
- Tooltips point at a Button or Link and hold no children.
- No modal opens inside another.
- At most 3 dialogs, drawers and popovers per section. A Lightbox’s thumbnails are content and don’t count.
- At most 8 tooltips.
- There is no prop that opens an overlay by itself, so nothing opens on load or on a timer.
- Rendering. Everything is native and needs no script in current browsers:
- dialogs and drawers are
<dialog>withcommand="show-modal"/closeandclosedby(explicit→none); - a Dialog with
fallbackHrefopens from a real link; - popovers use
popover="auto"+popovertarget, anchored to their trigger; - tooltips use
popover="hint"+interestforandaria-describedby; - a Lightbox’s thumbnails link to their images and open a dialog that moves with
--next/--prevcommands. - Buttons that only open or close overlays no longer make a surface interactive, so the interaction runtime and data are not loaded for them.
- dialogs and drawers are
- Overlay module.
/_cloudhal/overlay.1.jsis about 1.5 kB gzipped and loads only with an overlay:- it shims Invoker Commands,
closedbyandinterestforwhere they are missing; - it opens a dialog from its fallback link;
- it keeps
aria-expandedon triggers; - it handles lightbox navigation (buttons and arrow keys);
- it places popovers and tooltips where anchor positioning is missing.
- it shims Invoker Commands,
- Theme. The scroll lock is
html:has(dialog:modal). Entrances use@starting-styleand follow the motion level; there are none under reduced motion. - Check.
render_pageaddsoverlay-open: each overlay is opened through its trigger, focus moves in, it closes, focus returns, and its text contrast is checked. An overlay nothing opens is reported. Screenshots of open overlays are left for later. - 25b fix. The drawer and full-screen menu dialog showed when closed on wide screens; only an open one is now laid out.
- Components.
- 25d shipped.
- Contract.
chrome.footer.regionis one section, refused unless it is a surface, and checked like a page’s surfaces.- Menu items gain
display: "mega"(an RFC-0003 amendment), allowed only on a top-level item with children. - The MCP menu tools and
updateoperations carry it. Studio’s menu editor does not edit it yet.
- Announcement. A bar above the header (
role=region, a tone, an optional link). Its close button writesch_dismiss=<id>(the last five ids, a year,SameSite=Lax). A newidshows a new bar.- Changed from the design: the edge cache does not vary on cookies, so the server cannot leave a closed bar out. Instead,
/_cloudhal/prefs.1.js(a 263 B classic script in<head>, loaded only with a dismissible bar or a consent bar) reads the cookies and marks the root (data-ch-announcement-dismissed,data-ch-consent). CSS hides the bar before the page paints, so nothing shifts.
- Changed from the design: the edge cache does not vary on cookies, so the server cannot leave a closed bar out. Instead,
- Consent.
notice(OK) orchoices(allow all, only essential): a fixed bottom bar with the privacy link that writesch_consent. While it shows, the page keeps room below it (scroll-padding-bottom, body padding from--ch-consent-h) so it never covers what has focus.- Cloudhal sites are cookieless and embeds load on a reader’s click, so
choicesrecords the choice and exposes it on the root for later third-party features. The bars’ buttons appear only with JavaScript; without it the bar still informs.
- Cloudhal sites are cookieless and embeds load on a reader’s click, so
- Toasts. A live region (
role=status) on every page. The interaction runtime (surface.10.js;surface.9.jsis an alias) says “Copied” or “Link copied” there aftercopyandshare. Toasts go after four seconds. - Mega menus. One wide popover under the header, its groups as columns (loose links form a first column).
- Footer region. Drawn above the columns with the page’s
Sections, its entry sources loaded and cache-tagged like a page’s. - Agents and people.
set_chrometakesannouncement,consentandfooter.region;get_chromeresolves the bars.- Studio’s Header and footer section gains a Bars segment.
chrome.2.js(withchrome.1.jsas an alias) handles the bars’ buttons.
- Contract.