oficina / estilo Public

Menu rows and bar actions that POST or open a dialog; a fill shell; one group closes

Sheets, the second adopter, corrects the chrome Docs built:

- menu.Item gains Action (a one-button POST form) and Dialog (a button
  opening a <dialog> through the invoker command show-modal). Sheets'
  New sheet is a POST and its Help moved into the account menu as a
  dialog; both work with scripts off.
- Bar.Actions is []Action (a menu.Item plus Primary), so Create is a
  POST both as the bar's primary button and as the Menu sheet's row.
  Breaking for Docs: []chrome.Link becomes []chrome.Action.
- estilo-shell="fill": the full-height shell for Sheets' grid. The
  viewport less the bar, with the bar held at 52px and the root's
  scrollbar gutter dropped.
- chrome.js closes every member of the one estilo-menus group on Escape
  and outside click, menubar menus included, and closes the menu that
  held a dialog row (with a showModal fallback).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 50567b151510c6a762e5187e48172e979967458a parent 76dbe9b
8 files changed, +194 −29
  • chrome/chrome.css +19 −0
  • chrome/chrome.go +16 −3
  • chrome/chrome.js +35 −10
  • chrome/chrome_test.go +60 −4
  • chrome/templates/bar.html +9 −6
  • menu/menu.go +18 −1
  • menu/menu_test.go +28 −0
  • menu/templates/menu.html +9 −5

CI failed — run details

CI log
=== vet ===
CGO_ENABLED=0 go vet ./...
../../cache/go-mod/github.com/carlosframework/rastrillo@v0.23.0/sessions/sessions.go:72:12: pattern migrations/*.sql: no matching files found
make: *** [Makefile:22: test-vet] Error 1

amadan: step vet failed: exit status 2

amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/chrome/chrome.css b/chrome/chrome.css
index d40baff..5670e8f 100644
--- a/chrome/chrome.css
+++ b/chrome/chrome.css
@@ -58,6 +58,7 @@
[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-actions] form { display: contents; } /* a POST action lays out as its button */
[estilo-bar-trigger] {
display: inline-flex;
align-items: center;
@@ -87,6 +88,24 @@
[estilo-bar]:has([estilo-bar-context], [estilo-bar-title]) [estilo-bar-name] { display: none; } /* the icon stays; the title gets the room */
}
+/* The shell. <div estilo-shell> wraps the skip link, the bar and <main>
+ on every page and has no styling of its own. estilo-shell="fill" is the
+ full-height layout for the one kind of page that must fill the viewport
+ (Sheets' grid): the shell is exactly the viewport, the bar keeps its
+ 52px, and <main> takes the rest, so the working area is the viewport
+ less the bar without any app re-deriving that sum. The document never
+ scrolls there, so the root drops its reserved scrollbar gutter, which
+ would otherwise leave a dead strip down the inline end. <main> must be
+ the shell's direct child. */
+[estilo-shell~="fill"] {
+ display: flex;
+ flex-direction: column;
+ block-size: 100dvh;
+}
+[estilo-shell~="fill"] > [estilo-bar] { flex: none; }
+[estilo-shell~="fill"] > main { flex: 1 1 auto; min-block-size: 0; }
+:root:has([estilo-shell~="fill"]) { scrollbar-gutter: auto; }
+
/* The editable title slot: the open object's title, renamed in place. The
input is a bordered control, so it keeps day's radius; its block size is
fixed so an input in the bar can never grow the bar. */
diff --git a/chrome/chrome.go b/chrome/chrome.go
index c72c159..ef49e6a 100644
--- a/chrome/chrome.go
+++ b/chrome/chrome.go
@@ -75,6 +75,19 @@ type TitleEdit struct {
Label, Submit string
}
+// Action is one page action. Its Item says what activating it does (a
+// link, a POST or a dialog; see menu.Item), so the bar's button and the
+// Menu sheet's row are the same element kind and cannot disagree. Primary
+// marks the page's main action, drawn as the primary button.
+//
+// Docs' actions were all links (chrome.Link). Sheets' Create is a POST,
+// and a link in the sheet would be a GET to the list that creates
+// nothing, so the action carries the item instead.
+type Action struct {
+ menu.Item
+ Primary bool
+}
+
// BarLabels are the bar's own strings: the skip link (rendered by the
// app's layout) and the narrow Menu trigger.
type BarLabels struct {
@@ -91,9 +104,9 @@ type Bar struct {
// Nav is the app's section links; NavLabel their accessible name.
Nav []Link
NavLabel string
- // Actions are page actions ("Share…"): buttons at wide widths, rows
- // in the Menu sheet below 800px.
- Actions []Link
+ // Actions are page actions ("Share…", "Create"): buttons at wide
+ // widths, rows in the Menu sheet below 800px.
+ Actions []Action
// Switcher is optional; nil drops it with nothing else to change.
Switcher *Switcher
// Account is set when someone is signed in. Otherwise SignIn is the
diff --git a/chrome/chrome.js b/chrome/chrome.js
index f0de97f..0ef7ea3 100644
--- a/chrome/chrome.js
+++ b/chrome/chrome.js
@@ -1,17 +1,26 @@
-// chrome/chrome.js: optional behaviour for estilo-bar. Load it with
-// <script defer>. Everything in the bar already works without it; this
-// only adds what <details> cannot do for itself:
+// 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 bar panel (Menu, switcher, account) and
-// returns focus to its trigger;
-// - a click outside the open bar panel closes it;
+// - 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.
+// 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.
//
-// Menubar menus belong to the app that renders them and are not touched.
+// 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. An app script that handles a key first
+// (Docs' menubar) calls preventDefault, and this then does nothing.
;(() => {
- const panels = '[estilo-bar] details[estilo-account], [estilo-bar] details[estilo-switcher], [estilo-bar] details[estilo-bar-more]'
- const openPanel = () => document.querySelector(panels.split(', ').map((s) => s + '[open]').join(', '))
+ const group = 'details[name="estilo-menus"][open]'
+ const openPanel = () => document.querySelector(group)
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return
@@ -34,4 +43,20 @@
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', () => summary.focus(), { once: true })
+ }
+ if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return
+ dialog.showModal()
+ e.preventDefault()
+ })
})()
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go
index c78bdd1..052f7d8 100644
--- a/chrome/chrome_test.go
+++ b/chrome/chrome_test.go
@@ -88,8 +88,11 @@ func signedIn(label string) Bar {
ContextEdit: &TitleEdit{Action: "/d/abc/title", Name: "title", Value: label + "-title", Label: label + "-title-label", Submit: label + "-rename"},
Nav: []Link{{Href: "/", Label: label + "-nav", Current: true}},
NavLabel: label + "-navlabel",
- Actions: []Link{{Href: "/d/abc/share", Label: label + "-share"}},
- Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}},
+ Actions: []Action{
+ {Item: menu.Item{Href: "/d/abc/share", Label: label + "-share"}},
+ {Item: menu.Item{Action: "/d/new", Label: label + "-create"}, Primary: true},
+ },
+ Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}},
Account: &Account{
TriggerLabel: label + "-trigger", Initials: "P", Address: "paul@example.test",
Labels: AccountLabels{SignedInAs: label + "-signedinas", Appearance: label + "-appearance", Help: label + "-help"},
@@ -111,7 +114,7 @@ func TestBarRendersCallerLabelsInEveryLocale(t *testing.T) {
`<a estilo-bar-brand href="/">`, esc(label) + "-app",
`<form estilo-bar-title method="post" action="/d/abc/title">`, `value="` + esc(label) + `-title"`, `aria-label="` + esc(label) + `-title-label"`, esc(label) + "-rename",
`aria-label="` + esc(label) + `-navlabel"`, `aria-current="page"`,
- esc(label) + "-share", esc(label) + "-menu",
+ esc(label) + "-share", esc(label) + "-create", esc(label) + "-menu",
`aria-label="` + esc(label) + `-apps"`, esc(label) + "-allapps",
`aria-label="` + esc(label) + `-trigger"`, esc(label) + "-signedinas", "paul@example.test",
esc(label) + "-connect", esc(label) + "-appearance", esc(label) + "-light", esc(label) + "-dark", esc(label) + "-system",
@@ -235,11 +238,60 @@ func TestNoMenuTriggerWithoutNavOrActions(t *testing.T) {
}
}
+// An action is not always a link. Sheets' Create is a POST, and it must
+// be one in both places the bar draws it: a primary button at wide widths
+// and a row in the narrow Menu sheet. Drawn as a link in the sheet, it
+// would be a GET to the list and create nothing.
+func TestActionsCanPostInTheBarAndTheSheet(t *testing.T) {
+ html := render(t, "estilo-bar", signedIn("x"))
+ actions := html[strings.Index(html, "<div estilo-bar-actions>"):strings.Index(html, "<details estilo-bar-more")]
+ for _, want := range []string{
+ `<a rst-btn href="/d/abc/share">x-share</a>`,
+ `<form method="post" action="/d/new"><button rst-btn="primary" type="submit">x-create</button></form>`,
+ } {
+ if !strings.Contains(actions, want) {
+ t.Errorf("bar actions lack %q in %s", want, actions)
+ }
+ }
+ sheet := html[strings.Index(html, `<div estilo-menu="sheet">`):strings.Index(html, "<details estilo-switcher")]
+ for _, want := range []string{
+ `<a estilo-menu-item href="/d/abc/share"><span estilo-menu-label>x-share</span></a>`,
+ `<form method="post" action="/d/new"><button estilo-menu-item type="submit"><span estilo-menu-label>x-create</span></button></form>`,
+ } {
+ if !strings.Contains(sheet, want) {
+ t.Errorf("the Menu sheet lacks %q in %s", want, sheet)
+ }
+ }
+ b := signedIn("x")
+ b.Actions = []Action{{Item: menu.Item{Dialog: "help", Label: "x-help"}}}
+ html = render(t, "estilo-bar", b)
+ if !strings.Contains(html, `<button rst-btn type="button" commandfor="help" command="show-modal">x-help</button>`) {
+ t.Error("a dialog action is not an invoker button", html)
+ }
+}
+
+// A full-height app (Sheets' grid) keys its layout on the shell. The rule
+// is estilo's, not each app's: the working area is the viewport less the
+// bar, and the bar must neither grow nor shrink inside it.
+func TestTheFillShellGivesTheWorkingAreaTheRestOfTheViewport(t *testing.T) {
+ src := string(CSS())
+ for _, want := range []string{
+ "[estilo-shell~=\"fill\"] {\n display: flex;\n flex-direction: column;\n block-size: 100dvh;",
+ "[estilo-shell~=\"fill\"] > [estilo-bar] { flex: none; }",
+ "[estilo-shell~=\"fill\"] > main { flex: 1 1 auto; min-block-size: 0; }",
+ } {
+ if !strings.Contains(src, want) {
+ t.Errorf("chrome.css lacks the fill shell rule %q", want)
+ }
+ }
+}
+
func TestUntrustedValuesAreEscaped(t *testing.T) {
b := signedIn("x")
b.ContextEdit.Value = `"><script>bad()</script>`
b.Account.Address = `<img src=x onerror=bad>`
b.Actions[0].Href = "javascript:bad()"
+ b.Actions[1].Action = "javascript:bad()"
html := render(t, "estilo-bar", b)
if strings.Contains(html, "<script>") || strings.Contains(html, "<img src=x") || !strings.Contains(html, "#ZgotmplZ") {
t.Fatal("unsafe bar markup", html)
@@ -368,7 +420,11 @@ func TestScriptIsClassicAndSelfContained(t *testing.T) {
if strings.Contains(src, "import ") || strings.Contains(src, "export ") {
t.Fatal("chrome.js must be a classic script")
}
- for _, want := range []string{"Escape", "estilo-bar-title-input", "defaultValue"} {
+ // Escape and outside-click close every member of the one top-level
+ // group, the menubar's menus included: Sheets' menubar lost both when
+ // it left rastrillo's dropdown. And an invoker row closes the menu it
+ // was in, with a fallback where the platform lacks invoker commands.
+ for _, want := range []string{"Escape", "estilo-bar-title-input", "defaultValue", `details[name="estilo-menus"][open]`, "commandfor", "showModal"} {
if !strings.Contains(src, want) {
t.Errorf("chrome.js lost %q", want)
}
diff --git a/chrome/templates/bar.html b/chrome/templates/bar.html
index 4ac79e8..84bd9e8 100644
--- a/chrome/templates/bar.html
+++ b/chrome/templates/bar.html
@@ -36,7 +36,7 @@
<div estilo-menu="sheet">
{{- range .Nav}}<a estilo-menu-item href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}><span estilo-menu-label>{{.Label}}</span></a>{{end}}
{{- if and .Nav .Actions}}<hr>{{end}}
- {{- range .Actions}}<a estilo-menu-item href="{{.Href}}"><span estilo-menu-label>{{.Label}}</span></a>{{end}}
+ {{- range .Actions}}{{template "estilo-menu-item" .Item}}{{end}}
</div>
</details>
{{- end}}
@@ -51,11 +51,14 @@
</div>
</header>{{end}}
-{{/* estilo-bar-actions: the wide-screen actions, from Bar.Actions. The
- same links appear as rows in the narrow Menu sheet. An app may
- redefine this after parsing to change how they are drawn, but the
- sheet always renders Bar.Actions, so both stay one list. */}}
-{{define "estilo-bar-actions"}}{{range .Actions}}<a rst-btn href="{{.Href}}">{{.Label}}</a>{{end}}{{end}}
+{{/* estilo-bar-actions: the wide-screen actions, from Bar.Actions, each
+ the element its Item names: a link, a one-button POST form, or a
+ button opening a dialog. The same items are rows in the narrow Menu
+ sheet, so both stay one list. An app may redefine this after parsing
+ to change how they are drawn, but the sheet always renders
+ Bar.Actions. */}}
+{{define "estilo-bar-actions"}}{{range .Actions}}{{template "estilo-bar-action" .}}{{end}}{{end}}
+{{define "estilo-bar-action"}}{{if .Action}}<form method="post" action="{{.Action}}"><button rst-btn{{if .Primary}}="primary"{{end}} type="submit">{{.Label}}</button></form>{{else if .Dialog}}<button rst-btn{{if .Primary}}="primary"{{end}} type="button" commandfor="{{.Dialog}}" command="show-modal">{{.Label}}</button>{{else}}<a rst-btn{{if .Primary}}="primary"{{end}} href="{{.Href}}">{{.Label}}</a>{{end}}{{end}}
{{/* estilo-bar-title: the open object's title, editable in place. A real
form, so Enter renames with scripts off. The submit button is visually
diff --git a/menu/menu.go b/menu/menu.go
index 59a78c6..f143c47 100644
--- a/menu/menu.go
+++ b/menu/menu.go
@@ -29,11 +29,28 @@ func CSS() []byte { return append([]byte(nil), css...) }
// the app's page templates with ParseFS(Templates(), "templates/*.html").
func Templates() embed.FS { return templateFS }
-// Item is one link row rendered by estilo-menu-item.
+// Item is one row rendered by estilo-menu-item. Set exactly one of Href,
+// Action and Dialog; each is a different element, and each works with
+// scripts off:
+//
+// - Href: a link, <a href>, for a row that navigates.
+// - Action: a one-button <form method="post"> to that path, for a row
+// that changes something ("New sheet").
+// - Dialog: a <button> that opens the <dialog> with that id through the
+// platform's invoker command (command="show-modal"), for a row that
+// asks for more before anything happens ("Keyboard shortcuts…").
+// chrome.js opens it where the engine has no invoker commands.
+//
+// Action and Dialog came with Sheets, the second adopter: Docs' rows were
+// all links, and Sheets' Help moved into the account menu as a dialog.
type Item struct {
Href, Label string
// Key is an already-formatted shortcut, from Shortcut. Optional.
Key string
+ // Action is the POST target of a form row.
+ Action string
+ // Dialog is the id of the <dialog> a button row opens.
+ Dialog string
}
// Apple reports whether the request comes from an Apple platform, the
diff --git a/menu/menu_test.go b/menu/menu_test.go
index 4af67e2..60c5158 100644
--- a/menu/menu_test.go
+++ b/menu/menu_test.go
@@ -71,6 +71,34 @@ func TestItemRendersCallerLabelsInEveryLocale(t *testing.T) {
}
}
+// Sheets, the second adopter, needs rows that are not links: New sheet
+// is a POST, and Keyboard shortcuts… opens a dialog. Both must work with
+// scripts off, so a POST is a one-button form and a dialog opens with the
+// platform's invoker command (command="show-modal"), not a click handler.
+func TestItemCanPostOrOpenADialog(t *testing.T) {
+ for locale, label := range locales {
+ t.Run(locale, func(t *testing.T) {
+ esc := template.HTMLEscapeString(label)
+ html := render(t, "estilo-menu-item", Item{Action: "/sheets", Label: label, Key: "⌥N"})
+ if !strings.Contains(html, `<form method="post" action="/sheets"><button estilo-menu-item type="submit"><span estilo-menu-label>`+esc+`</span><kbd estilo-menu-key>⌥N</kbd></button></form>`) || strings.Contains(html, "<a ") {
+ t.Fatalf("a POST row is not a one-button form: %s", html)
+ }
+ html = render(t, "estilo-menu-item", Item{Dialog: "sheets-shortcuts", Label: label})
+ if !strings.Contains(html, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>`+esc+`</span></button>`) || strings.Contains(html, "<a ") || strings.Contains(html, "<form") {
+ t.Fatalf("a dialog row is not an invoker button: %s", html)
+ }
+ })
+ }
+ html := render(t, "estilo-menu-item", Item{Action: "javascript:bad()", Label: "x"})
+ if !strings.Contains(html, "#ZgotmplZ") {
+ t.Fatal("an unsafe POST target is not neutralised", html)
+ }
+ html = render(t, "estilo-menu-item", Item{Dialog: `x" onclick="bad()`, Label: "x"})
+ if strings.Contains(html, `onclick="bad()"`) {
+ t.Fatal("a dialog id escapes its attribute", html)
+ }
+}
+
func TestItemEscapesUntrustedHrefAndLabel(t *testing.T) {
html := render(t, "estilo-menu-item", Item{Href: "javascript:alert(1)", Label: "<img src=x onerror=bad>"})
if !strings.Contains(html, "#ZgotmplZ") || strings.Contains(html, "<img") {
diff --git a/menu/templates/menu.html b/menu/templates/menu.html
index 8eb07c0..f0982b8 100644
--- a/menu/templates/menu.html
+++ b/menu/templates/menu.html
@@ -1,8 +1,12 @@
-{{/* estilo-menu-item: one row in any estilo-menu. A link: every action that
- can be a link or a form works with scripts off. Callers supply the
- label in their own locale; Key is an already-formatted shortcut
- (menu.Shortcut). */}}
-{{define "estilo-menu-item"}}<a estilo-menu-item href="{{.Href}}"><span estilo-menu-label>{{.Label}}</span>{{with .Key}}<kbd estilo-menu-key>{{.}}</kbd>{{end}}</a>{{end}}
+{{/* estilo-menu-item: one row in any estilo-menu. Data: menu.Item, with
+ exactly one of Href (a link), Action (a one-button POST form) or
+ Dialog (a button opening the <dialog> with that id through the
+ platform's invoker command). All three work with scripts off; chrome.js
+ only adds the dialog for engines without invoker commands. Callers
+ supply the label in their own locale; Key is an already-formatted
+ shortcut (menu.Shortcut). */}}
+{{define "estilo-menu-item"}}{{if .Action}}<form method="post" action="{{.Action}}"><button estilo-menu-item type="submit">{{template "estilo-menu-item-body" .}}</button></form>{{else if .Dialog}}<button estilo-menu-item type="button" commandfor="{{.Dialog}}" command="show-modal">{{template "estilo-menu-item-body" .}}</button>{{else}}<a estilo-menu-item href="{{.Href}}">{{template "estilo-menu-item-body" .}}</a>{{end}}{{end}}
+{{define "estilo-menu-item-body"}}<span estilo-menu-label>{{.Label}}</span>{{with .Key}}<kbd estilo-menu-key>{{.}}</kbd>{{end}}{{end}}
{{/* estilo-icon-check: the glyph inside an estilo-menu-check column.
Lucide "check" (ISC, brand/LICENSE.lucide). */}}
{{define "estilo-icon-check"}}<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>{{end}}