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>
10 files changed,
+331
−67
fixtures/browser-drive.mjs+27 −8fixtures/build-bundles.mjs+3 −0fixtures/share-link.mjs+45 −0internal/docs/assets.go+4 −1internal/docs/render.go+5 −0internal/docs/share.go+25 −0internal/docs/share_page_test.go+52 −0internal/docs/static/share.b7f152cdd9e793ac.js+1 −0internal/docs/views/share.html+119 −58internal/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 |