oficina / docs Public

Style the share page, give the one-time link a Copy button, one-row toolbar on phones

- Share page (the audit's worst Docs finding) on rastrillo's vocabulary:
  grants as list rows with a ghost Stop sharing, the address through the
  field partial, the role as option cards, rst-btn throughout, and the
  one-time link in a read-only field with a Copy button
  (fixtures/share-link.mjs, a new small bundle; hidden until it runs,
  the field selects on focus without it) and a "Link copied." status.
  TestTheSharePageIsBuiltFromTheDesignSystem, red against the reviewed
  share.html.
- Below 40rem the formatting bar is one row that scrolls sideways
  instead of three wrapped rows. The combobox lists are anchor-
  positioned to their inputs so the scroller cannot clip them; where
  anchor positioning is missing (Firefox) the old wrap stays. CLS 0 at
  390 and 320 over eight loads.
- fixtures/browser-drive.mjs anchors on [estilo-bar-brand] and expects
  the editor at the bar's own tab stops + 3, counted from the page,
  instead of the removed [rst-shell-brand] and a pinned four.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 37f05591d7488d0a4bd1449f5d19f74692470f42 parent fd64413
10 files changed, +331 −67
  • fixtures/browser-drive.mjs +27 −8
  • fixtures/build-bundles.mjs +3 −0
  • fixtures/share-link.mjs +45 −0
  • internal/docs/assets.go +4 −1
  • internal/docs/render.go +5 −0
  • internal/docs/share.go +25 −0
  • internal/docs/share_page_test.go +52 −0
  • internal/docs/static/share.b7f152cdd9e793ac.js +1 −0
  • internal/docs/views/share.html +119 −58
  • internal/docs/views/show.html +50 −0
diff --git a/fixtures/browser-drive.mjs b/fixtures/browser-drive.mjs
index 5d40205..33a45c8 100644
--- a/fixtures/browser-drive.mjs
+++ b/fixtures/browser-drive.mjs
@@ -1031,18 +1031,29 @@ async function caretChecks() {
// editor, from check 6 above). It reported 8, which was a true
// measurement of nothing. That is the failure shape AGENTS.md
// names twice, so: the anchor must be something always rendered.
- // [rst-shell-brand] is — it is the first focusable thing in the
- // bar at every width — and the assertion below fails if it ever
- // stops being focusable, rather than quietly measuring from
- // somewhere else.
+ // [estilo-bar-brand] is — the estilo bar's brand link, the first
+ // focusable thing in the bar at every width (it was rastrillo's
+ // [rst-shell-brand] until round 2 replaced the shell) — and the
+ // assertion below fails if it ever stops being focusable, rather
+ // than quietly measuring from somewhere else.
const anchored = await evaluate(
- `(()=>{const b=document.querySelector('[rst-shell-brand]');if(!b)return false;b.focus();` +
+ `(()=>{const b=document.querySelector('[estilo-bar-brand]');if(!b)return false;b.focus();` +
`return document.activeElement===b})()`,
)
check(
'the tab-order check starts where it thinks it does',
anchored === true,
- 'could not focus [rst-shell-brand] — the count below would measure from somewhere else',
+ 'could not focus [estilo-bar-brand] — the count below would measure from somewhere else',
+ )
+ // How many tab stops the bar itself has after the brand, counted from
+ // the live page rather than pinned: the title field and its Rename
+ // button, Share…, the switcher when configured, the account trigger.
+ // Pinning a total is what made the old bound go stale when the bar
+ // changed; what check 8 below holds is the claim that matters — the menu
+ // bar and the formatting bar are ONE stop each.
+ const barStops = await evaluate(
+ `(()=>{const bar=document.querySelector('[estilo-bar]');const brand=bar.querySelector('[estilo-bar-brand]');` +
+ `return [...bar.querySelectorAll('a[href],button,input,summary')].filter(e=>e!==brand&&e.tabIndex>=0&&!e.disabled&&e.getClientRects().length>0&&getComputedStyle(e).visibility!=='hidden').length})()`,
)
await sleep(200)
let reachedAfter = -1
@@ -1069,6 +1080,9 @@ async function caretChecks() {
// Four, derived rather than chosen, at this drive's 1280px (above
// the shell's own 800px breakpoint, so the bar is laid out wide:
// the hamburger is hidden and the tail is display: contents).
+ // (History: the paragraphs below derived a fixed four for the
+ // rastrillo shell. Round 2 counts the bar's own stops from the page
+ // instead; see barStops above and the check at the end.)
// From [rst-shell-brand], in DOM order: the account summary (1),
// the menu bar, entire, as one roving-tabindex stop (2), the
// formatting bar, entire, as one roving-tabindex stop (3), the
@@ -1102,10 +1116,15 @@ async function caretChecks() {
// The first real run of this check is therefore a check on this
// derivation as much as on the page; if it reports five, the extra
// stop is the thing to find, not the bound to raise.
+ // Round 2 (estilo bar): the stops are the bar's own (barStops,
+ // counted above from the page), then the menu bar as one stop, the
+ // formatting bar as one stop, then the editor. So the editor is
+ // exactly barStops + 3 presses away, and a menu bar or toolbar that
+ // leaks a second stop makes it barStops + 4 or more.
check(
'the menu bar and the formatting bar are one tab stop each',
- reachedAfter > 0 && reachedAfter <= 4,
- `the editor was ${reachedAfter} tab presses from the shell bar`,
+ reachedAfter > 0 && reachedAfter === barStops + 3,
+ `the editor was ${reachedAfter} tab presses from the brand; the bar has ${barStops} stops of its own, so ${barStops + 3} was expected`,
)
// 9. ...and the arrow keys are what move inside it.
diff --git a/fixtures/build-bundles.mjs b/fixtures/build-bundles.mjs
index a2e42e1..b0aa4a8 100644
--- a/fixtures/build-bundles.mjs
+++ b/fixtures/build-bundles.mjs
@@ -39,6 +39,9 @@ mkdirSync(staticDir, { recursive: true })
const bundles = [
{ name: 'editor', entry: 'editor-entry.js' },
{ name: 'jumpto', entry: 'jump-to.mjs' },
+ // The share page's Copy button: a few hundred bytes, and only that
+ // page needs it.
+ { name: 'share', entry: 'share-link.mjs' },
]
for (const { name, entry } of bundles) {
diff --git a/fixtures/share-link.mjs b/fixtures/share-link.mjs
new file mode 100644
index 0000000..b237af5
--- /dev/null
+++ b/fixtures/share-link.mjs
@@ -0,0 +1,45 @@
+// fixtures/share-link.mjs — the share page's Copy button.
+//
+// The published link is shown exactly once (suite ruling 16: the server
+// keeps a hash, not the URL), so copying it is the whole job of that
+// moment. Without script the page still works: the link is in a
+// read-only field that selects on focus, and a person copies it by hand.
+// With script this reveals the Copy button, writes the link to the
+// clipboard, and says so in the polite status line beside it.
+//
+// It carries no copy of its own: the "copied" wording is rendered by the
+// server from the catalog into data-copied.
+
+export function mountCopy(root) {
+ const input = root.querySelector('[docs-share-url-input]')
+ const button = root.querySelector('[docs-share-copy]')
+ const status = root.querySelector('[docs-share-copy-status]')
+ if (!input || !button) return false
+ input.addEventListener('focus', () => input.select())
+ button.hidden = false
+ button.addEventListener('click', async () => {
+ input.focus()
+ input.select()
+ let ok = false
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(input.value)
+ ok = true
+ }
+ } catch {
+ ok = false
+ }
+ if (!ok) {
+ try {
+ ok = document.execCommand('copy')
+ } catch {
+ ok = false
+ }
+ }
+ if (status) status.textContent = ok ? button.getAttribute('data-copied') || '' : ''
+ })
+ return true
+}
+
+const root = document.querySelector('[docs-share-public]')
+if (root) mountCopy(root)
diff --git a/internal/docs/assets.go b/internal/docs/assets.go
index c85c0ec..93cefd3 100644
--- a/internal/docs/assets.go
+++ b/internal/docs/assets.go
@@ -71,7 +71,7 @@ var bundleNames = mustResolveBundleNames()
// bundle at all would otherwise start cleanly and simply never offer
// the palette, which is the same silent-failure shape the
// exactly-one rule above exists to prevent.
-var bundleKinds = []string{"editor", "jumpto"}
+var bundleKinds = []string{"editor", "jumpto", "share"}
func mustResolveBundleNames() map[string]string {
entries, err := fs.ReadDir(staticFS, "static")
@@ -118,6 +118,9 @@ func editorScriptPath() string { return bundlePath("editor") }
// fixtures/jump-to.mjs), loaded by both the list and a document.
func jumpToScriptPath() string { return bundlePath("jumpto") }
+// shareScriptPath is the share page's Copy button (fixtures/share-link.mjs).
+func shareScriptPath() string { return bundlePath("share") }
+
// iconPath is the absolute URL path this app's tab icon is served at,
// and views/head.html's <link rel="icon"> href. Fixed rather than
// content-hashed the way the editor bundle above is: a favicon is
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 5440373..1867450 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -115,6 +115,11 @@ var Catalog = rastrillo.Catalog{
"docs.share.public.once": "Copy this link now. This is the only time it will be shown.",
"docs.share.public.published": "This document is published. The link was shown once when it was created and cannot be shown again — replace it if you no longer have it.",
"docs.share.public.replace": "Replace the link",
+ // The one-time link's field and its Copy button (share.html). "Link
+ // copied." is the suite's wording for the same confirmation (Calls).
+ "docs.share.public.url": "Published link",
+ "docs.share.public.copy": "Copy link",
+ "docs.share.public.copied": "Link copied.",
// docs.public.readonly is the standing notice on the published page
// itself (views/public.html). It is there because a person arriving
diff --git a/internal/docs/share.go b/internal/docs/share.go
index af4e954..76cc7d5 100644
--- a/internal/docs/share.go
+++ b/internal/docs/share.go
@@ -50,6 +50,22 @@ type sharePage struct {
CopyHint string
OnceWarning string
PublishedBody string
+
+ // The role picker's option cards (rastrillo's choice-field), the
+ // one-time link's accessible name, and the Copy button with the
+ // status it announces (fixtures/share-link.mjs).
+ RoleChoices []roleChoice
+ URLLabel string
+ CopyButton string
+ CopiedLabel string
+ ShareScript string
+}
+
+// roleChoice is one option card: rastrillo's choice-field reads Value,
+// Title, Desc and Checked.
+type roleChoice struct {
+ Value, Title, Desc string
+ Checked bool
}
// shareRow is one person who reaches this document.
@@ -160,6 +176,15 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
CopyHint: rastrillo.T(r, "docs.share.public.hint"),
OnceWarning: rastrillo.T(r, "docs.share.public.once"),
PublishedBody: rastrillo.T(r, "docs.share.public.published"),
+
+ RoleChoices: []roleChoice{
+ {Value: "reader", Title: rastrillo.T(r, "docs.share.role.reader"), Checked: true},
+ {Value: "writer", Title: rastrillo.T(r, "docs.share.role.writer")},
+ },
+ URLLabel: rastrillo.T(r, "docs.share.public.url"),
+ CopyButton: rastrillo.T(r, "docs.share.public.copy"),
+ CopiedLabel: rastrillo.T(r, "docs.share.public.copied"),
+ ShareScript: shareScriptPath(),
}
if errKey != "" {
page.Error = rastrillo.T(r, errKey)
diff --git a/internal/docs/share_page_test.go b/internal/docs/share_page_test.go
new file mode 100644
index 0000000..aae9489
--- /dev/null
+++ b/internal/docs/share_page_test.go
@@ -0,0 +1,52 @@
+package docs_test
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// The share page is on the design system's vocabulary (the audit's worst
+// Docs finding: browser-default buttons, a bare fieldset, a bulleted list
+// of grants, and a one-time link printed as <code> with no Copy button).
+func TestTheSharePageIsBuiltFromTheDesignSystem(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
+ published := post(t, app, alice, "/d/"+sqid+"/share/link", "on=1")
+ body := get(t, app, alice, published.Header().Get("Location")).Body.String()
+ if published.Header().Get("Location") == "" {
+ body = published.Body.String()
+ }
+
+ if m := regexp.MustCompile(`<button(?: [^>]*)?>`).FindAllString(body, -1); len(m) == 0 {
+ t.Fatal("no buttons on the share page")
+ } else {
+ for _, b := range m {
+ if !strings.Contains(b, "rst-btn") && !strings.Contains(b, "estilo-") {
+ t.Errorf("an unstyled button: %s", b)
+ }
+ }
+ }
+ for _, gone := range []string{`<ul[ >]`, `<li[ >]`, `<code>`, `<fieldset>`} {
+ if regexp.MustCompile(gone).MatchString(body) {
+ t.Errorf("the share page still writes %s", gone)
+ }
+ }
+ for _, want := range []string{
+ `<div rst-list docs-share-list>`,
+ `<span docs-share-who>bob@example.com</span>`,
+ `<fieldset rst-choice>`,
+ `type="email" id="share-address" name="address"`,
+ `docs-share-url-input type="text" readonly value="http`,
+ `docs-share-copy data-copied="docs.share.public.copied" hidden>docs.share.public.copy</button>`,
+ `<script type="module" defer src="/static/share.`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("the share page lacks %s", want)
+ }
+ }
+}
diff --git a/internal/docs/static/share.b7f152cdd9e793ac.js b/internal/docs/static/share.b7f152cdd9e793ac.js
new file mode 100644
index 0000000..b8678c5
--- /dev/null
+++ b/internal/docs/static/share.b7f152cdd9e793ac.js
@@ -0,0 +1 @@
+function s(o){let e=o.querySelector("[docs-share-url-input]"),c=o.querySelector("[docs-share-copy]"),r=o.querySelector("[docs-share-copy-status]");return!e||!c?!1:(e.addEventListener("focus",()=>e.select()),c.hidden=!1,c.addEventListener("click",async()=>{e.focus(),e.select();let t=!1;try{navigator.clipboard&&navigator.clipboard.writeText&&(await navigator.clipboard.writeText(e.value),t=!0)}catch{t=!1}if(!t)try{t=document.execCommand("copy")}catch{t=!1}r&&(r.textContent=t&&c.getAttribute("data-copied")||"")}),!0)}var a=document.querySelector("[docs-share-public]");a&&s(a);export{s as mountCopy};
diff --git a/internal/docs/views/share.html b/internal/docs/views/share.html
index f8ad7a5..4d9190d 100644
--- a/internal/docs/views/share.html
+++ b/internal/docs/views/share.html
@@ -14,102 +14,163 @@
That is also why the address rides a hidden field rather than the
URL — the row already names it in text, and a URL is copied,
logged and pasted in a way a form body is not. */}}
-{{define "content"}}
-<article rst-stack>
+{{define "content"}}<style>
+ /* The share page on the design system's own vocabulary: fields, option
+ cards, list rows and buttons. It was the audit's worst Docs finding —
+ browser-default buttons, a bare fieldset, a bulleted list of grants,
+ and a one-time link printed as <code> with no way to copy it. */
+ [docs-share] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-5);
+ max-inline-size: 44rem;
+ }
+ [docs-share] > [rst-page-header] {
+ margin-block-end: 0;
+ }
+ [docs-share-list] [rst-row] {
+ gap: var(--rst-sp-3);
+ }
+ [docs-share-who] {
+ overflow-wrap: anywhere;
+ }
+ [docs-share-role] {
+ flex: none;
+ font-size: var(--rst-fs-sm);
+ }
+ [docs-share-list] form {
+ flex: none;
+ margin: 0;
+ }
+ [docs-share-public] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-3);
+ padding-block-start: var(--rst-sp-5);
+ border-block-start: 1px solid var(--rst-line);
+ }
+ [docs-share-public] h2,
+ [docs-share-public] p {
+ margin: 0;
+ }
+ [docs-share-url] {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ }
+ [docs-share-url-input] {
+ flex: 1 1 16rem;
+ min-inline-size: 0;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: var(--rst-fs-sm);
+ }
+ [docs-share-copy-status] {
+ margin: 0;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ }
+ [docs-share-actions] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+ }
+ [docs-share-actions] form {
+ margin: 0;
+ }
+</style>
+<article rst-stack docs-share>
<header rst-page-header>
- <div rst-titles>
+ <div rst-page-header-titles>
<h1>{{.Heading}}</h1>
<p rst-muted>{{.DocTitle}}</p>
</div>
- <a href="/d/{{.Sqid}}">{{.BackLabel}}</a>
+ <a rst-btn="ghost" href="/d/{{.Sqid}}">{{.BackLabel}}</a>
</header>
{{if .Error}}{{template "callout" (dict "Body" .Error "Tone" "negative" "Alert" true)}}{{end}}
{{/* Who reaches it today. An empty list says so in words rather than
rendering an empty table, because a table with no rows reads as a
- thing that failed to load. */}}
+ thing that failed to load.
+
+ Each row's revoke is its own <form>, not a link: revoking is a
+ mutation and a GET that changes state is one a prefetcher can fire.
+ That is also why the address rides a hidden field rather than the
+ URL. */}}
{{if .Grants}}
- <ul rst-list docs-share-list>
+ <div rst-list docs-share-list>
{{range .Grants}}
- <li rst-list-item>
- <span docs-share-who>{{.Address}}</span>
+ <div rst-row>
+ <span rst-row-main><span docs-share-who>{{.Address}}</span></span>
<span rst-muted docs-share-role>{{.Role}}</span>
<form method="post" action="/d/{{$.Sqid}}/share/revoke">
<input type="hidden" name="address" value="{{.Address}}">
- <button type="submit">{{$.RevokeButton}}</button>
+ <button rst-btn="ghost" type="submit">{{$.RevokeButton}}</button>
</form>
- </li>
+ </div>
{{end}}
- </ul>
+ </div>
{{else}}
<p rst-muted>{{.EmptyBody}}</p>
{{end}}
- {{/* Adding somebody. The role is a radio group rather than a select so
- both options are visible without opening anything, and so the
- keyboard contract is the one radios already have. Reader is
+ {{/* Adding somebody. The role is a pair of option cards rather than a
+ select so both are visible without opening anything. Reader is
checked by default: the safer of the two is the one a person gets
- by not choosing. */}}
- <form method="post" action="/d/{{.Sqid}}/share" docs-share-form>
- <label for="share-address">{{.AddressLabel}}</label>
- <input id="share-address" name="address" type="email" autocomplete="email" required>
-
- <fieldset>
- <legend>{{.RoleLabel}}</legend>
- <label><input type="radio" name="role" value="reader" checked> {{.RoleReader}}</label>
- <label><input type="radio" name="role" value="writer"> {{.RoleWriter}}</label>
- </fieldset>
-
- <button type="submit">{{.ShareButton}}</button>
+ by not choosing. Scriptless, per spec §6.2: a plain POST, and a 303
+ back here afterwards so a reload never re-posts. */}}
+ <form rst-form method="post" action="/d/{{.Sqid}}/share" docs-share-form>
+ {{template "field" (dict "ID" "share-address" "Name" "address" "Type" "email" "Label" .AddressLabel "Autocomplete" "email" "Required" true)}}
+ {{template "choice-field" (dict "Legend" .RoleLabel "Name" "role" "Options" .RoleChoices)}}
+ <div rst-form-foot><button rst-btn="primary" type="submit">{{.ShareButton}}</button></div>
</form>
{{/* The third rung: a read-only link anyone can follow, signed in or
- not. One <form> whichever way the switch currently points, with
- the direction in a hidden field — so a page left open in a tab
- cannot turn publishing back on from a button that still says
- "off", which two separate routes would have allowed.
-
- The URL is printed as text, not as a link. It is meant to be
- copied into a message rather than followed from here, and a
- page whose only affordance is a link invites the owner to click
- it, land on their own published copy, and wonder where the app
- went. Reading it as text also makes it obvious that this is a
- thing you hand to someone.
+ not. One <form> whichever way the switch currently points, with the
+ direction in a hidden field — so a page left open in a tab cannot
+ turn publishing back on from a button that still says "off".
- No copy button: that needs JavaScript, and this screen has a
- zero-JS floor (spec §6.2). Select-and-copy works everywhere,
- today. */}}
+ The link appears only in the render that follows the publish that
+ minted it — the database holds a SHA-256 and cannot produce a URL
+ (suite ruling 16). It is a read-only field rather than a link: it
+ is meant to be copied into a message, not followed from here. The
+ Copy button is fixtures/share-link.mjs's, hidden until that script
+ runs; without it the field selects on focus for a manual copy. */}}
<section docs-share-public>
<h2>{{.PublicHeading}}</h2>
<p rst-muted>{{.PublicBody}}</p>
-
{{if .Published}}
- {{/* The URL appears here only in the render that follows the publish
- that minted it — the database holds a SHA-256 and cannot produce
- a URL (suite ruling 16). Afterwards this section says the
- document is published and offers to replace or stop it. */}}
{{if .PublicURL}}
<div docs-share-once role="status">{{template "callout" (dict "Body" .OnceWarning "Tone" "warning")}}</div>
- <p docs-share-url><code>{{.PublicURL}}</code></p>
+ <div docs-share-url>
+ <input rst-input docs-share-url-input type="text" readonly value="{{.PublicURL}}" aria-label="{{.URLLabel}}">
+ <button rst-btn="primary" type="button" docs-share-copy data-copied="{{.CopiedLabel}}" hidden>{{.CopyButton}}</button>
+ </div>
+ <p docs-share-copy-status role="status"></p>
{{else}}
<p rst-muted docs-share-state>{{.PublishedBody}}</p>
{{end}}
<p rst-muted>{{.CopyHint}}</p>
- <form method="post" action="/d/{{.Sqid}}/share/link">
- <input type="hidden" name="on" value="0">
- <button type="submit">{{.UnpublishBtn}}</button>
- </form>
- <form method="post" action="/d/{{.Sqid}}/share/link">
- <input type="hidden" name="on" value="1">
- <button type="submit">{{.ReplaceButton}}</button>
- </form>
+ <div docs-share-actions>
+ <form method="post" action="/d/{{.Sqid}}/share/link">
+ <input type="hidden" name="on" value="1">
+ <button rst-btn type="submit">{{.ReplaceButton}}</button>
+ </form>
+ <form method="post" action="/d/{{.Sqid}}/share/link">
+ <input type="hidden" name="on" value="0">
+ <button rst-btn="ghost" type="submit">{{.UnpublishBtn}}</button>
+ </form>
+ </div>
{{else}}
- <form method="post" action="/d/{{.Sqid}}/share/link">
- <input type="hidden" name="on" value="1">
- <button type="submit">{{.PublishButton}}</button>
- </form>
+ <div docs-share-actions>
+ <form method="post" action="/d/{{.Sqid}}/share/link">
+ <input type="hidden" name="on" value="1">
+ <button rst-btn="primary" type="submit">{{.PublishButton}}</button>
+ </form>
+ </div>
{{end}}
</section>
</article>
+<script type="module" defer src="{{.ShareScript}}"></script>
{{end}}
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index e0ec203..f152217 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -1013,6 +1013,56 @@
font: inherit;
font-size: var(--rst-fs-sm);
}
+ /* A phone: one row that scrolls sideways, not three rows of wrapped
+ controls with separators stranded at the starts of lines (the audit's
+ "crowded at 390px"). The toolbar is its own horizontal scroller, so
+ the page never overflows, and nothing wraps, so the first paint is
+ already the final one (CLS stays 0).
+
+ The catch is the three comboboxes: their lists are absolutely
+ positioned under the input, and inside a scroller they would be
+ clipped. Anchor positioning takes them out: each list is fixed to the
+ viewport and anchored to its own combobox. Where a browser has no
+ anchor positioning (Firefox, today) the whole rule is skipped and the
+ bar wraps as before, which is crowded but every list still opens. */
+ @media (max-width: 39.99rem) {
+ @supports (anchor-name: --docs-probe) {
+ [docs-toolbar] {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ scrollbar-width: thin;
+ padding-block-end: 2px;
+ }
+ [docs-toolbar-group] {
+ flex: none;
+ flex-wrap: nowrap;
+ }
+ [docs-toolbar] [rst-combo],
+ [docs-tool-select] {
+ flex: none;
+ inline-size: 7.5rem;
+ }
+ [docs-toolbar] [rst-combo]:has(#docs-select-size-combo),
+ #docs-select-size {
+ inline-size: 4.5rem;
+ }
+ [docs-toolbar] [rst-combo]:has(#docs-select-block-combo) { anchor-name: --docs-select-block; }
+ [docs-toolbar] [rst-combo]:has(#docs-select-font-combo) { anchor-name: --docs-select-font; }
+ [docs-toolbar] [rst-combo]:has(#docs-select-size-combo) { anchor-name: --docs-select-size; }
+ [docs-toolbar] [rst-combo]:has(#docs-select-block-combo) [rst-combo-list] { position-anchor: --docs-select-block; }
+ [docs-toolbar] [rst-combo]:has(#docs-select-font-combo) [rst-combo-list] { position-anchor: --docs-select-font; }
+ [docs-toolbar] [rst-combo]:has(#docs-select-size-combo) [rst-combo-list] { position-anchor: --docs-select-size; }
+ [docs-toolbar] [rst-combo-list] {
+ position: fixed;
+ inset: auto;
+ top: anchor(bottom);
+ left: anchor(left);
+ min-inline-size: anchor-size(width);
+ inline-size: max-content;
+ }
+ }
+ }
/* Disabled is mirrored onto the input by fixtures/toolbar.mjs —
select.js does not do it — and has to LOOK disabled too, or the
control reads as usable while refusing to be used. Same token the