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