oficina / memoria Public

Pin home/connect at Home main (01166fc); vendor estilo's base, menu and chrome.js

connect 01166fc chains an in-app Sign out to id.* (a 307), skips the
form for a signed-in /signin, and carries Credentials.Directory.
writeestilo now also writes estilo's base.css (links), menu.css
(floating panels) and chrome.js (Escape and outside-click close) for
the shared chrome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d4995a746965d3d3d8774ff9d73e88201dd41ce2 parent 97b8a23
6 files changed, +252 −5
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/memoria/static/base.css +15 −0
  • internal/memoria/static/chrome.js +82 −0
  • internal/memoria/static/menu.css +143 −0
  • scripts/writeestilo/main.go +9 −2
diff --git a/go.mod b/go.mod
index 3ec360d..0dfacdb 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ toolchain go1.26.5
require (
amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0
- amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f
+ amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3
diff --git a/go.sum b/go.sum
index a4586d6..945bb9f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg=
amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
-amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
+amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zRzgyHwD/RmZ4Hy4biwtxK7CdaSs=
diff --git a/internal/memoria/static/base.css b/internal/memoria/static/base.css
new file mode 100644
index 0000000..9873e71
--- /dev/null
+++ b/internal/memoria/static/base.css
@@ -0,0 +1,15 @@
+/* base/base.css: links. Zero specificity on purpose: any component rule wins.
+ Prose links keep their underline, because colour must never carry meaning
+ alone. Chrome links (bar, nav, menus, rows, buttons) set their own colour
+ and text-decoration, and every one of them outranks :where(). */
+: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;
+}
diff --git a/internal/memoria/static/chrome.js b/internal/memoria/static/chrome.js
new file mode 100644
index 0000000..1f583c6
--- /dev/null
+++ b/internal/memoria/static/chrome.js
@@ -0,0 +1,82 @@
+// chrome/chrome.js: optional behaviour for estilo-bar and the one top-level
+// menu group. Load it with <script defer>. Everything already works
+// without it; this only adds what <details> and the platform cannot do for
+// themselves:
+//
+// - Escape closes the open member of the group (name="estilo-menus": the
+// bar's Menu, switcher and account, and every menubar menu) and returns
+// focus to its title;
+// - a click outside the open member closes it;
+// - Escape in the editable title restores the saved value and leaves
+// the field;
+// - a row or button that opens a dialog (command="show-modal") closes
+// the menu it sat in, returns focus to that menu's title when the
+// dialog closes, and opens the dialog itself on an engine without
+// invoker commands.
+//
+// The group is one group in every app, so closing it is estilo's job, not
+// each app's: Sheets' menubar lost Escape and outside-click entirely when
+// it left rastrillo's dropdown.
+//
+// Both listen in the capture phase. A document app has to stop key
+// events from propagating out of its menus — Sheets' grid library acts on
+// every key it hears on document, so an arrow in an open menu would walk
+// the cell cursor too — and a bubble-phase listener on document never
+// hears the Escape such an app stops. Capture hears it first. An app that
+// closes its own menu on Escape (Docs' menubar) finds nothing open and
+// does nothing, so nothing is handled twice.
+;(() => {
+ const group = 'details[name="estilo-menus"][open]'
+ const openPanel = () => document.querySelector(group)
+
+ // Focus returns to a menu's title; in a menubar with a roving tab stop
+ // (one title tabbable, the rest tabindex=-1) the stop moves with it, so
+ // Tab then Shift+Tab comes back to the menu you were in, not to File
+ // (Astra, estilo #11). A menubar without one is left without one.
+ const returnTo = (title) => {
+ title.focus()
+ const bar = title.closest('[estilo-menubar]')
+ if (!bar) return
+ const titles = bar.querySelectorAll('[estilo-menubar-title]')
+ if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return
+ Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 })
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || e.defaultPrevented) return
+ const input = e.target.closest && e.target.closest('[estilo-bar-title-input]')
+ if (input) {
+ input.value = input.defaultValue
+ input.blur()
+ e.preventDefault()
+ return
+ }
+ const open = openPanel()
+ if (!open) return
+ open.open = false
+ const summary = open.querySelector('summary')
+ if (summary) returnTo(summary)
+ e.preventDefault()
+ }, true)
+
+ document.addEventListener('click', (e) => {
+ const open = openPanel()
+ if (open && !open.contains(e.target)) open.open = false
+ }, true)
+
+ const invokers = typeof HTMLButtonElement !== 'undefined' && 'command' in HTMLButtonElement.prototype
+ document.addEventListener('click', (e) => {
+ const button = e.target.closest && e.target.closest('button[commandfor][command="show-modal"]')
+ if (!button || button.disabled) return
+ const dialog = document.getElementById(button.getAttribute('commandfor'))
+ const holder = button.closest('details[name="estilo-menus"]')
+ if (holder) {
+ holder.open = false
+ const summary = holder.querySelector('summary')
+ if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true })
+ }
+ if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return
+ dialog.showModal()
+ e.preventDefault()
+ })
+})()
diff --git a/internal/memoria/static/menu.css b/internal/memoria/static/menu.css
new file mode 100644
index 0000000..2ddf029
--- /dev/null
+++ b/internal/memoria/static/menu.css
@@ -0,0 +1,143 @@
+/* menu/menu.css: the one floating panel, and the menu bar it hangs from.
+ Every floating surface in the suite uses [estilo-menu]: the account
+ menu, the switcher, the narrow Menu sheet and every menubar menu.
+ A float is rounded and so is a highlight inside it; a menubar title is
+ in the page flow, so it is square (the suite's square rule). */
+:root {
+ --estilo-float-radius: 9px; /* rastrillo's hard-coded dropdown value, named. PUBLIC */
+ --estilo-float-padding: 0.25rem;
+ --estilo-item-padding-block: 0.4rem;
+ --estilo-item-padding-inline: 0.65rem;
+}
+[estilo-menu] {
+ box-sizing: border-box;
+ /* Sized to its rows, not to what it hangs from. Every panel is absolutely
+ positioned inside a <details> no wider than its title, and an absolute
+ box's shrink-to-fit width is capped by that, so without this a row with
+ a shortcut wrapped ("Decrease / indent") inside a menu with room to
+ spare. Docs found it on first adoption. */
+ inline-size: max-content;
+ min-inline-size: 12rem;
+ max-inline-size: min(22rem, calc(100vw - 1rem));
+ /* The 8rem floor is for short frames, where the viewport maths alone can
+ leave a panel a few pixels tall: an overflowing menu can be scrolled,
+ an invisible one cannot. */
+ max-block-size: max(calc(100dvh - var(--estilo-bar-block-size, 3.25rem) - 1rem), 8rem);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: var(--estilo-float-padding);
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius);
+ box-shadow: var(--rst-shadow-pop);
+ font-size: var(--rst-fs-sm);
+ text-align: start;
+ z-index: 30;
+}
+[estilo-menu~="sheet"] {
+ position: absolute;
+ inset-inline: 0.5rem;
+ inset-block-start: calc(100% + 4px);
+ inline-size: auto;
+ max-inline-size: none;
+}
+[estilo-menu] form { margin: 0; }
+[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] {
+ display: inline-flex;
+ flex: none;
+ inline-size: 1rem;
+ block-size: 1rem;
+ visibility: hidden;
+ color: var(--rst-accent);
+}
+[estilo-menu-check] svg { inline-size: 100%; block-size: 100%; }
+[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 */
+}
+
+/* The document menu bar: File, Edit, View... Titles are in the page flow,
+ so their highlight is square. Every title joins the one top-level
+ exclusivity group, name="estilo-menus", with the bar's own triggers. */
+[estilo-menubar] { display: flex; flex-wrap: wrap; align-items: stretch; min-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;
+ box-sizing: border-box;
+ block-size: 100%;
+ min-block-size: 2rem;
+ padding-inline: 0.6rem;
+ border-radius: 0; /* square: in-flow */
+ color: var(--rst-text);
+ font-size: var(--rst-fs-sm);
+ white-space: nowrap;
+ list-style: none;
+ cursor: default;
+ user-select: none;
+}
+[estilo-menubar-title]::-webkit-details-marker { display: none; }
+[estilo-menubar-title]::marker { content: ""; }
+[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; }
+/* A panel hung from a title near the inline end overflows a phone, so
+ below 30rem every menubar panel spans the viewport instead. */
+@media (max-width: 30rem) {
+ [estilo-menubar-menu] { position: static; }
+ [estilo-menubar] { position: relative; }
+ [estilo-menubar-menu] > [estilo-menu] { inset-inline: 0.5rem; inline-size: auto; min-inline-size: 0; max-inline-size: none; }
+}
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go
index a0a37b8..7ecdfca 100644
--- a/scripts/writeestilo/main.go
+++ b/scripts/writeestilo/main.go
@@ -1,16 +1,20 @@
// Command writeestilo re-writes the files internal/memoria/static takes from
// the estilo this module pins: theme.css (Calma, with Memoria's accent),
// and rastrillo's busy rule as estilo carries it — rastrillo.js without
-// the old rule, busy.js and busy.css — and the sign-in door's two
-// stylesheets, chrome.css and sky.css. Run it from the repository root
+// the old rule, busy.js and busy.css — and the shared chrome: base.css
+// (links), menu.css (floating panels), chrome.css (the bar, the account
+// menu and the sign-in door), chrome.js (Escape and outside-click close)
+// and sky.css (the door's sky). Run it from the repository root
// after upgrading estilo; the pin tests in internal/memoriatest fail until you do.
package main
import (
"os"
+ "amadan.net/oficina/estilo.git/base"
"amadan.net/oficina/estilo.git/busy"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
)
@@ -25,7 +29,10 @@ func main() {
"rastrillo.js": busy.ShimJS(),
"busy.js": busy.JS(),
"busy.css": busy.CSS(),
+ "base.css": base.CSS(),
+ "menu.css": menu.CSS(),
"chrome.css": chrome.CSS(),
+ "chrome.js": chrome.JS(),
"sky.css": sky.CSS(),
} {
if err := os.WriteFile("internal/memoria/static/"+name, body, 0o644); err != nil {