oficina / estilo Public

Spec: revise shared chrome after Fable's review (#9)

Addresses findings 1-17: prefix-lint fix in the sign-in CSS; section 6.2
rewritten around Home's real /apps/<slug> behaviour with the changes on
home feat/r2-signout-chain; one exclusivity group; published bar height
and z-index ceiling with sticky offsets; an editable-title slot; verified
http.Error inventory and recoverer placement; SafeReturn/SafePath only;
Referrer-Policy audit and a branded 403 for the sign-out chain; corrected
Docs, Sheets, Memoria, Calls, Slides and Calendar checklists. Adds Q7 and
the reviewer's recommendation to each open question in section 13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6520ab4268b8d29436fdea8b5bd2cec3c4b54a0c parent 8d7bd9b
1 file changed, +823 −253
  • docs/superpowers/specs/2026-09-23-shared-chrome.md +823 −253

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.003s
?   	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.005s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.004s
ok  	amadan.net/oficina/estilo.git/savestatus	0.066s
ok  	amadan.net/oficina/estilo.git/theme	0.003s
diff --git a/docs/superpowers/specs/2026-09-23-shared-chrome.md b/docs/superpowers/specs/2026-09-23-shared-chrome.md
index 0f30e59..e59cdd8 100644
--- a/docs/superpowers/specs/2026-09-23-shared-chrome.md
+++ b/docs/superpowers/specs/2026-09-23-shared-chrome.md
@@ -1,8 +1,18 @@
# 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.
+Status: **proposed, revised after review**, 2026-09-23.
+
+- The whole-spec review by the highest-capability model (AGENTS.md,
+ Process) is done: Claude Fable 5.1, verdict APPROVE-WITH-CHANGES, on
+ oficina/estilo #9. This revision addresses its findings 1–17.
+- Every code citation was re-checked against each repo's `origin/main`
+ on 2026-09-23: Docs 7543cf5, Sheets bf59232, Slides a88f7ff, Calendar
+ f6aa41c, Memoria 02cb530, Calls (meet) 3846ca4, Home 3339ff6 (plus
+ branch `fix/r1-signout-ends-id-session`).
+- Paul's answers to §13 (seven questions, all open) are still needed
+ before any adopting branch starts.
+- Every CSS block in this spec has been run through estilo's prefix-lint
+ regex, and none trips it.
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,
@@ -43,7 +53,15 @@ Paul's rulings this spec builds on (2026-09-23):
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.
+ `rst-error`, because the lint scans `.css` only. **Estilo CSS may not
+ descend into rastrillo markup either.** A selector like
+ `[estilo-x] [rst-form]` trips the tripwire just as `[rst-form]` does.
+ Estilo CSS stands beside rastrillo markup; where it must adjust a
+ rastrillo-rendered element, it targets an element name
+ (`[estilo-signin] form`) or an estilo attribute the template also
+ emits. The regex runs over the whole `.css` file, **comments
+ included**, so a comment that names a rastrillo attribute selector
+ trips it too.
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
@@ -105,8 +123,9 @@ specificity, so every rastrillo component (`rst-btn`, `rst-row-main`,
```
- `: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.
+ flips with `light-dark()` through the token. That replaces the
+ browser's own dark-mode default link colour (`rgb(158,158,255)`,
+ which Memoria shows because no rule sets one).
- **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.
@@ -115,8 +134,19 @@ specificity, so every rastrillo component (`rst-btn`, `rst-row-main`,
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.
+- **No app rule to delete.** No app on `origin/main` sets a base `a`
+ colour (Memoria's `main a {…}` was only the audit's suggestion). An
+ adopter adds none.
+- **It doesn't bleed into components.** Rastrillo's `[rst-row-main] > a`,
+ `[rst-shell-nav] a`, `[rst-dropdown-menu] a`, `[rst-back-nav] a`,
+ `[rst-seg-tabs] a`, `[rst-shell-brand]`, `.rst-nm` and `rst-btn` all
+ declare `color`, and the ones that matter declare `text-decoration`
+ (review finding 13).
+- **Intended visible change:** Calendar's week column headers and agenda
+ day headings are bare `<a>`, so they become accent and underlined. The
+ audit wants them muted, and that's R3.
+- **Docs browser gate:** a list-row title's computed colour equals
+ `--rst-text`, which proves the base rule lost to the component.
## 3. Error and not-found page (S2)
@@ -161,36 +191,79 @@ 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()
+r.NotFound(a.htmlError(http.StatusNotFound)) // chi; replaces "404 page not found"
+r.MethodNotAllowed(a.methodNotAllowed) // GET/HEAD to a POST-only path → the 404 page;
+ // other methods → 405 text/plain. It MUST set the
+ // Allow header itself: chi's default does, a custom handler doesn't.
+// inside the router, AFTER the session middleware:
+r.Use(a.recoverToErrorPage) // panic → 500 page with rastrillo.NewRef(), signed-in chrome
+// and in cmd/<app>/main.go:
+opts.ErrorPage = <app>.ErrorPage // rastrillo.Serve's own recoverPanics, for panics outside chi
```
-- `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`),
+- **Two recoverers, both needed.**
+ - Slides and Calendar already set `opts.ErrorPage` for
+ `rastrillo.Serve`'s outer `recoverPanics` (`cmd/slides/main.go:262`,
+ `cmd/calendar/main.go:82`).
+ - Docs, Sheets, Memoria and Calls don't. A panic there today is plain
+ text from outside chi.
+ - The outer recoverer has no session, so its page is necessarily
+ signed-out chrome. That's why the chi recoverer is mounted **inside**
+ the session middleware: that's the one people normally hit, and it
+ renders the viewer's chrome. Mount it outside, and every 500 repeats
+ Calendar's "error page always looks signed out" bug.
+- **`a.htmlError(status)` and `a.errorPage(w, r, status, opts…)`** do the
+ same job:
+ - resolve the viewer with an **optional** session read (never require
+ one)
+ - build the same `chrome.Bar` as any other page
+ - set `Cache-Control: no-store`
+ - write `text/html`
+- **Non-HTML stays non-HTML, decided per route group or by `Accept`, not
+ by an `/api/` prefix.** Docs has no `/api/` prefix at all. Its byte and
+ JSON endpoints are `/d/{sqid}/updates` (GET and POST), `/stream` and
+ `/documents/search` (`app.go:594,611-612`). The rule:
+ - a handler in a JSON, byte or stream route group answers
+ `text/plain` or JSON
+ - a shared helper decides from `Accept` (it contains `text/html` →
+ page)
+- **Where one helper serves both** (Sheets' `a.notFound`,
+ `handlers_sheets.go:263-264`, reached from HTML pages and from `/api`
+ through `sheetFromURL`), the split happens inside that helper, by
+ `Accept`.
+- **HTML `http.Error` sites to convert** (checked on `origin/main`,
+ 2026-09-23):
+ - **Docs:** 35 in `handlers.go`, plus `publish.go` (5), `share.go`
+ (10), `export.go` (3), `search.go` (3) and `auth.go` (3). Many of
+ these are JSON, stream or update answers and stay as they are. Each
+ is classified on the branch.
+ - **Sheets:** `handlers_sheets.go:95,138,264,276`,
+ `handlers_table.go:165,187,210`, `handlers_view.go:53`,
+ `theme.go:82`, `render.go:169,178`.
+ - **Memoria:** `handlers.go:1048` ("could not read that document") and
+ `home_render.go:18`.
+ - **Home:** `passkey.go:171,187` and `render.go:83,89`.
+- **Not converted: Memoria `handlers.go:752`.** That's `writeOutcome`,
+ the JSON answer to the upload tray. It deliberately answers a bare
+ non-200, which the tray reads as "no answer" and says so (the comment
+ at `:748-751`). Turning it into an HTML page breaks the tray.
+- **Home:** the directory origin and the credential origin both get
+ `NotFound`. Unknown hosts keep the empty 404 (`origins.go:91,95,105`),
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.
+- **Tests these changes break on purpose:** Sheets'
+ `TestIndexIsNotACatchAll` (`page_test.go:56`) pins chi's plain 404 for
+ unknown paths. It's rewritten to assert the HTML 404 page. The "not a
+ catch-all" half (only `/` renders the index) stays.
+- **Gate (per app):**
+ - `TestHTMLErrorsRenderTheErrorPage` runs a table of cases {unknown
+ route, missing object, bad parameter, panic} × {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, each classified as non-HTML. It fails on any new
+ unclassified site.
+ - Prove both tests fail before the change, per the R1 brief.
## 4. Callout and notice tones (S3)
@@ -208,7 +281,8 @@ template: `rst-notice` (always success green, `tokens.css:1268`),
| 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 |
+| Calls | `rst-notice role="status"` (`settings.html:7`, `meeting_settings.html:6`) | "Meeting defaults saved." / "Meeting settings saved." | positive |
+| Calls | JS text in `<p id="share-status" role="status">` (`share-link.js:11`, `share.html:12`) | "Link copied." | not a callout: stays a quiet status line, which is right for a transient confirmation |
| 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 |
@@ -221,7 +295,7 @@ template: `rst-notice` (always success green, `tokens.css:1268`),
| 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 |
+| Memoria | plain `<p>{{.Run.Message}}</p>` (`notion.html:76`) | "Import finished, but some pages could not be read." / "The import stopped…" | today not a callout at all; becomes a warning callout (finished-with-errors, stopped) or info (finished) chosen by run state |
| 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 |
@@ -247,12 +321,24 @@ layout becomes:
```html
<body>
+<div estilo-shell> <!-- optional outer wrapper, see below -->
<a rst-skip href="#main">{{.Chrome.Labels.Skip}}</a>
{{template "estilo-bar" .Chrome}}
<main rst-page id="main">…</main>
+</div>
</body>
```
+`[estilo-shell]` is the wrapper a full-height app keys its layout on. It
+replaces rastrillo's `<div rst-shell-topbar>`. Today Sheets' grid page
+sizes itself with `[rst-shell-topbar]:has([data-sheet-surface]) { height: 100dvh }`
+(`sheets.css:32-36`). That rule moves to
+`[estilo-shell]:has([data-sheet-surface])` in the app's CSS. `chrome.css`
+gives `[estilo-shell]` no styling of its own. An app may instead keep
+`<div rst-shell-topbar>` as the wrapper. That's harmless: every rastrillo
+rule scoped by it targets `[rst-shell-bar]`, `[rst-shell-menu]` and
+`[rst-shell-tail]`, which are no longer rendered.
+
```html
{{define "estilo-bar"}}
<header estilo-bar{{if .Sticky}}="sticky"{{end}}>
@@ -260,7 +346,8 @@ layout becomes:
<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}}
+ {{- with .ContextEdit}}{{template "estilo-bar-title" .}} {{/* §5.4 */}}
+ {{- else 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}}
@@ -284,6 +371,7 @@ layout becomes:
```
Data (`chrome.Bar`): `App{Name, Slug, Home, Icon}`, `Context string`,
+`ContextEdit *TitleEdit` (§5.4),
`Nav []Link`, `NavLabel`, `HasActions bool`, `Switcher *Switcher`,
`Account *Account`, `SignIn *Link`, `Sticky bool`,
`Labels{Skip, Menu}`, `Icons`. `Link{Href, Label string; Current bool}`.
@@ -310,11 +398,12 @@ Data (`chrome.Bar`): `App{Name, Slug, Home, Icon}`, `Context string`,
```css
:root {
- --estilo-bar-block-size: 3.25rem; /* 52px: today's signed-in height, so the fewest pages move */
+ --estilo-bar-block-size: 3.25rem; /* 52px: today's signed-in height, so the fewest pages move. PUBLIC: see §5.3 */
+ --estilo-z-bar: 45; /* PUBLIC: the bar's stacking level, and the ceiling for in-page layers (§5.3) */
--estilo-trigger-size: 2rem; /* 32px targets, above the 24px floor */
}
[estilo-bar] {
- position: relative; z-index: 20;
+ position: relative; z-index: var(--estilo-z-bar);
box-sizing: border-box;
display: flex; flex-wrap: nowrap; align-items: center;
gap: var(--rst-sp-3);
@@ -377,9 +466,99 @@ 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.
+exactly 52px with `getBoundingClientRect` at 1440px, **at 800px and
+840px** (where nav, context, actions, switcher and account first compete
+for the row), and at 390px. It's measured signed in, signed out, on the
+error page, with Menu open, and with the editable title focused (§5.4).
+Its `scrollWidth` equals its `clientWidth`, meaning nothing overflows.
+
+### 5.3 Published values: height, sticky offsets, stacking
+
+Two custom properties in `chrome.css` are **public contract**. Apps read
+them. They don't copy the numbers.
+
+- **`--estilo-bar-block-size` (52px).**
+ - A page with a sticky bar sets
+ `html { scroll-padding-block-start: var(--estilo-bar-block-size); }`
+ so anchors and `:target` don't land under the bar.
+ - Any in-page sticky element below a sticky bar uses
+ `inset-block-start: var(--estilo-bar-block-size)`, not `0`.
+ Calendar's `[cal-column-head] { position: sticky; top: 0 }`
+ (`calendar.css:208-214`) would otherwise slide under the bar. So would
+ Slides' sticky strip and inspector (`top: var(--rst-sp-3)`).
+ - A page whose bar isn't sticky keeps `top: 0`.
+ - A full-height app layout (Sheets' grid, Docs' editor) computes its
+ working area as `calc(100dvh - var(--estilo-bar-block-size))`.
+- **`--estilo-z-bar` (45).** The bar is a stacking context, and its
+ panels (account, switcher, Menu sheet) paint at the bar's level, not at
+ their own `z-index`. So:
+ - **Nothing in the page flow may exceed 45.** Rastrillo's in-page
+ floats already sit below it: dropdown 30, tooltip 35, row menu 40.
+ App layers must too. Today: Docs `[docs-chrome]` 3; Calendar column
+ heads 2 and `[cal-quick-form]` 30. All fit.
+ - **Vendor stacks are contained, not renumbered.** Sheets'
+ jspreadsheet CSS uses values up to 10001. The grid's wrapper gets
+ `isolation: isolate`, so they compete only inside the grid.
+ - **Modals and dialogs are above the bar.** Use native
+ `<dialog>.showModal()`, which puts them in the top layer (Docs'
+ dialogs already do). Any other full-screen fixed overlay uses
+ `z-index: 50` or more. Rastrillo's `rst-modal-overlay` is 10, which
+ would sit under the bar. No app renders it today; one that wants it
+ uses a top-layer `<dialog>` instead.
+ - The skip link stays at rastrillo's 60.
+ - **Gate:** open the account menu on each app's densest page (Docs
+ editor, Sheets grid, Calendar week, Slides editor). Assert that
+ `document.elementFromPoint` at the panel's centre is inside the
+ panel.
+
+### 5.4 Editable title slot (context that can be renamed)
+
+Sheets renames in the bar today: `<form data-doc-title>` with an
+`<input>` and a visually hidden "Rename" button, only when the person may
+write (`sheet.html:45-58`). Docs has no rename at all ("Untitled document"
+forever, audit §B7), and the bar is where its title lives. That's two
+callers now, so it's part of the API from the start, not a later Sheets
+correction. (Whether rename belongs in the bar at all is §13 Q7. The slot
+is designed so that dropping it is `ContextEdit: nil`.)
+
+```html
+{{define "estilo-bar-title"}}
+<form estilo-bar-title method="post" action="{{.Action}}">
+ <input estilo-bar-title-input name="{{.Name}}" value="{{.Value}}" aria-label="{{.Label}}"
+ autocomplete="off" spellcheck="false" dir="auto">
+ <button rst-btn="ghost" type="submit" estilo-bar-title-submit>{{.Submit}}</button>
+</form>
+{{end}}
+```
+
+`estilo-bar` renders it in place of the read-only `Context` when
+`ContextEdit` is set.
+
+```css
+[estilo-bar-title] { display: flex; align-items: center; flex: 0 1 22rem; min-inline-size: 0; }
+[estilo-bar-title-input] {
+ box-sizing: border-box; block-size: 2rem; /* fixed: an input in the bar may not grow it */
+ inline-size: 100%; min-inline-size: 6rem;
+ padding-inline: 0.4rem; border: 1px solid transparent; border-radius: var(--rst-radius-sm);
+ background: transparent; color: var(--rst-text); font: inherit; font-weight: 550;
+}
+[estilo-bar-title-input]:hover { border-color: var(--rst-line); }
+[estilo-bar-title-input]:focus { border-color: var(--rst-accent); outline: none; background: var(--rst-surface); }
+[estilo-bar-title-submit] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; }
+[estilo-bar-title]:focus-within [estilo-bar-title-submit] { position: static; clip-path: none; inline-size: auto; block-size: 2rem; overflow: visible; }
+```
+
+- **Data:** `Bar.ContextEdit *TitleEdit{Action, Name, Value, Label, Submit}`.
+ It's nil when the person can't write, and then the read-only `Context`
+ shows instead. Labels come from the caller: Sheets "Sheet title" and
+ "Rename"; Docs "Document title" and "Rename".
+- **Behaviour (script, optional):** Escape restores the value and blurs,
+ which fixes Sheets' sticky focus look (audit §B3). Enter submits with or
+ without script, because it's a real form.
+- The input is bordered, but it's a *control*, not a highlight, so its
+ 6px radius is `day`'s (§11).
+- At narrow widths the slot behaves like `Context`: the app name hides
+ and the field takes the room.
---
@@ -432,40 +611,108 @@ 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
+### 6.2 How an app knows its siblings' URLs, and what a switch costs
+
+**How Home knows them today** (home `origin/main` 3339ff6):
+
+- The display catalog (`directory_catalog.go`) holds no sibling URLs.
+ Each integrated app's row links to Home's own `/apps/<slug>`
+ (`:29-30`).
+- `/apps/<slug>` is the chooser's `Entry` (`docs_chooser.go:56-83`, one
+ instance per app).
+ - It needs a **directory** session: `subject()` reads
+ `sessions.Current` (`:132`). Without one it redirects to the
+ directory's own `/auth/start?return_to=/apps/<slug>`.
+ - It **404s any query string** (`r.URL.RawQuery != ""`, `:63`).
+ - It already **skips the chooser for a single team** (`case 1:`,
+ `:79`). What it redirects to is
+ `choice.Origin + "/auth/start?return_to=/"` (`redirect`, `:153-156`).
+ That starts a fresh assertion exchange on the target app even when
+ the person already has a live session there.
+- The team's origin comes from the directory store, which restricts
+ routes to `https://<label>.<slug>.oficina.works`
+ (`directory_runtime.go`, `directoryRouteAllowed`).
+- Preview deployments (`HOME_APP_PREVIEW_ORIGINS`,
+ `HOME_DOCS_PREVIEW_ORIGIN`) override **only the catalog page's hrefs**
+ (`directory_catalog.go:12-15, 34-35`). `/apps/<slug>` doesn't consult
+ them, so in a preview deployment a link to `/apps/sheets` ends on "No
+ teams available".
+
+So the only party that knows which sibling instances this person's team
+has is Home. **The switcher still links to Home, not to the siblings.**
+Each sibling tile is `{directory}/apps/<slug>`, and "All apps" is
+`{directory}/`.
+
+**What a switch costs today, stated plainly.** Take someone who signed
+into Docs from a deep link. They never acquired a *directory* session. A
+first click on Sheets goes:
+
+1. Docs → directory `/apps/sheets`, which has no directory session →
+ `/auth/start`
+2. id.* `/authorize` → interstitial → directory assertion → `/auth/finish`
+ → `/apps/sheets`
+3. single team → Sheets `/auth/start` → id.* `/authorize` → interstitial
+ → Sheets assertion → `/auth/finish` → `/`
+
+That's two assertion exchanges and two interstitials for one click. Later
+switches in the same browser skip step 2, because the directory session
+now exists. They still run step 3's exchange even when the Sheets session
+is live.
+
+**The Home changes that make it acceptable.** These are assigned to Home
+branch `feat/r2-signout-chain` (§12.1 row 1), which is cut first because
+it gates Docs:
+
+- **(a)** The single-team redirect goes to `choice.Origin + "/"`. The
+ target app's own guard starts an exchange only if it has no session of
+ its own. The chooser's `continueToDocs` (multi-team, after a POST)
+ changes the same way. The comment in `redirect` ("Generic entry carries
+ only the app root") still holds: the root is all that's passed.
+- **(b)** `/apps/<slug>` honours the preview overrides the same way the
+ catalog does. When `appPreviewOrigins[slug]` (or
+ `docsPreviewOrigin` for Docs) is set, it redirects to
+ `<preview origin>/signin`. The validation is the existing
+ `validAppPreviews`.
+- **(c)** The step-2 hop through the directory session stays. The
+ directory can't mint its own session without an assertion, and making
+ it do so is a Home design question, not a chrome one. §9.3's hidden
+ interstitial is a **prerequisite for rendering tiles**. Without it,
+ step 2 flashes "Continue to Oficina" mid-switch.
+- **(d)** Optional, later: `/apps/<slug>?team=<label>` preselects the
+ team whose route label the switching app's own host carries. It's
+ honoured only if that team is one of the person's. This needs `Entry`'s
+ query-string refusal at `:63` relaxed to exactly one `team` parameter,
+ so it's an `Entry` change, not just a chooser change. It isn't needed
+ for R2.
+
+**What 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.
+ `HOME_DIRECTORY_ORIGIN`, the same name Home uses in `origins.go`,
+ validated with Home's `originHost` rules.
+- Apps already hold `Issuer`, the credential origin. Stripping `id.` from
+ it isn't an honest derivation, so the directory origin is configured.
+- Docs doesn't import `home/connect` (its own `home_*.go` on
+ `home/handoff`, §12). So Docs reads `HOME_DIRECTORY_ORIGIN` in its own
+ `home_config.go` for R2. It moves onto `connect` in R3.
+
+**When tiles render (ties to §13 Q2):**
+
+- The `estilo-switcher` block is built in estilo either way.
+- Until (a), (b) and the §9.3 interstitial hide have merged, an app
+ renders the switcher with **"All apps" only**, and no tiles.
+- Once they've merged, the tiles appear.
- **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.
+
+**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.
+- A static per-app list of six origins. That's six values to drift, and
+ it's wrong for preview deployments.
---
@@ -498,7 +745,7 @@ Calendar quick-add, and Docs jump-to and dialogs (radius and shadow only).
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~="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;
@@ -565,15 +812,25 @@ Item markup (`menu/templates/menu.html` defines `estilo-menu-item`):
(`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.
+ (`position-try-fallbacks: flip-block, flip-inline`).
+- **One exclusivity group for every top-level menu:** `name="estilo-menus"`
+ goes on the bar triggers (Menu sheet, switcher, account) *and* on every
+ document menubar title. Opening File closes Account, and the other way
+ round. That's the behaviour Sheets chose on purpose, sharing
+ rastrillo's `rst-menus` across header and menubar (`sheet.html:113-121`,
+ pinned by `internal/guardstest/menugroup_test.go`). The narrow Menu
+ sheet can join the group, unlike rastrillo's shell menu, because the
+ account isn't inside it. **Nested disclosures (flyouts, groups inside a
+ panel) never carry the top-level name.** They either carry no `name` or
+ a name of their own. `<details name>` exclusivity is document-wide, so a
+ submenu sharing its parent's name closes the parent
+ (Sheets' `TestNoNestedExclusiveDetails`).
### 7.2 Menubar titles: square and in-flow
```html
<nav estilo-menubar aria-label="Menus">
- <details estilo-menubar-menu name="estilo-menubar">
+ <details estilo-menubar-menu name="estilo-menus">
<summary estilo-menubar-title>File</summary>
<div estilo-menu>…</div>
</details>
@@ -729,17 +986,36 @@ pressed".
- 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`.
+- `chrome.HandleAppearance` validates `mode` against the three values.
+ It validates `return_to` with **rastrillo's `sessions.SafeReturn`
+ rules, not a re-implementation.** A hand-written "starts with `/`, not
+ `//` or `/\`" check misses the control-character case SafeReturn
+ documents: browsers strip `\t\r\n` before parsing, so `/\t/evil.example`
+ passes a `//` prefix test and still leaves the site. Estilo pins
+ carlosframework rastrillo v0.23.0, which has `sessions.SafeReturn`, so
+ the handler calls it (or its path-only core) directly. Apps on
+ `home/connect` may use `handoff.SafePath`, which is equivalent for this
+ purpose. On failure it falls back to `/`.
+- The handler sets the host-only cookie `estilo_appearance` (1 year,
+ `SameSite=Lax`, `Secure`, `HttpOnly`) and answers 303 to `return_to`.
+- **`/appearance` sits inside the app-wide `csrf.Protect`, and outside
+ the session group.** A signed-out person can't reach the menu, but a
+ session that expired mid-page shouldn't turn the click into a redirect
+ loop. Sheets' `/mode` is already placed that way: after the app-wide
+ `homeRoutes.Middleware` / `csrf.Protect` at `app.go:155-159`, and
+ outside `requireSession` (`app.go:187`). Keep it.
- 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".
+- This promotes Sheets' existing design (`theme.go`); Calendar has the
+ same shape (`routes.go:82` `/mode`, `theme.go:12` `calendar_mode`,
+ `return_to` in `handlers.go:50-52`). Retiring:
+ - Sheets' `/mode`, `sheets_mode` and `"auto"`
+ - Calendar's `/mode`, `calendar_mode` and the "Auto" label, which
+ becomes "System"
+ - Sheets' `tools/drive/drop.mjs:227,239`, which sets `sheets_mode` by
+ name and must move to `estilo_appearance` in the same branch
- A per-host cookie means each app remembers separately (see §13 Q6).
### 8.4 Signed-out corner
@@ -813,7 +1089,7 @@ alike.
| | 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) |
+| Already signed in, `GET /signin` | — | 303 to `return_to` or `/`, with no form (fixes Slides). Owners: `connect` on the Home branch, and each app for local mode (§9.4) |
What this retires:
@@ -845,7 +1121,7 @@ directory origin, with no account and no switcher.
<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>
+ <form rst-form estilo-signin-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>
@@ -859,7 +1135,7 @@ directory origin, with no account and no switcher.
[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-form] { max-inline-size: none; } /* the template adds estilo-signin-form to its form; no rastrillo attribute selector here, not even in a comment: the lint scans the whole file (§0.1) */
[estilo-signin-foot] { margin-block-start: var(--rst-sp-6); font-size: var(--rst-fs-xs); color: var(--rst-text-muted); }
```
@@ -876,18 +1152,61 @@ 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`.
+ and by `connect`. It's validated **only** with the existing helpers:
+ `handoff.SafePath` (Home and `connect` apps) or `sessions.SafeReturn`
+ (rastrillo). There's no per-app re-implementation (see §8.3 for why).
- **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.
+ Each gets an app-owned wrapper: on a GET or HEAD with no session it
+ answers `303 /signin?return_to=` +
+ `url.QueryEscape(r.URL.RequestURI())`, and anything else gets what it
+ gets today. **The three aren't one copy-paste:**
+ - **Slides** guards with `amadan.net/rastrillo/rastrillo/auth`
+ v0.26.1-… (`app.go:131`).
+ - **Memoria** already has its own `requireSession`, which "FAILS
+ CLOSED" and wraps carlosframework v0.23.0 `auth`
+ (`app.go:193-225`). The redirect is added *inside* that wrapper,
+ keeping its fail-closed behaviour.
+ - **Calls** guards with `amadan.net/rastrillo/rastrillo/auth` v0.26.1-…
+ through `requireParticipant` (`guest.go`). It must keep the guest
+ path: anonymous sessions are allowed under `/m/` and on
+ `/native/authorize` and `/native/complete`. It must also answer the
+ room's stream, signal and ICE fetches the way `room.js` expects: a
+ redirect that lands somewhere else, or a 401/403, reads as "signed
+ out" (the comment and code at `room.js:1060-1101`). The fetch paths
+ keep today's answers. Only page GETs gain `return_to`.
+ - **Tests pinning today's bare Location**, updated on purpose: Calls
+ `internal/callstest/api_test.go:208` and `join_test.go:64` assert
+ exactly `/signin`. Slides' `signin_test.go` asserts a `/signin`
+ prefix, so it survives.
+ - An upstream rastrillo issue is filed for `refuseSession` to carry
+ `return_to` itself, as `sessions.Require` already does
+ (carlosframework v0.23.0 `sessions/sessions.go:394`).
+- **Already signed in, `GET /signin`** (§9.2's 303 row) needs code that
+ nobody owns today:
+ - `connect.Routes.Signin` (`home` `connect/routes.go:100`) never reads
+ the session. It always remembers `return_to` and 303s to
+ `/auth/start`. That's a `connect` change on Home branch
+ `feat/r2-signout-chain`: if `ReadSession` finds a live session, 303
+ to the validated `return_to` or `/`.
+ - Local mode: each app's own sign-in handler checks the session first.
+ Slides' `signin` (`auth.go:553`) is the one the audit caught; each
+ adopter checks its own.
- **Local mode** carries `return_to` through Begin → emailed link →
- Verify, falling back to `/`. Calls' join-link cookie path keeps working
- (`guest.go:16`).
+ Verify, falling back to `/`. Calls' join-link cookie path keeps working.
+ - **Docs has a documented reason on this path, so it's addressed, not
+ reversed silently.** `auth_navigation.go:55-56` says a destination
+ "never comes from return_to or another submitted URL". Read in
+ context, that governs `keymailDestination`: the **Keymail OAuth
+ authorize URL** the identity plugin generates, which must be exactly
+ `https://…/oauth/authorize`. It doesn't govern the page the person
+ lands on afterwards.
+ - This spec leaves that rule intact. The post-sign-in landing path is a
+ separate value, validated by `handoff.SafePath` (same-origin path
+ only: no scheme, no host, no `//`, no control characters), so it can
+ never become an off-site destination.
+ - If the Docs adopter finds the comment was meant more broadly, the
+ branch discussion records that and asks, rather than overriding it.
- **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,
@@ -896,47 +1215,111 @@ shows at once.
### 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
+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`, `connect/routes.go:156-163`)
+clears only the app's session and 303s to `/signed-out`. That page's
+"Sign in" then passes silently through the live 30-day id.* session.
+
+**Fix:** three parts, all on Home branch `feat/r2-signout-chain`, except
+Docs' copy (part 3).
+
+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.
+ - **CSP is already right.** Browsers enforce `form-action` on the
+ *redirect target* of a form submission, so the issuer must be in the
+ app's `form-action` for the 307 to be followed. `connect.Middleware`
+ sets `FormPolicy(issuer)` = `form-action 'self' <issuer>`
+ (`connect/routes.go:62-67, 86-88`), and Docs' own
+ `homeFormPolicy(settings.issuer)` does the same
+ (`home_routes.go:40`, `home_security.go`). Nothing to add. The Home
+ directory needed its `security.go` change on the B2 branch only
+ because it didn't have that policy.
+ - 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"]`).
+ - **Evidence.** The origin is `Origin` when it names one, otherwise
+ `Referer`'s origin. Chromium sends `Origin: null` on a POST carried
+ cross-origin by a 307, so on the hop that matters **Referer is the
+ only evidence**. It has to survive: under
+ `strict-origin-when-cross-origin` (rastrillo's default) it 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.
+ - **Refusal is loud, branded, and recoverable.** Both a refused
+ **POST** (status 403) and a plain **`GET id.*/signout`** (status 200)
+ render the same branded page:
+ - heading "Finish signing out"
+ - body "Select Sign out to finish signing out of Oficina on this
+ device."
+ - a **Sign out** button: a same-origin POST, which
+ `csrf.SameOrigin` admits
+
+ After it, the browser lands on the directory's `/signed-out`. The
+ refused POST never answers with `http.Error` text, which is what
+ `signout.go` does today on the B2 branch.
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.
+**Referrer-Policy audit (an adoption prerequisite).** Because Referer is
+the only evidence on the chained hop, any page that sends a page-level
+`Referrer-Policy: no-referrer` and also shows the account menu produces a
+refused sign-out. That page lands on "Finish signing out", which is
+recoverable but wrong. Page-level `no-referrer` headers on `origin/main`,
+2026-09-23:
+
+| App | Where | Page | Shows the account menu under this spec? | Resolution |
+|---|---|---|---|---|
+| Calls | `credits.go:31` (pinned by `credits_test.go:19-23`) | Open source | yes | `strict-origin` (below); update the test's assertion |
+| Slides, Calendar, Memoria | `credits.go:36` / `:31` / `:30` | Open source | yes | `strict-origin` |
+| Calls | `handlers_meetings.go:177` (`shareMeeting`) | Share meeting (signed-in, holds the invitation link in its body) | yes | `strict-origin` |
+| Slides | `home.go:83` (`renderHome`, the Home-connect pages) | connect and signed-out states | only in signed-in states | `strict-origin` |
+| Calls | `native_auth.go:96` (`nativeHeaders`) | native app hand-off | no (not a chrome page) | keep |
+| Calendar | `handlers_booking_util.go:52` | public booking pages | no (public, signed-out corner) | keep |
+| Docs | `auth_navigation.go:30` | sign-in navigation response | no | keep |
+| Home | `continuation.go:9`, `connect/oauth.go:34` | hand-off continuations | no | keep |
+
+**Resolution, general form:** a page that shows the account menu uses
+`Referrer-Policy: strict-origin`, not `no-referrer`.
+
+- **Still protected:** `strict-origin` never sends a path or query to
+ anyone, so whatever the page was protecting (a meeting's path, a
+ credits page's outbound hops) stays unsent. The credit links also carry
+ their own `referrerpolicy="no-referrer" rel="noreferrer"`
+ (estilo `credits/templates/credits.html:8`), so they keep sending
+ nothing at all.
+- **What it adds:** the chained sign-out POST carries exactly the origin,
+ which is all `handleCredentialSignout` needs.
+- **The cost:** the bare origin, sent on HTTPS navigations away from that
+ page. The rastrillo default already sends that cross-origin.
+- **Considered and rejected:** omitting Sign out from the menu on those
+ pages. It breaks the "exactly one Sign out, same corner" rule on
+ exactly the pages where it's hardest to notice.
+
+**Rule for adopters:** grep for `Referrer-Policy` and resolve every page
+that renders the account menu before the branch is reviewed. The e2e gate
+below runs sign-out from the credits page on purpose.
+
+**Residual risk, accepted and stated:** any page on a registered consumer
+origin can POST to `id.*/signout`. That's logout CSRF, and it needs a
+user-injected form on a page the victim visits. None of Docs public,
+Sheets `/l/` or Slides share renders user-supplied HTML forms today. An
+app that ever does must not do it on a consumer origin.
+
**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
@@ -944,19 +1327,35 @@ 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.
+**Order:** B2 (`fix/r1-signout-ends-id-session`) merges first. Then Home
+branch `feat/r2-signout-chain` lands, **before Docs starts**, not in
+parallel. It carries:
+
+- the credential-side widening and the branded refusal page
+- the `connect.Signout` 307
+- `connect.Credentials.Directory`
+- the `connect.Signin` already-signed-in check (§9.4)
+- the `/apps/<slug>` changes (§6.2 a–b)
+
+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*
+ (once from an ordinary page and once from the Open source page), click
+ "Sign in", and assert that the id.* email form appears with no silent
+ re-entry.
+- It runs in all three engines. That's also where Firefox's and WebKit's
+ `Origin` behaviour on a 307-carried POST is established, which neither
+ the spec nor its review verified.
+- 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 with the branded 403 page
---
@@ -1042,15 +1441,15 @@ quick-add pane is a popover, so it's already covered by the float 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 |
+| 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 (works with `GOPRIVATE`). Merge estilo first, then re-pin Docs to the merge. |
+| 1 | home | `feat/r2-signout-chain` | B2 merged | **Cut first, not in parallel: it gates Docs.** It carries the credential side admitting consumer origins, plus the branded "Finish signing out" page for GET and the refused POST (§9.5). The `connect` changes: `Signout` 307, `Credentials.Directory`, and the `Signin` already-signed-in check (§9.4). The `/apps/<slug>` changes: single-team redirect to `choice.Origin + "/"`, and honouring preview overrides (§6.2 a–b). And the id.* interstitial hide (§9.3), which the switcher tiles wait on. |
| 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 |
+| 8 | home | `polish/r2-shared-chrome` | 4 | The directory has no header at all today (`templates/layout.html`: only a `help` block and `main`), so this is a build, not a swap: directory bar, branded id.* pages, `NotFound` on both origins |
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
@@ -1059,94 +1458,217 @@ 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:**
+1. **Pins.**
+ - Bump `amadan.net/oficina/estilo.git` to main (S13; Docs is 18
+ commits behind, pinned at `672a447`).
+ - Bump the Home modules the app imports. That's
+ `amadan.net/oficina/home` for `connect` apps, and
+ `home/handoff` + `home/roster` for Docs.
+ - Record the rastrillo pin. Docs, Sheets, Calendar, Memoria and Home
+ use `github.com/carlosframework/rastrillo v0.23.0`. Calls and Slides
+ use `amadan.net/rastrillo/rastrillo v0.26.1-…` (two segments). Docs
+ requires both.
+ - Estilo's token tests run against v0.23.0. For a v0.26 app, the
+ two-pin check (AGENTS.md) hooks into its existing
+ `vendored_test.go`, which already pins `tokens.css` to *its* pin's
+ `ui.VendoredAssets("day")`. Add an assertion there that every
+ `var(--rst-*)` in estilo's `base.css`, `menu.css` and `chrome.css`
+ is defined in that `tokens.css` + `theme.css`.
+2. **Head.** `<meta charset="utf-8">` goes first. Every app today links
+ `brand.css` *before* the charset (Calendar `layout.html:13-14`, Sheets
+ `:19-20`, Memoria, Slides and Calls `:7-8`). Then serve and link
+ `base.css`, `menu.css` and `chrome.css` in the §0.5 order.
+3. **Layout.** `estilo-shell` > `estilo-bar` in place of the app's
+ topbar or header markup (§5.1). The brand is set in the page-data
+ builder, so every page has it. Apply the §5.3 sticky offsets and
+ stacking rules.
+4. **Account menu** per §8.2. Add `/appearance` (§8.3). Delete every
+ other Sign out and every standalone Help dropdown. "Connect your
+ Oficina account" is hidden for Home sessions. Docs, Calls and Memoria
+ already do that; **Sheets and Slides show it for Home sessions today
+ (Home §F.2 footnote) and are fixed on their branches.**
+5. **Switcher** (§6.2): `connect` apps build `chrome.Switcher` from
+ `connect.Credentials.Directory`. Docs reads `HOME_DIRECTORY_ORIGIN`
+ itself. It shows "All apps" only until row 1's `/apps/<slug>` changes
+ and the interstitial hide have merged. It's nil when Home isn't
+ configured.
+6. **Errors:** the error page and router wiring per §3.3, and the two
+ gate tests.
+7. **Callouts:** tones per §4, and the grep gate.
+8. **Titles** per §10, built in the layout, with an assertion test.
+9. **Sign-in and sign-out:**
+ - the sign-in, sent and signed-out templates per §9.2 and §9.3
+ - `return_to` per §9.4
+ - the sign-out chain per §9.5 (bump home)
+ - the Referrer-Policy audit per §9.5
+10. **Clean-up:** remove the app CSS these replace (listed per app
+ below), prove it's unused, and update the tests and tools that pin
+ the old markup (listed per app).
+
+**Per app, what's specific** (citations checked on `origin/main`,
+2026-09-23):
+
+- **Docs (first):** Docs 7543cf5.
+ - **Not on `home/connect`.** Docs imports `home/handoff` and
+ `home/roster` only, with its own `home_*.go`.
+ - `connect.Credentials.Directory` never reaches it. It reads
+ `HOME_DIRECTORY_ORIGIN` in `home_config.go` (item 5).
+ - Its own sign-out handler gets the §9.5 307.
+ - Its own `/signin` handler (`home_routes.go:121-129`, which already
+ validates `return_to` with `handoff.SafePath`) gets the
+ already-signed-in check.
+ - **Layout.** Replace the rastrillo `topbar` layout. It's one base
+ template cloned per page (`render.go:505-559`), so the change is
+ central. That fixes the "Home" brand on Share and the 45px public
+ bar. The sign-in page's CSS that hides the bar
+ (`signin.html:57-59`) goes: the sign-in pages show the brand-only
+ bar.
+ - **Sticky header.** `[docs-chrome]` (`show.html:849-855`, sticky,
+ z-index 3) already wraps menubar and toolbar. The estilo bar joins it
+ or sits above it with `Sticky`, and `[docs-chrome]` takes
+ `inset-block-start: var(--estilo-bar-block-size)` (§5.3).
+ - **Rename** uses the §5.4 title slot, with a new rename route (Q7).
+ - **Menubar.** Rebuild it on `estilo-menubar` and `estilo-menu`, in
+ group `estilo-menus` (replacing `name="docs-menubar"`). Delete the
+ `[docs-menu-*]` / `[docs-menubar]` CSS (`show.html:856-1050`).
+ - **It's also a JS change and a bundle rebuild.**
+ - `fixtures/menu.mjs` (from `:140`), `fixtures/shortcuts.mjs` and
+ `fixtures/editor-entry.js:326,349` query `[docs-menubar]` and
+ `[docs-menu*]`. They move to the estilo attributes.
+ - The committed, content-hashed bundle is rebuilt with
+ `fixtures/build-bundles.mjs`, and the new hashed filename is
+ committed.
+ - **View → Paged/Infinite check state.** `menu.go:95-100` says
+ `fixtures/menu.mjs` keeps `aria-pressed` in step after load. It
+ doesn't today. The spec's requirement is that the check reflects
+ **server state**: the mode comes from a cookie, and the page reloads
+ on change. A live toggle is in scope only if the adopter adds the
+ `aria-pressed` update to `menu.mjs`. Either way, the `menu.go`
+ comment is corrected to say which.
+ - **Menu content.** 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.
+ - **The rest.**
+ - Jump-to and dialogs take the float radius.
+ - The public notice → info.
+ - `return_to` in local mode follows §9.4's note on
+ `auth_navigation.go:55-56`.
+- **Sheets (second):** Sheets bf59232.
+ - **Layout.**
+ - Replace its own `[rst-shell-topbar]` markup (`layout.html`) with
+ `estilo-shell` + `estilo-bar`.
+ - The in-bar rename (`sheet.html:45-58`) becomes the §5.4 title
+ slot.
+ - The grid's full-height rule (`sheets.css:32-36`) re-keys on
+ `[estilo-shell]:has([data-sheet-surface])` and subtracts
+ `--estilo-bar-block-size` where it measures.
+ - Add `isolation: isolate` on the grid wrapper (§5.3).
+ - **Appearance.** `/mode`, `sheets_mode` and `"auto"` give way to
+ `chrome.HandleAppearance`. `/appearance` stays where `/mode` is:
+ inside the app-wide Protect, outside `requireSession`.
+ `tools/drive/drop.mjs:227,239` moves to the new cookie name.
+ - **Menubar.** Rebuild it (`sheets.css:188-311`) on estilo, keeping one
+ exclusivity group across header and menubar (the design
+ `menugroup_test.go` pins), and no `name` on submenus. Help moves into
+ the account menu. Tooltips via `menu.Shortcut`.
+ - **Tone and copy.**
+ - Callouts: `warn` → warning, errors → negative.
+ - Drop "Oficina Sheets". The signed-out landing `/` gives way to
+ `/signin`.
+ - The table-view cell pill goes square.
+ - `a.notFound` splits by `Accept` (§3.3).
+ - "Connect Home account" is hidden for Home sessions (item 4).
+ - **Expected casualties, rewritten to the new markup rather than
+ deleted:**
+ - `internal/guardstest/shellmenu_test.go`, `menugroup_test.go`,
+ `dropdownanchor_test.go` and `csstokens_test.go`
+ - `internal/sheets/page_test.go:56` `TestIndexIsNotACatchAll` (§3.3)
+ - `tools/drive/*.mjs` (`chrome.mjs`, `menus.mjs`,
+ `menu-actions.mjs`, `drop.mjs`, …), which drive the old
+ selectors
+- **Slides:** Slides a88f7ff.
- `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 capability pages (presenter view, remote, projector surface,
+ pair) render on `anon.html` (`render.go:95-100`). They take **no
+ bar**, which is a template-group change, and a signed-in presenter
+ stops seeing "Account → Sign in".
+ - `errors.html` is in the public template group. It moves to the
+ signed-in-capable group so errors show the viewer's chrome.
+ `opts.ErrorPage` is already set (`cmd/slides/main.go:262`).
+ - `requireSession` gets `return_to` (§9.4, v0.26 `auth`).
+ - Referrer-Policy: `home.go:83` and `credits.go:36` (§9.5).
+ - Titles change from "— Slides". "Connect Home account" is hidden for
+ Home sessions.
+- **Calendar:** Calendar f6aa41c.
+ - **Account menu.** It takes Settings, Booking pages (today it's only
+ linked from Settings) and Import a calendar.
+ - **Appearance.** `/mode` and `calendar_mode` (`routes.go:82`,
+ `theme.go:12`) give way to `/appearance`. Auto → System.
+ - **Sticky offsets.** `[cal-column-head]` (`calendar.css:208-214`,
+ sticky, `top: 0`, z 2) takes `--estilo-bar-block-size` if the bar is
+ sticky on the week view (§5.3). `[cal-quick-form]` (z 30) stays
+ under the bar's 45.
+ - **The rest.**
+ - Nav `aria-current` comes free from the bar. The `home.html` title
+ fixes "Hello".
+ - The error page resolves the viewer (`opts.ErrorPage` already set,
+ `cmd/calendar/main.go:82`).
+ - Referrer-Policy: `credits.go:31`.
+ - Apply the Q1 chip decision.
+- **Memoria:** Memoria 02cb530.
+ - **Header.** A new bar replaces the `rst-page-header` nav: it no
+ longer wraps, and the header drops from 97px to 52px.
+ - **Sources** becomes a nav link with `aria-current`, **rendered only
+ for owners**. `/connectors` is behind `requireOwner` (`app.go:152`,
+ `:245-256`), which 404s everyone else on purpose, so a non-owner must
+ never see the link.
+ - Account menu (new). The name becomes "Memoria" everywhere.
+ - `return_to` goes inside the existing `requireSession`
+ (`app.go:193-225`, §9.4).
+ - **Keep the footer's refresh region.** `<span rst-refresh="passages">`
+ (`layout.html:58`) must survive the new layout. `drop.js` (from
+ `:445`) aborts the whole swap if any named region on the page is
+ missing from the answer.
+ - **CSS to delete: only `memoria.css:11-26`** (`[rst-page-header]` and
+ `[rst-brand]`).
+ - **Keep `:9`**, the 46rem page width.
+ - **Keep `:33` `[rst-inline]`**, used by the delete form
+ (`index.html:189`) and the Notion forms (`notion.html:66,132`).
+ - **Keep `:35-44`**, the *page's* search form. The header has no
+ search box.
+ - The footer rules (`:27-32`) change only if the footer moves.
+ - **Tone.** "Import finished, but…" is a plain `<p>` (`notion.html:76`)
+ and becomes a callout per §4.
+ - There's no `main a` rule to remove (§2).
+ - Referrer-Policy: `credits.go:30`.
+- **Calls:** Calls 3846ca4.
+ - **Account corner** becomes the menu, with Settings as an item.
+ - **Nav:** Meetings · Settings. The header drops from 81px to 52px.
+ - **`calls-shell.css`** (28 lines) loses `:3-6` and `:13-28`, the
+ shell workarounds. **Keep `:7-12`**, the lobby and call-header
+ typography.
+ - **Tones.** `rst-notice` → tones per §4: the two "…saved." notices go
+ positive; "Link copied." stays a status line.
+ - **Guests** get the "Sign in" corner.
+ - **Credits.** `credits_test.go:19-23` today asserts that the credits
+ page carries no account context ("credits shell may retain account
+ context": no cookies, `no-referrer`). **Decision in this spec:**
+ credits resolves the viewer like every other page, so the account
+ menu and its single Sign out are there. The test is rewritten to
+ assert `strict-origin`, and that the page *sets* no cookies (it still
+ doesn't) while it *reads* the session.
+ - `requireParticipant` gains `return_to` for page GETs only, keeping
+ the guest path and the room's fetch answers (§9.4). Update
+ `api_test.go:208` and `join_test.go:64`.
+ - Referrer-Policy: `credits.go:31`, `handlers_meetings.go:177` (§9.5).
+- **Home:** home 3339ff6.
+ - **Directory bar.** Brand Oficina, and an account menu with a "Signed
+ in" identity row, Passkeys, Recovery codes and Sign out. The
+ full-width Sign out button goes. No switcher.
+ - The id.* pages use `estilo-signin*`. "Home" leaves user copy.
+ - `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`.
@@ -1175,8 +1697,8 @@ names as the originals.
- 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:
+ each URL at 1440×900, 840×900, 800×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)`
@@ -1185,6 +1707,9 @@ names as the originals.
- `[estilo-menu]` has a computed `border-radius` of `9px`
- menubar titles and bar triggers have `0px`
- the switcher is present or absent as configured
+ - with the account menu open, `document.elementFromPoint` at the
+ panel's centre is inside the panel (§5.3 stacking)
+ - a list-row title's computed colour equals `--rst-text` (§2)
- an unknown route returns 404 `text/html` with the bar
4. **CLS:** the PerformanceObserver used in `docs-shots.mjs:30`, recorded
across:
@@ -1217,27 +1742,72 @@ 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"?
+All seven are **open**. Paul hasn't answered them yet. Each lists the
+spec author's proposal and the recommendation from the whole-spec review
+(Claude Fable 5.1, oficina/estilo #9).
+
+1. **Calendar event chips:** square, or exempt from the square rule?
+ - *Spec:* square everywhere (§11).
+ - *Reviewer:* agrees. A placed block is a span of time that must meet
+ the hour rules, and the 3px inline-start rule carries its identity.
+ That gives one rule with no exemption. The quick-add pane is a float
+ (9px) anyway.
+2. **App switcher:** render the block in R2, or not? And the label for
+ the directory link?
+ - *Spec:* build the block in estilo either way. It's optional per app
+ (§6.1).
+ - *Reviewer:* reframe the question. "Render the block" and "the
+ mechanism" are separate things. Build it now, because it costs
+ nothing and Paul wants to see it. In R2, render tiles only after
+ Home's `/apps/<slug>` changes (§6.2 a–b) and the interstitial hide
+ have merged; until then render "All apps" alone. Make the decision
+ with the cost visible: today a first switch from an app goes
+ through the directory session and runs **two** assertion exchanges
+ (§6.2).
+ - *Label:* both recommend **"All apps"**. As a menu label, "Oficina"
+ collides with the brand on the hub's own bar.
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).
+ "Back to Home".
+ - *Spec:* never name the hub. Call the account **"your Oficina
+ account"** (the suite account). So "Connect your Oficina account",
+ "Sign in", and "All apps".
+ - *Reviewer:* agrees, and keep the verb "Connect". "Use your Oficina
+ account" reads better but loses the link-two-accounts meaning.
+ - Or does the hub get a name of its own?
+4. **Scope of "rounded corners only on floating things":**
+ - *Spec:* keep `day`'s radii on in-flow *containers and bordered
+ controls* (cards, inputs, buttons, the §5.4 title field). Square
+ only in-flow *highlights*, including borderless toolbar buttons.
+ - *Reviewer:* agrees. Anything more is a theme divergence and needs an
+ AGENTS.md decisions row (ruling 6). Accept the one visible seam this
+ leaves: a square borderless hover next to a 6px bordered button in
+ the same toolbar.
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)?
+ other apps' sessions in the same browser?
+ - *Spec:* not in R2 (§9.5). It would need Home to track where it hands
+ out assertions, plus a front-channel logout.
+ - *Reviewer:* no, not in R2. The id.* fix closes the "new assertion"
+ hole, which is the security-relevant one. Front-channel logout is a
+ Home design of its own. The §9.2 signed-out copy is already true
+ without it.
+6. **Appearance scope:** per app, or one preference for the whole suite?
+ - *Spec:* per app (a host-only cookie, §8.3).
+ - *Reviewer:* per app. A `Domain=oficina.works` cookie fails for
+ preview origins, is readable by every team instance and by id.*, and
+ it's a preference, not identity. If re-choosing per app turns out to
+ be annoying, the assertion can carry the preference later.
+7. **Is an editable title part of the bar?** (Added by the review.)
+ Sheets renames in the bar today (`sheet.html:45-58`), and Docs has no
+ rename at all.
+ - *Spec:* the bar API has an optional editable-title slot
+ (`ContextEdit`, §5.4). The input has a fixed 2rem block size, so it
+ can't break the 52px proof, and the width gates run with it focused.
+ Dropping it is `ContextEdit: nil`, and the title then shows
+ read-only.
+ - *Reviewer:* it's an `estilo-bar` API decision that changes the 52px
+ proof, so it belongs here, not in Sheets' corrections.
+ - *For Paul:* should rename live in the bar, as in Sheets and Google
+ Docs, or elsewhere (File → Rename…, or a settings page)? If
+ elsewhere, Sheets loses its in-bar rename, and the slot is removed
+ from the API before Docs builds it.