oficina / estilo Public

Spec: shared chrome for the Oficina apps (round 2)

Base links, error page, callout tones, top bar, account menu, optional
app switcher, one floating menu style, sign-in copy and return-to,
the in-app sign-out chain to id.*, page titles, the square-rule
inventory, and the adoption plan (Docs first, Sheets second).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8d7bd9be61cdcb904762780075ab76308db81d58 parent bd5cd6d
1 file changed, +1243 −0
  • docs/superpowers/specs/2026-09-23-shared-chrome.md +1243 −0

CI passed — run details

CI log
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/callui	0.002s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.005s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.004s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.004s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.003s
ok  	amadan.net/oficina/estilo.git/savestatus	0.070s
ok  	amadan.net/oficina/estilo.git/theme	0.004s
diff --git a/docs/superpowers/specs/2026-09-23-shared-chrome.md b/docs/superpowers/specs/2026-09-23-shared-chrome.md
new file mode 100644
index 0000000..0f30e59
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-23-shared-chrome.md
@@ -0,0 +1,1243 @@
+# Shared chrome for the Oficina apps (round 2 design spec)
+
+Status: **proposed**, 2026-09-23. It needs whole-spec review by the
+highest-capability model (AGENTS.md, Process) and Paul's answers to §13
+before any adopting branch starts.
+
+Scope: PLAN.md §1 items S1–S5, S7, S8 and B2's in-app half, plus the
+pieces of S6, S9 and S11 that the chrome can't ship without. Out of scope,
+and left for R3: S10 (dates), S12 (forms), S13 beyond the estilo pin bump,
+and every per-app item in PLAN.md §2.
+
+Sources: `polish-audit/PLAN.md`; the seven reports in
+`polish-audit/<app>.md` (sections B–D, and Home §F) and their screenshots;
+estilo `AGENTS.md` and `README.md`; rastrillo v0.23.0 `ui/tokens.css`,
+`ui/themes/day.css`, `ui/layouts/topbar.html` and `ui/partials/*`; home
+`internal/home/directory_catalog.go`, `directory_runtime.go`,
+`signout.go` (branch `fix/r1-signout-ends-id-session`) and
+`connect/routes.go`.
+
+Paul's rulings this spec builds on (2026-09-23):
+
+- The sign-in button says **"Sign in"**. "Oficina" is the suite, not the
+ name of the hub.
+- The app switcher should be visible in the design, but it isn't
+ committed. It's an optional block that costs nothing to drop (§6).
+- Only floating things get rounded corners. In-flow hover and selected
+ highlights are square.
+- Estilo's theme stays byte-identical to rastrillo `day`, enforced by
+ `TestMatchesDayByteForByte`. Everything here is an estilo **component**
+ with its own CSS file, written in the bare-attribute grammar (AGENTS.md
+ ruling 4, prefix `estilo-`). None of it is a theme edit.
+- The one rule: **Docs builds these primitives in estilo and Sheets adopts
+ them second.** No other app starts until Sheets' corrections have landed
+ (§12).
+
+---
+
+## 0. Constraints that shape every choice below
+
+1. **Estilo CSS may not select `[rst-*]` or `.rst-*`.** This is the
+ `internal/lint` prefix tripwire. Estilo can't restyle rastrillo's
+ `[rst-shell-bar]`, `[rst-dropdown-menu]` or `[rst-row]`. So the bar,
+ menus and account corner are **estilo-owned markup** (`estilo-*`
+ attributes). Estilo *templates* may still emit `rst-*` attributes to
+ reuse rastrillo's styling, for example `rst-btn`, `rst-callout` and
+ `rst-error`, because the lint scans `.css` only.
+2. **The theme is `day`, untouched.** New values (bar height, float
+ radius) are `--estilo-*` custom properties declared in the component
+ stylesheets, never in `theme/estilo.css`. Every `var(--rst-*)` a
+ component uses must be defined in `ui.TokensCSS()` + `theme.CSS()`,
+ enforced by a `…UseDefinedTokens` test per package, as `callui` does.
+3. **Twelve locales.** Estilo templates carry no strings. Callers supply
+ every label, as `homeconnect` does. The copy in this spec is the `en`
+ source.
+4. **Zero-JS floor.** Every trigger is a `<details>`/`<summary>` and every
+ action is a link or a form POST. With scripts off, a panel closes only
+ when you click its summary again. That's the same stated cost as
+ rastrillo `dropdown`. Script may add Esc/outside-click close and
+ menubar hover-switching.
+5. **Stylesheet order.** `tokens.css` → `theme.css` → `brand.css` →
+ `estilo/base.css` → `estilo/menu.css` → `estilo/chrome.css` → app CSS.
+ Estilo rules that override rastrillo defaults rely on coming after
+ `tokens.css`.
+
+## 1. Packages and files (what estilo gains)
+
+| Package | Files | Go surface | First adopter |
+|---|---|---|---|
+| `base` | `base/base.css` | `base.CSS() []byte` | Docs |
+| `menu` | `menu/menu.css`, `menu/templates/menu.html` | `menu.CSS()`, `menu.Templates()`, `menu.Shortcut(r *http.Request, keys string) string` | Docs |
+| `chrome` | `chrome/chrome.css`, `chrome/templates/{bar,account,switcher,error,signin}.html` | `chrome.CSS()`, `chrome.Templates()`, `chrome.Bar` (data), `chrome.Appearance(r) Mode`, `chrome.HandleAppearance`, `chrome.ThemeAttr(Mode) string` | Docs |
+| `brand` (gains Go) | existing `brand/svg/*.svg` | `brand.SVG(slug string) []byte`, `brand.Handler()`, which serves `/static/estilo/brand/<slug>.svg` | Docs (switcher) |
+
+Apps serve each CSS file at a fingerprinted path, as they do `theme.css`
+today. `chrome.Templates()` is parsed together with the app's own layout,
+like `homeconnect`. An app overrides the one named slot,
+`estilo-bar-actions`, by redefining it after parsing.
+
+The icon for Calls is still `brand/svg/meet.svg` (the rename left it
+alone). `brand.SVG("calls")` maps to that file, so no asset is renamed.
+
+---
+
+## 2. Base link styling (S1): an estilo `base.css`, not rastrillo
+
+**Decision:** estilo `base/base.css`, included by every app. It doesn't go
+upstream. AGENTS.md ruling 1 puts shared suite styling in estilo, and the
+theme rule forbids putting it in `theme/estilo.css`. The rule has zero
+specificity, so every rastrillo component (`rst-btn`, `rst-row-main`,
+`rst-shell-nav`) and every estilo component still wins without a fight.
+
+```css
+/* base/base.css: links. Zero specificity on purpose: any component rule wins. */
+:where(a:any-link) {
+ color: var(--rst-accent);
+ text-decoration-line: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.15em;
+}
+:where(a:any-link):hover { text-decoration-thickness: 2px; }
+:where(a:any-link):focus-visible {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 2px;
+}
+```
+
+- `:any-link` covers `:visited`, so there's no visited purple. The colour
+ flips with `light-dark()` through the token, which fixes Memoria's
+ `rgb(158,158,255)` in dark mode.
+- **Prose links keep their underline.** Estilo's standards say colour must
+ never carry meaning alone, and a link that is only blue fails that.
+ Chrome links (bar, nav, menus, rows, buttons) don't come from this rule.
+ Their components set `text-decoration: none`.
+- Contrast (computed from `day.css` by the WCAG 2.2 formula): `#2464e0` on `#ffffff` is 5.28:1, and `#7ba7ff` on `#111418` is 7.75:1 (6.95:1 on the dark
+ surface; 4.93:1 for light accent on `--rst-surface-2`). The tight pair in AGENTS.md is
+ accent-on-accent-soft (4.75:1). **Rule: no prose link sits on an
+ `--rst-accent-soft` background.**
+- Deleted in adoption: Memoria's proposed `main a {…}` and any app-local
+ `a {color}` rule.
+
+## 3. Error and not-found page (S2)
+
+### 3.1 Markup
+
+`chrome/templates/error.html` defines `estilo-error`. It emits rastrillo's
+`rst-error` markup, which is already styled (see `calendar-18`), so it
+needs no new CSS. It exists because rastrillo's `error-page` partial
+hard-codes the CTA label "Start page". Here the caller names the way back.
+
+```html
+{{define "estilo-error"}}<div rst-error>
+ <p rst-error-status>{{.Status}}</p>
+ <h1 rst-error-title>{{.Title}}</h1>
+ <p rst-error-body>{{.Body}}</p>
+ <p rst-error-cta><a rst-btn="primary" href="{{.Home.Href}}">{{.Home.Label}}</a>
+ {{- with .Back}} <a rst-btn href="{{.Href}}">{{.Label}}</a>{{end}}</p>
+ {{- with .Ref}}<p class="rst-mono" rst-error-ref dir="auto">{{$.RefLabel}} {{.}}</p>{{end}}
+</div>{{end}}
+```
+
+The page sits inside the app's **normal layout**, with the estilo bar
+showing whatever signed-in state the request has. Calendar's error page
+today always shows signed-out chrome, which is a bug this fixes.
+
+### 3.2 Copy (en)
+
+| Status | Case | `<title>` | h1 | Body | Primary CTA |
+|---|---|---|---|---|---|
+| 404 | unknown route | Page not found · {App} | We can’t find that page | The link may be out of date, or the page may have moved. | Go to {App} |
+| 404 | object missing **or not shared with you** (never 403, so nothing is enumerable) | {Noun} not available · {App} | This {noun} isn’t available | It may have been deleted, or it hasn’t been shared with you. | Go to {App} |
+| 403 | signed in, but not allowed on a route (not an object) | No access · {App} | You don’t have access to this page | Ask whoever runs this team if you think you should. | Go to {App} |
+| 500 | handler failure or panic | Something went wrong · {App} | Something went wrong | Try again in a moment. If it keeps happening, quote the reference below. | Try again (same URL, GET only; otherwise Go to {App}) |
+| 503 | dependency down | {App} is unavailable · {App} | {App} is unavailable right now | Try again in a few minutes. | Try again |
+
+`{noun}` is supplied by the caller: document (Docs, Memoria), sheet,
+deck, event, meeting, booking page. The reference label is "Reference:".
+The secondary **Back** link appears only when the Referer has been checked
+as same-origin, which is rastrillo's rule. All apostrophes are
+typographic (’).
+
+### 3.3 Router wiring (every app, and both Home origins)
+
+```go
+r.NotFound(a.htmlError(http.StatusNotFound)) // chi; replaces "404 page not found"
+r.MethodNotAllowed(a.htmlError(http.StatusNotFound)) // GET/HEAD get the page; other methods get 405 text/plain
+r.Use(a.recoverToErrorPage) // panic → 500 page with rastrillo.NewRef()
+```
+
+- `a.htmlError(status)` and `a.errorPage(w, r, status, opts…)` resolve
+ the viewer with an **optional** session read. They never require one.
+ They build the same `chrome.Bar` as any other page, set
+ `Cache-Control: no-store`, and write `text/html`.
+- **Non-HTML stays non-HTML.** Paths under `/api/`, requests whose
+ `Accept` doesn't include `text/html`, and fetch/XHR POSTs still get
+ `text/plain` or JSON. That's the only place a raw `http.Error` stays
+ allowed.
+- Every `http.Error` on an HTML GET route becomes `a.errorPage`. Counts
+ from the audits: Docs 27 in `handlers.go` plus `publish.go` and
+ `share.go`; Sheets `handlers_sheets.go:264`, `handlers_table.go:187,210`,
+ `handlers_view.go:53`, `theme.go:82`, `render.go:169`; Memoria
+ `handlers.go:749,1045`, `home_render.go:18`; Home `passkey.go:171,187`,
+ `render.go:83,89`.
+- Home: the directory origin and the credential origin both get
+ `NotFound`. Unknown hosts keep the empty 404 (`origins.go:91,95`),
+ because that isn't a page anyone navigates to.
+- **Gate (per app):** `TestHTMLErrorsRenderTheErrorPage` runs a table of
+ cases {unknown route, missing object, bad parameter} × {signed in,
+ signed out}. It asserts the status, `Content-Type: text/html`, the exact
+ `<title>`, that `estilo-bar` is present, and that the account trigger is
+ present if and only if the person is signed in. A second test lists the
+ remaining `http.Error` call sites by file:line and fails on any new one
+ that isn't under `/api/`. Prove both tests fail before the change, per
+ the R1 brief.
+
+## 4. Callout and notice tones (S3)
+
+**Rule:** the only notice vocabulary is rastrillo `[rst-callout]` with
+`rst-tone` ∈ `info | positive | warning | negative`, rendered through the
+rastrillo `callout` partial whenever the body is one paragraph. The partial
+emits `<div rst-callout-body><p>…</p></div>`, which fixes Memoria's
+double-height `<p rst-callout-body>`. The following go from every
+template: `rst-notice` (always success green, `tokens.css:1268`),
+`callout="warn"` (matches nothing), and hand-rolled status paragraphs.
+`role="alert"` is used for `negative` after a submit, and nowhere else.
+
+| App | Current | Message | Tone |
+|---|---|---|---|
+| Calls | `rst-notice` | sign-in errors: rate, address, expired, generic | negative (address errors go in the field's error slot instead) |
+| Calls | `rst-notice` | "Calls outside your network may not connect yet." (no relay) | warning |
+| Calls | `rst-notice` | "This meeting is already open in another tab." | warning |
+| Calls | `rst-notice` | "Link copied." | positive |
+| Sheets | `rst-callout="warn"` | "Your link. Copy it now…", contrast-floor notice | warning |
+| Sheets | neutral callout | sign-in errors (expired, cannot sign in, keymail, generic) | negative |
+| Sheets | notice + callout (duplicates) | "Link created. Copy it now…" | keep one: warning |
+| Sheets | notice | Shared / Access removed / Comment added / Thread resolved / Put back / Undone | positive |
+| Sheets | notice | "Unknown role", "A sheet needs an owner", "A comment needs something in it" | negative |
+| Docs | positive notice | "You are reading a shared copy of this document." | info |
+| Docs | field/notice | sign-in errors | negative |
+| Memoria | callout (`<p>` body) | "Stored 1 document." | positive |
+| Memoria | callout | "Nothing matched…" | not a callout; one muted line: "No matches for “{q}”." |
+| Memoria | callout | "The search did not run…" | negative |
+| Memoria | callout | connector errors ("We could not…", "Notion would not accept…") | negative |
+| Memoria | callout | "Importing now…" | info |
+| Memoria | callout | "Import finished, but some pages could not be read." / "The import stopped…" | warning |
+| Calendar | positive callout | sign-in sent | replaced by the §8 sent panel |
+| Calendar | callout | "Some reminders could not be delivered" | warning |
+| Calendar | callout | "Copy this now — it is not shown again" | warning |
+| Calendar | callout | import caveats ("Some events carry no time zone", …) | info |
+| Calendar | callout | "Choose a handle first" | warning, with "Settings" as a link |
+| Slides | callout | sign-in errors, "Your access … has been removed" | negative |
+| Slides | callout | "Copy this now — it is not shown again" | warning |
+| Home | callout | sign-in errors | negative |
+
+**Gate (per app):** a template grep test fails on `rst-notice`,
+`rst-callout="` (a valued callout attribute) and `rst-tone="warn"`.
+
+---
+
+## 5. Top bar (S7)
+
+### 5.1 Markup
+
+`estilo-bar` renders the whole header. Apps stop using rastrillo's
+`topbar` layout's `<header rst-shell-bar>`. It hard-codes an "Account"
+summary and a wrapping tail, and estilo CSS can't restyle it. Each app's
+layout becomes:
+
+```html
+<body>
+<a rst-skip href="#main">{{.Chrome.Labels.Skip}}</a>
+{{template "estilo-bar" .Chrome}}
+<main rst-page id="main">…</main>
+</body>
+```
+
+```html
+{{define "estilo-bar"}}
+<header estilo-bar{{if .Sticky}}="sticky"{{end}}>
+ <a estilo-bar-brand href="{{.App.Home}}">
+ <img estilo-brand-icon src="{{.App.Icon}}" alt="" width="28" height="28">
+ <span estilo-bar-name>{{.App.Name}}</span>
+ </a>
+ {{- with .Context}}<span estilo-bar-context dir="auto" title="{{.}}">{{.}}</span>{{end}}
+ {{- if .Nav}}
+ <nav estilo-bar-nav aria-label="{{.NavLabel}}">
+ {{- range .Nav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
+ </nav>
+ {{- end}}
+ <div estilo-bar-end>
+ <div estilo-bar-actions>{{template "estilo-bar-actions" .}}</div>
+ {{- if or .Nav .HasActions}}
+ <details estilo-bar-more name="estilo-menus">
+ <summary estilo-bar-trigger>{{.Icons.Menu}}<span>{{.Labels.Menu}}</span></summary>
+ <div estilo-menu="sheet">{{/* the same nav links and actions, as estilo-menu-item rows */}}</div>
+ </details>
+ {{- end}}
+ {{- with .Switcher}}{{template "estilo-switcher" .}}{{end}}
+ {{- if .Account}}{{template "estilo-account" .Account}}
+ {{- else with .SignIn}}<a rst-btn href="{{.Href}}" estilo-bar-signin>{{.Label}}</a>{{end}}
+ </div>
+</header>
+{{end}}
+{{define "estilo-bar-actions"}}{{end}}
+```
+
+Data (`chrome.Bar`): `App{Name, Slug, Home, Icon}`, `Context string`,
+`Nav []Link`, `NavLabel`, `HasActions bool`, `Switcher *Switcher`,
+`Account *Account`, `SignIn *Link`, `Sticky bool`,
+`Labels{Skip, Menu}`, `Icons`. `Link{Href, Label string; Current bool}`.
+
+- **The brand is the app's name**, as a link to the app root: **Docs,
+ Sheets, Slides, Calendar, Calls, Memoria**, and **Oficina** on the
+ hub's two origins, with the `oficina.svg` icon. The brand is defined
+ once in each app's page-data builder, never per template, which fixes
+ Docs' share page falling back to rastrillo's "Home". It's plain text,
+ not a link, on public read-only pages (Docs public, Sheets `/l/`,
+ Slides share), so a stranger isn't bounced to `/signin`.
+- **Context** is the open object's title: the document, sheet, deck or
+ meeting. It's weighted 550 so it reads as the page's subject. Docs'
+ 12.5px regular title goes.
+- **Signed out:** `SignIn = {Href: "/signin?return_to=<current path+query>", Label: "Sign in"}`.
+ On the sign-in, check-your-email and signed-out pages themselves,
+ `SignIn` is nil and the corner is empty. **The bar's height doesn't
+ change** (§5.2).
+- **Order in the end group is fixed in every app:**
+ actions → Menu (narrow only) → switcher → account. The account is always
+ the last thing at the inline end. That's the "same corner" rule.
+
+### 5.2 CSS (in `chrome/chrome.css`)
+
+```css
+:root {
+ --estilo-bar-block-size: 3.25rem; /* 52px: today's signed-in height, so the fewest pages move */
+ --estilo-trigger-size: 2rem; /* 32px targets, above the 24px floor */
+}
+[estilo-bar] {
+ position: relative; z-index: 20;
+ box-sizing: border-box;
+ display: flex; flex-wrap: nowrap; align-items: center;
+ gap: var(--rst-sp-3);
+ block-size: var(--estilo-bar-block-size); /* fixed, not min: nothing may grow it */
+ padding-inline: var(--rst-sp-4);
+ background: var(--rst-surface);
+ border-block-end: 1px solid var(--rst-line);
+}
+[estilo-bar~="sticky"] { position: sticky; inset-block-start: 0; }
+[estilo-bar-brand] {
+ display: inline-flex; align-items: center; gap: 0.5rem; flex: none;
+ color: var(--rst-text); font-weight: 650; text-decoration: none; white-space: nowrap;
+}
+[estilo-bar-context] {
+ flex: 0 1 auto; min-inline-size: 0;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ padding-inline-start: var(--rst-sp-3);
+ border-inline-start: 1px solid var(--rst-line);
+ color: var(--rst-text); font-weight: 550;
+}
+[estilo-bar-nav] { display: flex; align-self: stretch; gap: var(--rst-sp-4); }
+[estilo-bar-nav] a {
+ display: flex; align-items: center;
+ border-block: 2px solid transparent; /* square current marker, in-flow */
+ color: var(--rst-text-muted); font-size: var(--rst-fs-sm);
+ text-decoration: none; white-space: nowrap;
+}
+[estilo-bar-nav] a:hover { color: var(--rst-text); }
+[estilo-bar-nav] a[aria-current] { border-block-end-color: var(--rst-accent); color: var(--rst-text); font-weight: 550; }
+[estilo-bar-end] { display: flex; align-items: center; gap: var(--rst-sp-2); flex: none; margin-inline-start: auto; }
+[estilo-bar-actions] { display: flex; align-items: center; gap: var(--rst-sp-2); }
+[estilo-bar-trigger] {
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem;
+ box-sizing: border-box; min-inline-size: var(--estilo-trigger-size); block-size: var(--estilo-trigger-size);
+ padding-inline: 0.4rem; border-radius: 0; /* in-flow: square */
+ color: var(--rst-text-muted); cursor: pointer; list-style: none;
+}
+[estilo-bar-trigger]::-webkit-details-marker { display: none; }
+[estilo-bar-trigger]:hover, [open] > [estilo-bar-trigger] { color: var(--rst-text); background: var(--rst-surface-2); }
+[estilo-bar-trigger]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+[estilo-bar-more] { display: none; }
+@media (max-width: 799.98px) { /* rastrillo's shell breakpoint */
+ [estilo-bar-nav], [estilo-bar-actions] { display: none; }
+ [estilo-bar-more] { display: block; }
+ [estilo-bar]:has([estilo-bar-context]) [estilo-bar-name] { display: none; } /* icon stays; the title gets the room */
+}
+```
+
+**Narrow behaviour (below 800px):** it's still one row at 52px. Nav links
+and app actions move into the **Menu** panel, which is a floating
+`estilo-menu="sheet"` (§7) anchored under the bar. It overlays the page.
+It doesn't push it down the way rastrillo's open shell tail does, which is
+why the bar's height can't change. The switcher and the account never
+collapse into Menu: sign-out is two taps at every width. Docs today needs
+three. Width budget at 320px: 16 + 28 (icon) + 12 + 76 (Menu) + 8 + 32 +
+8 + 32 + 16 = 228px, so it fits with room for a truncated context.
+
+The nav and actions are rendered twice from the same data, once in the bar
+and once in Menu. The copy not in use is `display: none`, so assistive
+technology only ever sees one. That's the accepted cost of staying zero-JS.
+
+**Gate (Docs browser test, then each adopter):** `[estilo-bar]` measures
+exactly 52px with `getBoundingClientRect` at 1440px and at 390px, signed
+in, signed out, on the error page and with Menu open. Its `scrollWidth`
+equals its `clientWidth`, meaning nothing overflows.
+
+---
+
+## 6. App switcher (optional block)
+
+### 6.1 Markup and CSS
+
+```html
+{{define "estilo-switcher"}}
+<details estilo-switcher name="estilo-menus">
+ <summary estilo-bar-trigger aria-label="{{.Label}}">{{.Icon}}</summary> <!-- Label "Oficina apps"; Icon: lucide layout-grid -->
+ <div estilo-menu estilo-switcher-panel>
+ <div estilo-switcher-grid>
+ {{- range .Apps}}
+ <a estilo-switcher-app href="{{.Href}}"{{if .Current}} aria-current="true"{{end}}>
+ <img src="{{.Icon}}" alt="" width="32" height="32"><span>{{.Name}}</span></a>
+ {{- end}}
+ </div>
+ <hr>
+ <a estilo-menu-item href="{{.AllApps.Href}}"><span estilo-menu-label>{{.AllApps.Label}}</span></a> <!-- "All apps" -->
+ </div>
+</details>
+{{end}}
+```
+
+```css
+[estilo-switcher] { position: relative; }
+[estilo-switcher-panel] { inset-inline-end: 0; inset-block-start: calc(100% + 4px); position: absolute; }
+[estilo-switcher-grid] { display: grid; grid-template-columns: repeat(3, 5.5rem); gap: 0.25rem; }
+[estilo-switcher-app] {
+ display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
+ padding: 0.6rem 0.25rem; border-radius: var(--rst-radius-sm); /* inside a float: rounded is allowed */
+ color: var(--rst-text); font-size: var(--rst-fs-xs); text-decoration: none;
+}
+[estilo-switcher-app]:hover, [estilo-switcher-app]:focus-visible { background: var(--rst-accent-soft); }
+[estilo-switcher-app][aria-current] { background: var(--rst-accent-soft); font-weight: 600; }
+[estilo-switcher-app] img { border-radius: 21%; }
+```
+
+The order is fixed: Docs, Sheets, Slides / Calendar, Calls, Memoria, then
+a rule, then **All apps**, which goes to the hub directory. The current
+app's tile links to its own `/` and is marked with `aria-current` plus a
+visually hidden "(this app)" (the `.CurrentLabel` field). The state is
+never shown by colour alone.
+
+**Including or dropping it:** the switcher appears only when
+`Bar.Switcher != nil`. Dropping it everywhere is a one-line change in each
+app's page-data builder, or a single `chrome.Options{Switcher: false}`.
+There's no CSS to delete and no layout that depends on it. The account
+corner is identical with or without it. The Home directory never shows it
+because the page *is* the directory.
+
+### 6.2 How an app knows its siblings' URLs
+
+Here's how Home knows them. The display catalog (`directory_catalog.go`)
+has no sibling URLs. Each row links to Home's own `/apps/<slug>` chooser.
+The chooser asks which team, then hands off to that team's route origin,
+which `directoryRouteAllowed` restricts to
+`https://<label>.<slug>.oficina.works`. Preview deployments override the
+link from `HOME_APP_PREVIEW_ORIGINS` / `HOME_DOCS_PREVIEW_ORIGIN`. So the
+only party that knows which sibling instances *this person's team
+actually has* is Home.
+
+**The smallest honest mechanism: the switcher links to Home, not to the
+siblings.** Every sibling tile is `{directory}/apps/<slug>`.
+
+- The app needs **one** new value, the hub directory origin. It goes in
+ `connect.Credentials` as `Directory string`, from the env var
+ `HOME_DIRECTORY_ORIGIN`, the same name Home already uses in
+ `origins.go`, validated with Home's `originHost` rules. Apps already
+ hold `Issuer`, the credential origin. The directory origin can't be
+ honestly derived from it by stripping `id.`, so it's configured.
+- **No Home, no switcher.** A standalone (dev) instance has no directory,
+ and `Switcher` stays nil.
+- **Rejected:** deriving `https://<label>.<sibling>.oficina.works` from
+ the app's own host. It would link to instances the team may not have
+ (an unknown host gives an empty 404). Also rejected: a static per-app
+ list of six origins. That's six values to drift, and it's wrong for
+ preview deployments.
+- **The cost, and the Home change that pays it:** a switch passes through
+ the directory. Home's `/apps/<slug>` should **skip the chooser when the
+ person has exactly one team with that app** and hand off straight away.
+ That's a Home-side change on the adoption branch (§12, Home). An
+ optional later refinement: `?team=<label>` taken from the app's own
+ host, which the chooser would honour only if that team is one of the
+ person's.
+
+---
+
+## 7. Menus and popovers (S5, S9)
+
+### 7.1 One floating panel (`menu/menu.css`)
+
+Every floating surface in the suite uses this: account menu, switcher,
+narrow Menu sheet, menubar menus, toolbar pickers' lists, row menus,
+Calendar quick-add, and Docs jump-to and dialogs (radius and shadow only).
+
+```css
+:root {
+ --estilo-float-radius: 9px; /* rastrillo's hard-coded dropdown value, named */
+ --estilo-float-padding: 0.25rem;
+ --estilo-item-padding-block: 0.4rem;
+ --estilo-item-padding-inline: 0.65rem;
+}
+[estilo-menu] {
+ box-sizing: border-box;
+ min-inline-size: 12rem;
+ max-inline-size: min(22rem, calc(100vw - 1rem));
+ max-block-size: calc(100dvh - var(--estilo-bar-block-size, 3.25rem) - 1rem); /* fixes Docs' Format menu clipping at min(20rem, …) */
+ overflow-y: auto; overscroll-behavior: contain;
+ padding: var(--estilo-float-padding);
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius);
+ box-shadow: var(--rst-shadow-pop);
+ font-size: var(--rst-fs-sm);
+ z-index: 30;
+}
+[estilo-menu="sheet"] { position: absolute; inset-inline: 0.5rem; inset-block-start: calc(100% + 4px); max-inline-size: none; }
+[estilo-menu-item] {
+ display: flex; align-items: center; gap: 0.5rem;
+ box-sizing: border-box; inline-size: 100%; min-block-size: 2rem;
+ padding: var(--estilo-item-padding-block) var(--estilo-item-padding-inline);
+ border: 0; border-radius: var(--rst-radius-sm); /* inset and rounded: inside a float */
+ background: none; color: var(--rst-text);
+ font: inherit; text-align: start; text-decoration: none; cursor: pointer;
+}
+[estilo-menu-item]:hover, [estilo-menu-item]:focus-visible { background: var(--rst-accent-soft); }
+[estilo-menu-item]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+[estilo-menu-item][aria-disabled="true"], [estilo-menu-item]:disabled {
+ color: var(--rst-text-faint); background: none; cursor: default;
+}
+[estilo-menu-check] { flex: none; inline-size: 1rem; block-size: 1rem; visibility: hidden; color: var(--rst-accent); }
+[estilo-menu-item][aria-pressed="true"] [estilo-menu-check],
+[estilo-menu-item][aria-current] [estilo-menu-check] { visibility: visible; }
+[estilo-menu-label] { flex: 1 1 auto; min-inline-size: 0; }
+[estilo-menu-key] {
+ flex: none; margin-inline-start: auto; padding-inline-start: 1.5rem;
+ color: var(--rst-text-muted); font: inherit; font-size: var(--rst-fs-xs);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+[estilo-menu] hr { border: 0; border-block-start: 1px solid var(--rst-line); margin: 0.25rem 0; }
+[estilo-menu-heading] {
+ margin: 0; padding: 0.5rem var(--estilo-item-padding-inline) 0.25rem;
+ color: var(--rst-text-muted); font-size: var(--rst-fs-xs); font-weight: 600; /* no text-transform */
+}
+```
+
+Item markup (`menu/templates/menu.html` defines `estilo-menu-item`):
+
+```html
+<a estilo-menu-item href="…" [aria-current="true"]>
+ [<span estilo-menu-check aria-hidden="true">{{icon "check"}}</span>] <!-- only in menus with checkable items -->
+ <span estilo-menu-label>Paged</span>
+ [<kbd estilo-menu-key>⌘P</kbd>]
+</a>
+<form method="post" action="…"><button estilo-menu-item type="submit" name="…" value="…" [aria-pressed="true"]>…</button></form>
+```
+
+- **Checked state:** the check column (`estilo-menu-check`) is present on
+ *every* item of a menu that has any checkable item, so labels stay
+ aligned. It's rendered only in those menus, so plain menus have no
+ empty gutter. Buttons use `aria-pressed="true"` and links use
+ `aria-current="true"`. The check glyph carries the state, not colour.
+ This fixes Docs View → Paged/Infinite (drawn nothing), Format → current
+ block style (drawn as *disabled*), Sheets and Calendar Appearance
+ (unstyled `button[aria-current]` and `aria-pressed`), and Docs' size
+ picker.
+- **Separator:** `<hr>`, 1px `--rst-line`, spanning the panel's inner
+ width.
+- **Shortcut column:** `<kbd estilo-menu-key>`, pushed to the inline end,
+ muted and tabular.
+- **Disabled:** only for *contextual* unavailability, such as Redo with
+ nothing to redo or Paste without permission. Disabled items are faint,
+ have no hover, and stay in place.
+- **Submenus:** there are no inline accordions inside a menu. That removes
+ the Sheets Format ▸ Number/Fill pattern that scrolls the panel. A
+ submenu is a flyout `estilo-menu` anchored to its item's inline end. It
+ isn't needed for R2. Sheets decides in its adoption whether to trim
+ Format (the report's suggestion) or to need a flyout. If it needs one,
+ the flyout becomes a Sheets-built primitive.
+- **Positioning:** a panel is `position: absolute` under its `<details>`
+ (`inset-inline-end: 0` for triggers at the end of the bar,
+ `inset-inline-start: 0` for menubar titles). Where supported, it's
+ enhanced with the same anchor-positioning block rastrillo uses
+ (`position-try-fallbacks: flip-block, flip-inline`). Exclusivity group:
+ `name="estilo-menus"` for bar triggers and `name="estilo-menubar"` for a
+ document menubar.
+
+### 7.2 Menubar titles: square and in-flow
+
+```html
+<nav estilo-menubar aria-label="Menus">
+ <details estilo-menubar-menu name="estilo-menubar">
+ <summary estilo-menubar-title>File</summary>
+ <div estilo-menu>…</div>
+ </details>
+ …
+</nav>
+```
+
+```css
+[estilo-menubar] { display: flex; align-items: stretch; block-size: 2rem; }
+[estilo-menubar-menu] { position: relative; }
+[estilo-menubar-menu] > [estilo-menu] { position: absolute; inset-inline-start: 0; inset-block-start: calc(100% + 2px); }
+[estilo-menubar-title] {
+ display: flex; align-items: center; block-size: 100%;
+ padding-inline: 0.6rem; border-radius: 0; /* square: in-flow */
+ color: var(--rst-text); font-size: var(--rst-fs-sm);
+ list-style: none; cursor: default; user-select: none;
+}
+[estilo-menubar-title]::-webkit-details-marker { display: none; }
+[estilo-menubar-title]:hover { background: var(--rst-surface-2); }
+[open] > [estilo-menubar-title] { background: var(--rst-accent-soft); }
+[estilo-menubar-title]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+```
+
+This replaces Docs' `[docs-menu-*]` (6px panel, full-bleed rows, 6px
+rounded titles) and Sheets' `[data-menubar] > [rst-dropdown]` (6px titles,
+no hover background). Script enhancement: while one menu is open, hovering
+another title opens that one.
+
+### 7.3 Menu copy rules (S9)
+
+- **Unbuilt features are hidden, not disabled.** An item appears only if
+ it works in this build. Docs: the whole **Insert** menu goes (every
+ item is unbuilt), and so do **Edit → Find and replace** and **View →
+ Show ruler**. **Body text** shows checked, not disabled. Sheets: the
+ vendor context menu goes (B7). A menu with no items isn't rendered.
+- **Ellipsis `…` (U+2026)** goes only on items that need more input before
+ anything happens (a dialog or form): "Share…", "Comment…",
+ "Word count…", "Keyboard shortcuts…", "Rename…". Items that simply
+ navigate get none: "Version history", "Open as table".
+- **Platform-correct shortcuts.** `menu.Shortcut(r, "Mod+Shift+K")` picks
+ the platform **server-side**, so nothing flips after paint. The platform
+ is Apple if `Sec-CH-UA-Platform` is `"macOS"` or `"iOS"`, or if the
+ `User-Agent` contains `Macintosh`, `iPhone` or `iPad`. Pages already
+ send `Cache-Control: no-store` or `private`, and `Vary: User-Agent` is
+ added where they don't.
+
+| Spec token | Apple | Other |
+|---|---|---|
+| `Mod` | ⌘ | Ctrl |
+| `Shift` | ⇧ | Shift |
+| `Alt` | ⌥ | Alt |
+| `Ctrl` (literal) | ⌃ | Ctrl |
+| `Enter` / `Delete` / `Tab` / `Esc` | ↩ / ⌫ / ⇥ / ⎋ | Enter / Delete / Tab / Esc |
+| format | modifiers in the order ⌃⌥⇧⌘ then the key, no separator: **⇧⌘K** | joined with `+`: **Ctrl+Shift+K** |
+
+ The same helper renders Docs' Keyboard shortcuts dialog and Sheets'
+ Help sheet and toolbar tips. "Ctrl Z", "Ctrl + C" and "Undo (Ctrl+Z)"
+ all go. Tooltips read `Undo (⌘Z)`.
+
+---
+
+## 8. Account menu (S4)
+
+### 8.1 Markup
+
+```html
+{{define "estilo-account"}}
+<details estilo-account name="estilo-menus">
+ <summary estilo-bar-trigger aria-label="{{.TriggerLabel}}"> <!-- "Account: paul@tito.io" -->
+ {{if .Initials}}<span estilo-avatar aria-hidden="true">{{.Initials}}</span>{{else}}{{.Icons.Person}}{{end}}
+ </summary>
+ <div estilo-menu estilo-account-panel>
+ <div estilo-menu-identity>
+ <span estilo-menu-identity-label>{{.Labels.SignedInAs}}</span> <!-- "Signed in as" -->
+ {{with .Name}}<span estilo-menu-identity-name dir="auto">{{.}}</span>{{end}}
+ {{with .Address}}<span estilo-menu-identity-address dir="auto">{{.}}</span>{{end}}
+ </div>
+ {{- if .Items}}<hr>{{range .Items}}{{template "estilo-menu-item" .}}{{end}}{{end}}
+ <hr>
+ <p estilo-menu-heading id="estilo-appearance">{{.Labels.Appearance}}</p> <!-- "Appearance" -->
+ <form method="post" action="{{.Appearance.Action}}" role="group" aria-labelledby="estilo-appearance">
+ <input type="hidden" name="return_to" value="{{.Appearance.Return}}">
+ {{- range .Appearance.Options}} <!-- Light, Dark, System -->
+ <button estilo-menu-item type="submit" name="mode" value="{{.Value}}"{{if .Current}} aria-pressed="true"{{else}} aria-pressed="false"{{end}}>
+ <span estilo-menu-check aria-hidden="true">{{$.Icons.Check}}</span><span estilo-menu-label>{{.Label}}</span></button>
+ {{- end}}
+ </form>
+ <hr>
+ <p estilo-menu-heading>{{.Labels.Help}}</p> <!-- "Help" -->
+ {{range .Help}}{{template "estilo-menu-item" .}}{{end}} <!-- app help items, then "Open source" last -->
+ <hr>
+ <form method="post" action="{{.SignOut.Action}}">
+ <button estilo-menu-item type="submit"><span estilo-menu-label>{{.SignOut.Label}}</span></button> <!-- "Sign out" -->
+ </form>
+ </div>
+</details>
+{{end}}
+```
+
+```css
+[estilo-account] { position: relative; }
+[estilo-account-panel] { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); min-inline-size: 15rem; }
+[estilo-avatar] {
+ display: inline-flex; align-items: center; justify-content: center;
+ inline-size: 1.75rem; block-size: 1.75rem; border-radius: 50%; /* an identity mark, not a highlight */
+ background: var(--rst-accent-soft); color: var(--rst-accent);
+ font-size: var(--rst-fs-xs); font-weight: 650;
+}
+[estilo-menu-identity] { display: grid; gap: 0.1rem; padding: 0.5rem var(--estilo-item-padding-inline); }
+[estilo-menu-identity-label] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); }
+[estilo-menu-identity-name] { font-weight: 600; overflow-wrap: anywhere; }
+[estilo-menu-identity-address] { color: var(--rst-text-muted); overflow-wrap: anywhere; }
+[estilo-menu-identity-name] + [estilo-menu-identity-address] { font-size: var(--rst-fs-xs); }
+```
+
+The account group is `role="group"`, and the pressed state is set on
+every option (`true` or `false`), so a screen reader hears "Dark,
+pressed".
+
+### 8.2 Order and content (the same in every app)
+
+1. **Identity row**, not interactive: "Signed in as", then the name if
+ there is one, then the address. The trigger is initials (first letter
+ of name, or of the address's local part, uppercase, one or two
+ letters). Its accessible name is "Account: {address}". The hub
+ directory can't read the address (by design, Home §B). There the
+ identity row shows only "Signed in", and the trigger shows the person
+ icon.
+2. **App items**, optional and account-scoped. These are pages, so no
+ ellipsis.
+ - Calendar: Settings · Booking pages · Import a calendar
+ - Slides: Agents
+ - Calls: Settings
+ - Home directory: Passkeys · Recovery codes (links to `id.*`)
+ - Any app with a Home link offer: **Connect your Oficina account** (see
+ §13 Q3; today it says "Connect Home account"). It's hidden when the
+ session is already a Home session, as Docs, Calls and Memoria do
+ today.
+3. **Appearance:** Light · Dark · System, with the current one checked.
+4. **Help:** app help items first (Docs "Keyboard shortcuts…", Sheets
+ "Keyboard shortcuts…" (its Help menu moves here), Slides "Keyboard
+ shortcuts…" once it lists them), then **Open source** last in every
+ app. The standalone Help dropdowns in Calendar, Calls, Memoria, Slides
+ and Home go.
+5. Separator.
+6. **Sign out.** It appears exactly once in the app.
+ - Removed: Docs File → Sign out; Home's full-width button under the
+ list; Calls' and Memoria's bar buttons.
+ - **Gate:** on every signed-in page, `page.getByText('Sign out', {exact:true})`
+ resolves to exactly one element.
+
+### 8.3 Appearance mechanism
+
+- The form POSTs to `/appearance` with `mode` ∈ `light | dark | system`
+ and `return_to`.
+- `chrome.HandleAppearance` validates both. `return_to` must start with
+ `/`, and not with `//` or `/\`. The handler sets the host-only cookie
+ `estilo_appearance` (1 year, `SameSite=Lax`, `Secure`, `HttpOnly`) and
+ answers 303 to `return_to`.
+- The layout writes `<html data-theme="{{chrome.ThemeAttr .Mode}}">`
+ (`light`/`dark`, absent for `system`). That's the toggle `day.css`
+ already implements (`:root[data-theme=…]`). The attribute is in the
+ first bytes, so there's no flash.
+- This is Sheets' existing design (`theme.go`), promoted. Sheets'
+ `/mode`, `sheets_mode` and `"auto"` retire. Calendar's "Auto" becomes
+ "System".
+- A per-host cookie means each app remembers separately (see §13 Q6).
+
+### 8.4 Signed-out corner
+
+- A plain `rst-btn` link, **"Sign in"**, to
+ `/signin?return_to=<current path+query>`. It's not primary: primary is
+ for page actions.
+- No appearance menu when signed out. The page follows the system setting,
+ which fixes Calendar's "Account" holding only Light/Dark/Auto.
+- Guests in Calls get the same "Sign in" corner.
+- The pages that *are* sign-in, sent and signed-out show nothing in the
+ corner.
+- Credits (Open source) is reachable while signed out from a footer link,
+ "Open source", on the sign-in page.
+
+---
+
+## 9. Sign-in and sign-out (S8, B2's in-app half)
+
+### 9.1 The flow (Home on, which is production)
+
+```
+app GET /deep/link no session
+ → 303 /signin?return_to=%2Fdeep%2Flink every app (Slides/Calls/Memoria fixed, §9.4)
+ → connect.Signin remembers return_to → /auth/start → id.oficina.works/authorize?consumer=<app>
+ → id.* /signin "Sign in" · to continue to {App} · [Email me a link] (branded, §9.3)
+ → id.* /signin/sent "Check your email"
+ → email "Sign in to {App}" → id.* "Finish signing in" [Sign in] → (passkey step if enrolled)
+ → id.* /authorize/resume (auto-POST; the fallback copy is hidden for 1.5s, §9.3)
+ → app /auth/finish → 303 /deep/link the exact page you came from
+```
+
+With Home off (standalone and dev), the app's local sign-in pages use the
+same templates and copy. Only the brand in the bar differs: the app, not
+Oficina.
+
+### 9.2 Copy deck (en, used everywhere)
+
+Voice: second person, contractions, typographic apostrophes (’), and
+"email", never "mail". Times: "15 minutes", in the page and the email
+alike.
+
+| Surface | Element | Copy |
+|---|---|---|
+| Entry (bar, signed-out and public pages) | button | **Sign in** |
+| Sign-in page | `<title>` | Sign in · {App} (id.*: Sign in · Oficina) |
+| | heading | **Sign in** |
+| | lede | to continue to {App} (id.* with a known consumer. Directory consumer: "to Oficina". Local mode: omitted) |
+| | label | Email address |
+| | help (below field) | We’ll email you a link that signs you in. It works once and expires in 15 minutes. |
+| | submit | **Email me a link** |
+| | footer link | Open source |
+| Sent state (replaces the form) | `<title>` | Check your email · {App} |
+| | heading | Check your email |
+| | body | If {address} can sign in here, we’ve sent it a link. It works once and expires in 15 minutes. |
+| | link | Use a different address (→ `/signin?return_to=…`, keeping the return) |
+| Errors (negative callout above the field, `role="alert"`) | `err=address` | That doesn’t look like an email address. (It goes in the field's error slot; forms set `novalidate` so the browser bubble never shows.) |
+| | `err=rate` | Too many attempts. Wait a few minutes, then try again. |
+| | `err=expired` | That link has expired or was already used. Enter your email to get a new one. |
+| | `err=denied` (local mode only; Home never confirms) | That address can’t sign in here. Ask whoever runs this team to add you. |
+| | `err=removed` | Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong. |
+| | `err=1` (generic) | We couldn’t sign you in. Enter your email to get a new link. |
+| Link landing (id.*, or local verify) | `<title>` / heading | Finish signing in |
+| | body | Select Sign in to finish signing in on this device. |
+| | button | **Sign in** |
+| Hand-off interstitial (id.* `/authorize/resume`) | body (hidden 1.5s) | Continuing to {App}… |
+| | button | Continue |
+| Email | subject | Sign in to {App} (directory: Sign in to Oficina) |
+| | body | Here’s your link to sign in to {App}:<br><br>{link}<br><br>It works once and expires in 15 minutes. If you didn’t ask for it, you can ignore this email. |
+| Signed-out page (`/signed-out`, every app) | `<title>` | Signed out · {App} |
+| | heading | You’re signed out |
+| | body | You’ve signed out of {App} on this device. (directory: "You’ve signed out of Oficina on this device.") |
+| | button | **Sign in** (primary, → `/signin`) |
+| Already signed in, `GET /signin` | — | 303 to `return_to` or `/`, with no form (fixes Slides) |
+
+What this retires:
+
+- "Continue", "Send link", "Send me a link", "Send me a sign-in link"
+- "A sign in link should be on its way…"
+- "Sign in to Oficina Sheets"
+- "Sign in with Home", "Continue to Home", "Signed out of Docs"
+- "(We email you a link. There is no password to forget.)"
+- "memoria holds your team's work…" on the sign-in page, and Memoria's
+ footer claim when signed out
+- "expires shortly"
+- The per-app email subjects ("Your calendar sign-in link", …)
+
+Memoria's `?outcome=` switch was fixed in R1 (B5). Its strings become the
+ones above.
+
+### 9.3 Branded sign-in pages (id.* and local)
+
+`chrome/templates/signin.html` defines `estilo-signin`,
+`estilo-signin-sent`, `estilo-signin-finish` and `estilo-signed-out`. Each
+is the body of a page in the normal layout with the estilo bar. On id.*
+the bar has the brand only: Oficina icon plus "Oficina", linking to the
+directory origin, with no account and no switcher.
+
+```html
+{{define "estilo-signin"}}
+<section estilo-signin>
+ <img estilo-brand-icon="listing" src="{{.App.Icon}}" alt="" width="40" height="40">
+ <h1>{{.Labels.Heading}}</h1>
+ {{with .Labels.Lede}}<p estilo-signin-lede>{{.}}</p>{{end}}
+ {{with .Error}}<div rst-callout rst-tone="negative" role="alert">…{{.}}…</div>{{end}}
+ <form rst-form method="post" action="{{.Action}}" novalidate>
+ <input type="hidden" name="return_to" value="{{.Return}}">
+ {{/* rastrillo field-text: Email address, type=email, autocomplete=email, the field error slot */}}
+ <div rst-form-foot><button rst-btn="primary" type="submit">{{.Labels.Submit}}</button></div>
+ </form>
+ <p estilo-signin-foot><a href="{{.OpenSource.Href}}">{{.OpenSource.Label}}</a></p>
+</section>
+{{end}}
+```
+
+```css
+[estilo-signin] { box-sizing: border-box; max-inline-size: 26rem; margin: 4rem auto var(--rst-sp-6); padding-inline: var(--rst-sp-4); }
+[estilo-signin] h1 { margin: var(--rst-sp-3) 0 0; font-size: 1.375rem; font-weight: 600; }
+[estilo-signin-lede] { margin: 0.25rem 0 var(--rst-sp-4); color: var(--rst-text-muted); }
+[estilo-signin] [rst-form] { max-inline-size: none; }
+[estilo-signin-foot] { margin-block-start: var(--rst-sp-6); font-size: var(--rst-fs-xs); color: var(--rst-text-muted); }
+```
+
+The page is a left-aligned narrow column with the app icon at listing
+size. It isn't a card, so nothing in-flow is framed. The whole form is
+capped at 26rem, which fixes Sheets' right-aligned button at 704px, Home's
+callout and field at different widths, and Calls' three left edges.
+
+The Home interstitial gets `[estilo-handoff] { animation: estilo-reveal 0s 1.5s both; }`
+with `@keyframes estilo-reveal { from { visibility: hidden } to { visibility: visible } }`.
+The fallback appears only if the auto-submit stalls. A `<noscript>` copy
+shows at once.
+
+### 9.4 Return to the originating page (every app)
+
+- One parameter name, **`return_to`**. It's used by rastrillo `sessions`
+ and by `connect`, and validated with `handoff.SafePath` or
+ `sessions.SafeReturn`.
+- **Slides, Calls, Memoria** (Home §F.2) guard with rastrillo
+ `auth.RequireSession`, whose `refuseSession` drops the return path.
+ Each wraps it in an app-owned `requireSession` middleware: on a GET or
+ HEAD with no session it answers
+ `303 /signin?return_to=` + `url.QueryEscape(r.URL.RequestURI())`, and
+ anything else gets 403. An upstream rastrillo issue is filed for
+ `refuseSession` to do the same, as `sessions.Require` already does.
+- **Local mode** carries `return_to` through Begin → emailed link →
+ Verify, falling back to `/`. Calls' join-link cookie path keeps working
+ (`guest.go:16`).
+- **Gate (per app, Playwright):** open a deep link signed out, complete
+ sign-in, and assert that `page.url()` equals the deep link. Cases: Docs
+ document, Sheets grid, Slides deck, Calendar week, Calls meeting,
+ Memoria document.
+
+### 9.5 The in-app sign-out gap (chain to `id.*/signout`)
+
+**The gap:** `fix/r1-signout-ends-id-session` makes the *directory's* Sign
+out end the id.* session. It does this with a 307 to
+`id.*/signout`, admitted by `signoutFromHome`, which accepts only the
+directory origin. An **app's** Sign out (`connect.Routes.Signout`) clears
+only the app's session and lands on `/signed-out`. That page's "Sign in"
+then passes silently through the live 30-day id.* session.
+
+**Fix:** two halves, both in the home repo.
+
+1. **`connect.Routes.Signout`** (the app side) clears the app session and
+ the connect cookies exactly as today. Then, **when Home is configured**,
+ it answers `307` to `{Issuer}/signout`. It keeps the POST (307, not
+ 303), for the same reason `signout.go` gives: a GET sign-out is one any
+ `<img>` can trigger. The app's CSP `form-action` already has to allow
+ the issuer, so `connect.FormPolicy(issuer)` is checked to include it. In
+ standalone mode it's unchanged: 303 to `/signed-out`.
+2. **`handleCredentialSignout`** (the id.* side) widens its admission from
+ "the directory origin" to **"any registered consumer origin"**, which
+ are the values of `HOME_CONSUMERS`. Those are the same allowlist that
+ decides who may receive an assertion, and the directory is already one
+ of them (`consumers["directory"]`).
+ - The origin is read as today: `Origin` when it names one, otherwise
+ `Referer`'s origin. Chromium sends `Origin: null` after a
+ cross-origin 307, and under `strict-origin-when-cross-origin` the
+ Referer is exactly the consumer origin.
+ - `Sec-Fetch-Site` must be `same-site` (team instances under
+ `oficina.works`) or absent. `cross-site` is admitted only for an
+ origin that is registered *and* isn't under the suite domain (a
+ preview origin).
+ - **The redirect target is derived from the matched origin, never from
+ a parameter:** `303 {matched origin}/signed-out`. The directory keeps
+ landing on its own `/signed-out`. No open redirect is possible.
+ - Refusal stays loud, but useful. `GET id.*/signout` renders a branded
+ page: "Finish signing out" with a **Sign out** button (a same-origin
+ POST, which `csrf.SameOrigin` admits). After it, the browser lands on
+ the directory's `/signed-out`. A browser that strips both headers
+ can always finish by hand.
+3. **Docs** keeps its own hand-off code (`internal/docs/home_*.go`, off
+ the shared path, Home §F.3). It gets the same 307 in its sign-out
+ handler on its adoption branch. Moving Docs onto `home/connect` is
+ R3.
+
+**What this still doesn't do:** end *other* apps' sessions in the same
+browser. Home keeps no list of where it has handed out assertions (the
+comment in `signout.go`). After this fix no app can get a *new* assertion
+without the email or passkey step, but a second app tab stays signed in
+until its own session ends. The signed-out copy in §9.2 is written to be
+true under that limit. Suite-wide sign-out is §13 Q5.
+
+**Order:** B2 (`fix/r1-signout-ends-id-session`) merges first. Then a Home
+branch lands the credential-side widening and the `connect` change
+together. Then each app bumps its `amadan.net/oficina/home` pin on its
+adoption branch.
+
+**Gate:** extend `polish-audit/home-run/signout-e2e{,-firefox,-webkit}.mjs`
+with the flow: sign in to an app through Home, sign out *in the app*,
+click "Sign in", and assert that the id.* email form appears with no
+silent re-entry, in all three engines. Add a Go test on the credential
+side: a registered consumer origin via `Origin`, and via `Referer` with
+`Origin: null`, is admitted and redirected to that origin's `/signed-out`.
+An unregistered origin, a same-site team host that isn't registered, and
+no evidence are all refused.
+
+---
+
+## 10. Page titles and names (S6)
+
+**Separator:** ` · ` (a space, U+00B7, a space). Never "—" or "|". The
+layout builds the title as `{{range .TitleParts}}{{.}} · {{end}}{{.App.Name}}`,
+so no handler can drift. It's asserted per app.
+
+| Surface | Pattern | Examples |
+|---|---|---|
+| App root (the list) | `{App}` | Docs · Sheets · Slides · Calls · Memoria |
+| Calendar root (a dated view) | `{view heading} · Calendar` | Wednesday 23 September · Calendar |
+| Open object | `{Object title} · {App}` | Packing List — Thursday Site Visit · Docs; Untitled document · Docs; Commission Q2 · Sheets |
+| Object sub-page | `{Page} · {Object title} · {App}` | Share · Commission Q2 · Sheets; Version history · Commission Q2 · Sheets; Table view · Commission Q2 · Sheets; Speaker notes · Welcome to Slides · Slides; Meeting settings · Weekly sync · Calls |
+| Other page | `{h1} · {App}` | Settings · Calls; Agenda · Calendar; Sources · Memoria; Agents · Slides; Open source · Docs |
+| Search results | `“{query}” · {App}` | “revenue” · Memoria |
+| Public, read-only | `{Object title} · {App}` | the same as the open object |
+| Sign-in and sign-out pages, errors | per §3.2 and §9.2 | Sign in · Calendar; Signed out · Calls; Page not found · Docs |
+| Hub | `Oficina` (front page and directory); `{Page} · Oficina` (id.* and other hub pages) | Check your email · Oficina; Passkeys · Oficina |
+
+**Rule:** the tab title's `{Page}` equals the page's h1. This fixes
+Calendar's "Edit" vs "Edit event" and "Import" vs "Import a calendar".
+
+**Naming table (user-visible):**
+
+| Thing | Name | Retire |
+|---|---|---|
+| Hub, as a place | Oficina (the suite's front door) | "Home" in user copy ("Back to Home", "Return to Home…") |
+| Hub account | your Oficina account (§13 Q3) | "Home account" |
+| Docs | Docs | "Documents" as the brand (the list h1 "Documents" stays) |
+| Sheets | Sheets | "Oficina Sheets" |
+| Slides | Slides | none |
+| Calendar | Calendar | "Hello" (layout default and `home.html`) |
+| Calls | Calls | none (no "Meet" is visible) |
+| Memoria | Memoria | "memoria" (brand, titles, sentence starts, email) |
+
+---
+
+## 11. Square-rule inventory (S11)
+
+**The rule as applied:** a highlight (hover, selected, current or open
+background) on something **in the page flow** is square. Things that
+**float** (menus, popovers, dialogs, tooltips, toasts) are rounded
+(`--estilo-float-radius` 9px), and so are highlights *inside* a float
+(inset items, 6px). This spec assumes containers and controls in the flow
+(cards `rst-box`/`rst-list`, inputs, bordered buttons) keep `day`'s radii,
+because changing them would be a theme divergence (§13 Q4).
+
+| Surface | Today | R2 |
+|---|---|---|
+| Docs menubar titles | 6px hover and open | square (§7.2) |
+| Sheets menubar triggers | 6px, no hover background | square, `--rst-surface-2` hover (§7.2) |
+| Borderless toolbar buttons (Docs `[docs-tool]`, Sheets `[data-tool]`) | 6px hover and pressed | **square**: they're in-flow highlights, not bordered buttons (interpretation; §13 Q4) |
+| Bar triggers (Menu, switcher, account) | rastrillo summaries, colour only | square hover and open background (§5.2) |
+| Bar nav current | 2px underline | 2px underline (already square) |
+| Account, switcher and menu items | 6px inset inside a 9px panel | unchanged (inside a float) |
+| Docs menu panels 6px, jump-to 8px, dialogs 6px; Calendar quick-add 6px with its own shadow | mixed | all 9px `--estilo-float-radius` + `--rst-shadow-pop` |
+| Docs jump-to rows | 6px inset selected, no hover | inside a float: 6px inset, with a hover that matches `aria-selected` |
+| Sheets table-view cell link | 6px pill inside a square cell | square, fills the cell (R3, Sheets) |
+| Slides deck rows, viewer slide list, inspector block list, strip selection | 6–8px | square highlight; thumbnails keep their frame (R3, Slides) |
+| Slides segmented switcher | hard-coded 8px | `var(--rst-radius)` (it's a bordered control) |
+| `rst-list` first/last-row hover (Docs, Home, Slides, Calendar) | 7px corners | **conforming**: the corner belongs to the card, and a square highlight clipped by a rounded card looks the same. No change |
+| Memoria results, table rows, primed bar | square | conforming |
+| Calendar `[cal-slot]` hover | square | conforming |
+| **Calendar event chips** (day and week grid blocks, month and agenda chips), `calendar.css:118` | 6px | **open question, §13 Q1** |
+| Calendar `[cal-book-pane]` (in-flow panel) | 6px | card: keeps `var(--rst-radius)` under Q4's assumption |
+
+**Q1, recommendation: square, everywhere.** A timed block in the day and
+week grid is a span of time that has to line up with the hour rules. With
+rounded corners, back-to-back events (Coffee 09:00 → Dentist 10:00 in
+`calendar-07`) show notches at every join. The 3px coloured
+inline-start rule already gives the chip its identity. Month and agenda
+chips would also go square, so there's one rule and no exemption. The
+quick-add pane is a popover, so it's already covered by the float rule
+(9px), not by an exemption.
+
+---
+
+## 12. Adoption plan
+
+### 12.1 Order, branches, the one rule
+
+| # | Repo | Branch | Depends on | Notes |
+|---|---|---|---|---|
+| 0 | estilo | `feat/shared-chrome` (base, menu, chrome, brand Go) | this spec approved | Built *with* Docs: Docs pins the branch's pseudo-version while both are in review. Merge estilo first, then re-pin Docs to the merge. |
+| 1 | home | `feat/r2-signout-chain` | B2 merged | credential admits consumer origins; `connect.Signout` 307; `connect.Credentials.Directory`; `GET id.*/signout` page |
+| 2 | **docs** | `polish/r2-shared-chrome` | 0, 1 | **first adopter** |
+| 3 | **sheets** | `polish/r2-shared-chrome` | 2 merged | **second adopter.** Every correction it needs lands as an estilo branch `fix/shared-chrome-sheets` *before* anyone else adopts. Until Sheets has adopted, estilo's chrome is a home, not an agreed interface. |
+| 4 | slides | `polish/r2-shared-chrome` | 3 + estilo corrections merged | |
+| 5 | calendar | `polish/r2-shared-chrome` | 4 | Q1 applied here |
+| 6 | memoria | `polish/r2-shared-chrome` | 4 | |
+| 7 | meet (Calls) | `polish/r2-shared-chrome` | 4 | |
+| 8 | home | `polish/r2-shared-chrome` | 4 | directory bar, branded id.*, `/apps/<slug>` single-team skip |
+
+5–8 are independent of each other once 4 has merged. Each app branch is
+its own discussion and CI run, gets a whole-branch review by the
+highest-capability model, and follows the repo's AGENTS.md (worktree from
+a fresh `origin/main`, `make ci`, stop before merge).
+
+**Common checklist, in order, for every app branch:**
+
+1. Bump `amadan.net/oficina/estilo.git` to main (S13; Docs is 18 commits
+ behind) and `amadan.net/oficina/home` for `connect`. Record the
+ rastrillo pin: Docs, Sheets, Calendar, Memoria and Home are on
+ `carlosframework/rastrillo v0.23.0`; Calls and Slides serve tokens
+ from `amadan.net/rastrillo v0.26.1-…`. Estilo's token tests run
+ against v0.23.0, so a v0.26 app runs `chrome`'s defined-token check
+ against its own `tokens.css` as well (the two-pin trap, AGENTS.md).
+2. Serve and link `base.css`, `menu.css` and `chrome.css` in the §0.5
+ order.
+3. Layout: `estilo-bar` in place of the app's topbar or header markup.
+ The brand is set in the page-data builder, so every page has it.
+4. Account menu per §8.2. Add `/appearance`. Delete every other Sign out
+ and every standalone Help dropdown.
+5. Switcher: build `chrome.Switcher` from `connect.Credentials.Directory`.
+ It's nil when Home isn't configured.
+6. Error page and router wiring per §3.3, and the two gate tests.
+7. Callout tones per §4, and the grep gate.
+8. Titles per §10, built in the layout, with an assertion test.
+9. Sign-in, sent and signed-out templates per §9.2 and §9.3. `return_to`
+ per §9.4. Sign-out chain per §9.5 (bump home).
+10. Remove the app CSS these replace (listed per app below) and prove it's
+ unused.
+
+**Per app, what's specific:**
+
+- **Docs (first):**
+ - Replace rastrillo `topbar` usage in `head.html`, `list.html`,
+ `show.html`, `share.html`, `public.html` and `home_link.html`. That
+ fixes the "Home" brand on Share and the 45px public bar.
+ - The editor header (bar, menubar, toolbar) goes sticky as one block.
+ - Rebuild the menubar on `estilo-menubar` and `estilo-menu`. Delete
+ `[docs-menu-*]` (`show.html:887–982`).
+ - Remove the Insert menu, Find and replace, Show ruler and File → Sign
+ out. Show View and Format state with checks. Use `menu.Shortcut` in
+ menus and the shortcuts dialog. Add "Open a document… ⌘K". Add
+ ellipses per §7.3.
+ - Jump-to and dialogs take the float radius.
+ - Public notice → info.
+ - Docs' own `home_*` sign-out gets the 307.
+- **Sheets (second):**
+ - Replace its own `[rst-shell-topbar]` in `layout.html`. The grid
+ page's full-height layout is the expected source of estilo
+ corrections, for example the sticky bar inside a `100dvh` grid.
+ - Its `/mode` handler and cookie give way to `chrome.HandleAppearance`.
+ - Rebuild the menubar (`sheets.css:188–311`) on estilo. Help moves into
+ the account menu.
+ - Tooltips via `menu.Shortcut`.
+ - Callouts: `warn` → warning, errors → negative.
+ - Drop "Oficina Sheets". The signed-out landing `/` gives way to
+ `/signin`.
+ - The table-view cell pill goes square.
+- **Slides:**
+ - `account.html` and `help.html` go, and with them `account.html`'s
+ ownership of `head`.
+ - Presenter view, remote and the projector surface render **no bar**:
+ they're capability pages, and a signed-in presenter stops seeing
+ "Account → Sign in".
+ - `requireSession` gets `return_to`.
+ - Titles change from "— Slides".
+- **Calendar:**
+ - The account menu takes Settings, Booking pages and Import a
+ calendar. Appearance: Auto → System.
+ - Nav `aria-current` comes free from the bar. The `home.html` title
+ fixes "Hello".
+ - The error page resolves the viewer.
+ - Apply the Q1 chip decision.
+- **Memoria:**
+ - A new bar replaces `rst-page-header` nav: it no longer wraps, and the
+ header drops from 97px to 52px.
+ - Sources becomes a nav link with `aria-current`. Account menu (new).
+ - The name becomes "Memoria" everywhere. `requireSession` gets
+ `return_to`.
+ - Delete `memoria.css:9–44`, the header and search overrides that the
+ bar replaces.
+- **Calls:**
+ - The account corner becomes the menu (Settings is an item).
+ - Nav: Meetings · Settings. The header drops from 81px to 52px, and the
+ `calls-shell.css:3–27` workarounds go.
+ - `rst-notice` → tones. Guests get the "Sign in" corner.
+ - Credits resolves the viewer. `requireSession` gets `return_to`.
+- **Home:**
+ - The directory gets the bar: brand Oficina, account menu with a
+ "Signed in" identity row, Passkeys, Recovery codes and Sign out. No
+ switcher.
+ - The id.* pages use `estilo-signin*`. The interstitial is hidden for
+ 1.5s. "Home" leaves user copy.
+ - `/apps/<slug>` skips the chooser for a single team. `NotFound` on
+ both origins.
+ - The public front page (`static/site/`) is out of scope. Its header's
+ "Sign in" keeps its own design, but uses the label "Sign in" and
+ `/signin`.
+
+### 12.2 How to verify
+
+Every adoption branch reports all of the following in its discussion.
+Screenshots are saved to `polish-audit/shots-r2/`, with the same file
+names as the originals.
+
+1. **Re-run that app's audit script** with its output directed to
+ `shots-r2/`. The scripts: `scripts/docs-shots.mjs`, `docs-shots2.mjs`;
+ `sheets-audit.mjs`; `calendar-scripts/audit.mjs`, `audit2.mjs`;
+ `scripts/calls-audit.mjs`; `scripts/memoria-shots.mjs`,
+ `memoria-extra.mjs`; `slides-work/cap1–5.mjs`, `flick.mjs`;
+ `home-run/walk.mjs`, `narrow.mjs`, `signout-e2e*.mjs`. Playwright
+ comes from `oficina/meet/node_modules`. The run recipes are in each
+ report's section A.
+2. **Before/after pairs** that must be looked at side by side:
+ - the list page (desktop and 390px)
+ - the account menu open
+ - a menubar menu open with hover (Docs, Sheets)
+ - the 404 page signed in and signed out
+ - sign-in, sent and error states
+ - after sign-out
+ - Menu open at 390px
+3. **A shared check script**, `polish-audit/r2-chrome-check.mjs`, written
+ on the Docs branch and reused by each later app with a URL list. For
+ each URL at 1440×900 and 390×844, signed out and signed in, it
+ asserts:
+ - `[estilo-bar]` is exactly 52px and doesn't overflow
+ - `document.title` matches `^(.+ · )*(Docs|Sheets|Slides|Calendar|Calls|Memoria|Oficina)$`
+ - no link's computed colour is `rgb(0, 0, 238)` or `rgb(85, 26, 139)`
+ - there's exactly one visible or reachable "Sign out"
+ - the account trigger is the last child of `[estilo-bar-end]`
+ - `[estilo-menu]` has a computed `border-radius` of `9px`
+ - menubar titles and bar triggers have `0px`
+ - the switcher is present or absent as configured
+ - an unknown route returns 404 `text/html` with the bar
+4. **CLS:** the PerformanceObserver used in `docs-shots.mjs:30`, recorded
+ across:
+ - sign-in → first signed-in page
+ - list → object → list
+ - error → home
+
+ The target is **CLS < 0.01** for each, and a bar `top`/`height` that
+ is identical before and after every hop. This is what proves S7's
+ 45 → 51px jump is gone.
+5. **Return-to and sign-out e2e** per §9.4 and §9.5 gates.
+6. **Prove the gates ran.** Per the R1 brief and the "gates report false
+ green" lesson, each new Go or browser test is shown failing on the
+ pre-change tree first, and the discussion quotes both runs.
+
+Estilo's own gate for branch 0: `make test`. That includes:
+
+- the prefix tripwire over the three new CSS files
+- `TestMatchesDayByteForByte`, which must stay untouched and passing
+- `Test{Base,Menu,Chrome}UseDefinedTokens`
+- template render tests with twelve caller locales, as `homeconnect`
+ does
+- `menu.Shortcut` table tests covering Apple and other platforms and
+ every modifier combination
+
+The bar-height and no-wrap proof lives in Docs' browser test, because
+estilo has no browser harness (AGENTS.md, gallery).
+
+---
+
+## 13. Decisions that are Paul's
+
+1. **Calendar event chips:** square (recommended, §11), or exempt?
+2. **App switcher:** include it in R2 (recommended; it costs one config
+ value per app and one Home change), or leave the block unrendered?
+ Also the label for the directory link: "All apps" (proposed), or
+ "Oficina"?
+3. **Account-connection wording, now that the hub isn't "Oficina":**
+ today's strings say "Connect Home account", "Sign in with Home" and
+ "Back to Home". Proposed: never name the hub, and call the account
+ **"your Oficina account"** (the suite account). So "Connect your
+ Oficina account", "Sign in", and "All apps". Or does the hub get a
+ name?
+4. **Scope of "rounded corners only on floating things":** this spec
+ keeps `day`'s radii on in-flow *containers and bordered controls*
+ (cards, inputs, buttons), and squares only in-flow *highlights*,
+ including borderless toolbar buttons. If cards and buttons should go
+ square too, that's a theme divergence and needs an AGENTS.md decisions
+ row (ruling 6).
+5. **Suite-wide sign-out:** should signing out of one app also end the
+ other apps' sessions in the same browser? It doesn't in this spec
+ (§9.5). It would need Home to track where it hands out assertions,
+ plus a front-channel logout.
+6. **Appearance scope:** per app (this spec's host-only cookie), or one
+ preference for the whole suite (a `Domain=oficina.works` cookie, which
+ doesn't work for preview origins)?