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>
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. |