Public: anyone can read this; the crew can change it.
Design: the quiet disclosure becomes a §5 component used by its four pages, and quiet-details leaves §5d #3933
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3925-design-the-quiet-disclosure-becomes-a-5 → main 6 commits fork
18 files
changed +129
−57
docs/DESIGN.md +3 −2
| @@ -165,6 +165,7 @@ specification. | ||
| 165 | 165 | | **tabs** `tabs()` | `.tabs > a` | — | `aria-current="page"` + 2px `--fg` underline | a header row with the active item underlined | |
| 166 | 166 | | **segmented control** `seg()` | `.seg > a` | — | `aria-current`, or `:has(:checked)` for radios | `‹ Open | Closed | All ›` | |
| 167 | 167 | | **menu** `menu()` | `.menu` (`<details>`) | `data-align=start\|end` | open/closed | a popup list | |
| 168 | | **disclosure** `disclosure()` | `.disclosure` (`<details>`, a quiet `›` summary) | `small` summary, `label` (aria-label), `extra` classes | open/closed; the chevron turns, the summary is at least 44px tall | a folded section | | |
| 168 | 169 | | **dialog** | `.dialog` | `data-side=bottom` (sheet) | JS `showModal()`; every dialog has a real page behind it | a modal box | |
| 169 | 170 | | **tooltip** | `[data-tip]` | `data-tip-side=bottom` | hover/focus; hidden on coarse pointers | a status-bar hint | |
| 170 | 171 | | **toast / flash** `Flash` | `.toast` | `data-tone=success\|danger\|attn` | one per page load, cookie-driven | a one-line banner | |
| @@ -364,7 +365,7 @@ The merge box gains a seventh state, _Waiting on checks_, when | ||
| 364 | 365 | object that can drift. |
| 365 | 366 | |
| 366 | 367 | **Website spec findings** — under a pull's Checks line, from the preview's |
| 367 | latest deploy run (`core/preview_findings.ts`): a `.quiet-details` whose | |
| 368 | latest deploy run (`core/preview_findings.ts`): a **disclosure** whose | |
| 368 | 369 | summary is the count (edge-owned findings counted apart) and whose body is |
| 369 | 370 | a `.list` of `.row`s, one per finding, with its specification.website |
| 370 | 371 | section as an https link. Report-only, so the words never say failed; the |
| @@ -392,7 +393,7 @@ gone, so this list only shrinks. | ||
| 392 | 393 | - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on: `.bar` `.bare` `.foot` `.grow` `.hide-lg` `.hide-sm` `.js-only` `.live-pill` `.mark` `.mb` `.mt` `.muted` `.narrow` `.narrow-wide` `.num` `.reduced` `.shell` `.shell-wide` `.skip` `.small` `.spacer` `.sr-only` `.stack` `.stack-tight` `.topbar` `.topbar-tools` `.truncate` `.wordmark` |
| 393 | 394 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.live-status` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-lead` `.passkey-note` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search` |
| 394 | 395 | - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list` |
| 395 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, following, lineage: `.about` `.act` `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.connect-more` `.connect-path` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.following` `.following-actions` `.following-eyebrow` `.following-foot` `.following-head` `.following-sentence` `.fork-bar` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.handoff` `.handoff-links` `.headroom` `.hero` `.hero-terminal` `.issue-head` `.issue-meta` `.kv` `.lineage` `.lineage-children` `.lineage-here` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.quiet-details` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep` | |
| 396 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, following, lineage: `.about` `.act` `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.connect-more` `.connect-path` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.following` `.following-actions` `.following-eyebrow` `.following-foot` `.following-head` `.following-sentence` `.fork-bar` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.handoff` `.handoff-links` `.headroom` `.hero` `.hero-terminal` `.issue-head` `.issue-meta` `.kv` `.lineage` `.lineage-children` `.lineage-here` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep` | |
| 396 | 397 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
| 397 | 398 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 398 | 399 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
src/web/pages/board-notes.ts +10 −7
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { html, type Html } from "../html.ts"; |
| 12 | import { dot } from "../ui.ts"; | |
| 12 | import { disclosure, dot } from "../ui.ts"; | |
| 13 | 13 | |
| 14 | 14 | /** One line on the board. */ |
| 15 | 15 | export interface Note { |
| @@ -60,10 +60,13 @@ export function notesRegion( | ||
| 60 | 60 | return wrap( |
| 61 | 61 | o.fine ? html`<span class="small muted">${dot("success")} All fine</span>` : null, |
| 62 | 62 | ); |
| 63 | return wrap(html`<details id="notes-list" class="quiet-details"> | |
| 64 | <summary class="small">${summaryLine(notes)}</summary> | |
| 65 | <ul class="small muted"> | |
| 66 | ${notes.map((n) => html`<li>${n.line}</li>`)} | |
| 67 | </ul> | |
| 68 | </details>`); | |
| 63 | return wrap( | |
| 64 | disclosure( | |
| 65 | summaryLine(notes), | |
| 66 | html`<ul class="small muted"> | |
| 67 | ${notes.map((n) => html`<li>${n.line}</li>`)} | |
| 68 | </ul>`, | |
| 69 | { small: true, id: "notes-list" }, | |
| 70 | ), | |
| 71 | ); | |
| 69 | 72 | } |
src/web/pages/looks.ts +7 −6
| @@ -33,7 +33,7 @@ import { notFound } from "../../http/respond.ts"; | ||
| 33 | 33 | import { keepLook } from "../../looks/service.ts"; |
| 34 | 34 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 35 | 35 | import { html, when, type Html } from "../html.ts"; |
| 36 | import { card, icon, sha, time } from "../ui.ts"; | |
| 36 | import { card, disclosure, icon, sha, time } from "../ui.ts"; | |
| 37 | 37 | |
| 38 | 38 | /** The newest picture at one width, when there is one. */ |
| 39 | 39 | export const pictured = ( |
| @@ -262,13 +262,14 @@ export function lookCard( | ||
| 262 | 262 | </div> |
| 263 | 263 | </div> |
| 264 | 264 | ${when(darkPhone ?? darkDesktop, () => |
| 265 | html`<details class="quiet-details"> | |
| 266 | <summary class="muted small">In the dark</summary> | |
| 267 | <div class="looks"> | |
| 265 | disclosure( | |
| 266 | "In the dark", | |
| 267 | html`<div class="looks"> | |
| 268 | 268 | ${when(darkPhone, (l) => picture(l, "phone", page))} |
| 269 | 269 | ${when(darkDesktop, (l) => picture(l, "desktop", page))} |
| 270 | </div> | |
| 271 | </details>`, | |
| 270 | </div>`, | |
| 271 | { small: true }, | |
| 272 | ), | |
| 272 | 273 | )} |
| 273 | 274 | <p class="muted small"> |
| 274 | 275 | at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · |
src/web/pages/pull-spec.ts +6 −5
| @@ -7,15 +7,16 @@ | ||
| 7 | 7 | import { html, type Html } from "../html.ts"; |
| 8 | 8 | import type { Finding } from "../../release/webspec.ts"; |
| 9 | 9 | import { findingsWords } from "../../core/preview_findings.ts"; |
| 10 | import { disclosure } from "../ui.ts"; | |
| 10 | 11 | |
| 11 | 12 | const sectionName = (url: string): string => url.slice(url.indexOf("#") + 1) || url; |
| 12 | 13 | |
| 13 | 14 | export function specFindings(found: readonly Finding[]): Html { |
| 14 | 15 | if (found.length === 0) return html`<p class="muted">Website spec: no findings on the preview.</p>`; |
| 15 | 16 | const words = findingsWords(found); |
| 16 | return html`<details class="quiet-details"> | |
| 17 | <summary>${words[0]!.toUpperCase() + words.slice(1)} (report only)</summary> | |
| 18 | <ul class="list"> | |
| 17 | return disclosure( | |
| 18 | `${words[0]!.toUpperCase() + words.slice(1)} (report only)`, | |
| 19 | html`<ul class="list"> | |
| 19 | 20 | ${found.map( |
| 20 | 21 | (f) => |
| 21 | 22 | html`<li class="row"><span class="mono">${f.path}</span> ${f.message} — |
| @@ -23,6 +24,6 @@ export function specFindings(found: readonly Finding[]): Html { | ||
| 23 | 24 | ? html` <span class="muted">(the edge owns this)</span>` |
| 24 | 25 | : ""}</li>`, |
| 25 | 26 | )} |
| 26 | </ul> | |
| 27 | </details>`; | |
| 27 | </ul>`, | |
| 28 | ); | |
| 28 | 29 | } |
src/web/studio/feed.ts +7 −6
| @@ -20,7 +20,7 @@ import type { FeedItem } from "../../feed/rank.ts"; | ||
| 20 | 20 | import { html, type Html } from "../html.ts"; |
| 21 | 21 | import { fragment } from "../layout.ts"; |
| 22 | 22 | import { outboxJs } from "../outbox.ts"; |
| 23 | import { avatar, btn, card, empty, nameMark, timeAgo } from "../ui.ts"; | |
| 23 | import { avatar, btn, card, disclosure, empty, nameMark, timeAgo } from "../ui.ts"; | |
| 24 | 24 | import { writableAppsOf } from "./asks.ts"; |
| 25 | 25 | |
| 26 | 26 | /** Events that can change what the feed says. */ |
| @@ -74,17 +74,18 @@ export function feedCard( | ||
| 74 | 74 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 75 | 75 | const body = html`<p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p> |
| 76 | 76 | ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null}`; |
| 77 | const foot = html`<details class="quiet-details"> | |
| 78 | <summary class="small muted" aria-label="${target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute"}">${target ? "Steer" : "Tell"} · more</summary> | |
| 79 | <form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form"> | |
| 77 | const foot = disclosure( | |
| 78 | `${target ? "Steer" : "Tell"} · more`, | |
| 79 | html`<form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form"> | |
| 80 | 80 | <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea> |
| 81 | 81 | ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })} |
| 82 | 82 | </form> |
| 83 | 83 | <span class="bar"> |
| 84 | 84 | <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (x)">Dismiss</button> |
| 85 | 85 | ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="muted" title="Mute ${item.app}">Mute app</button>`} |
| 86 | </span> | |
| 87 | </details>`; | |
| 86 | </span>`, | |
| 87 | { small: true, label: target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute" }, | |
| 88 | ); | |
| 88 | 89 | return card(body, { |
| 89 | 90 | head, |
| 90 | 91 | foot, |
src/web/studio/feed_style.ts +1 −1
| @@ -44,7 +44,7 @@ export const feedCss = /* css */ ` | ||
| 44 | 44 | .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; } |
| 45 | 45 | @media (max-width: 600px) { |
| 46 | 46 | .feed .bar .btn { padding: 8px 10px; } |
| 47 | .feed .quiet-details > summary { min-height: 44px; display: flex; align-items: center; } | |
| 47 | .feed .disclosure > summary { min-height: 44px; display: flex; align-items: center; } | |
| 48 | 48 | } |
| 49 | 49 | @media (forced-colors: active) { |
| 50 | 50 | .feed > .card:focus-visible, #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid Highlight; } |
src/web/studio/tell.ts +9 −7
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | badge, |
| 38 | 38 | btn, |
| 39 | 39 | card, |
| 40 | disclosure, | |
| 40 | 41 | icon, |
| 41 | 42 | labelCss, |
| 42 | 43 | listCard, |
| @@ -396,13 +397,14 @@ async function studioTell( | ||
| 396 | 397 | // and both are one tap away when it is. What it waits for stays in view |
| 397 | 398 | // above them — one of the three questions the page answers — with its |
| 398 | 399 | // form for whoever may change it. |
| 399 | const details = html`<details class="quiet-details mt"> | |
| 400 | <summary>Details</summary> | |
| 401 | ${assigneeCard(ctx, repo, issue, `${thread}/assignee`)} | |
| 402 | ${labelsCard(ctx, repo, issue, applied, allLabels, `${thread}/labels`)} | |
| 403 | ${teams} | |
| 404 | ${sealCard(ctx, repo, issue, `${thread}/seal`)} | |
| 405 | </details>`; | |
| 400 | const details = disclosure( | |
| 401 | "Details", | |
| 402 | html`${assigneeCard(ctx, repo, issue, `${thread}/assignee`)} | |
| 403 | ${labelsCard(ctx, repo, issue, applied, allLabels, `${thread}/labels`)} | |
| 404 | ${teams} | |
| 405 | ${sealCard(ctx, repo, issue, `${thread}/seal`)}`, | |
| 406 | { extra: "mt" }, | |
| 407 | ); | |
| 406 | 408 | |
| 407 | 409 | const body = html`${repoHeader(ctx, repo, "issues", { |
| 408 | 410 | compact: true, |
src/web/style/components.ts +13
| @@ -581,6 +581,19 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 581 | 581 | position-area would keep position: fixed and park the panel at the |
| 582 | 582 | viewport's corner. Absolute-inside-the-details works everywhere. */ |
| 583 | 583 | |
| 584 | /* ---- disclosure: what a person sometimes needs, closed until asked for ---- */ | |
| 585 | .disclosure > summary { | |
| 586 | cursor: pointer; font-weight: 560; list-style: none; color: var(--muted-fg); | |
| 587 | display: flex; align-items: center; gap: 6px; padding: 6px 0; min-height: 44px; | |
| 588 | } | |
| 589 | .disclosure > summary::-webkit-details-marker { display: none; } | |
| 590 | .disclosure > summary::before { | |
| 591 | content: "›"; display: inline-block; color: var(--faint); | |
| 592 | transition: transform 120ms var(--ease); | |
| 593 | } | |
| 594 | .disclosure[open] > summary::before { transform: rotate(90deg); } | |
| 595 | .disclosure > summary:hover { color: var(--accent); } | |
| 596 | ||
| 584 | 597 | /* ---- topbar search (always a real form, so JS-off still searches) ---- */ |
| 585 | 598 | .topbar-search { |
| 586 | 599 | display: flex; align-items: center; gap: 6px; |
src/web/style/content.ts −13
| @@ -169,19 +169,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 169 | 169 | .connect-more[open] > summary::before { transform: rotate(90deg); } |
| 170 | 170 | .connect-more > summary:hover { color: var(--accent); } |
| 171 | 171 | .connect-more > p { margin: 6px 0 4px; } |
| 172 | /* A page's quiet Details: what a person sometimes changes, closed until | |
| 173 | asked for, the same disclosure as the one above. */ | |
| 174 | .quiet-details > summary { | |
| 175 | cursor: pointer; font-weight: 560; list-style: none; color: var(--muted-fg); | |
| 176 | display: flex; align-items: center; gap: 6px; padding: 6px 0; min-height: 44px; | |
| 177 | } | |
| 178 | .quiet-details > summary::-webkit-details-marker { display: none; } | |
| 179 | .quiet-details > summary::before { | |
| 180 | content: "›"; display: inline-block; color: var(--faint); | |
| 181 | transition: transform 120ms var(--ease); | |
| 182 | } | |
| 183 | .quiet-details[open] > summary::before { transform: rotate(90deg); } | |
| 184 | .quiet-details > summary:hover { color: var(--accent); } | |
| 185 | 172 | /* Why an issue closes, beside the button that closes it. */ |
| 186 | 173 | .form-actions select[name="reason"] { width: auto; } |
| 187 | 174 | .form-actions input[name="into"] { display: none; width: 7em; } |
src/web/ui.ts +19
| @@ -841,6 +841,25 @@ export function listCard(rows: Html[], o: CardOptions = {}): Html { | ||
| 841 | 841 | ); |
| 842 | 842 | } |
| 843 | 843 | |
| 844 | export interface DisclosureOptions { | |
| 845 | /** A small muted summary, for a disclosure tucked under a card or a strip. */ | |
| 846 | small?: boolean; | |
| 847 | /** The summary's accessible name, when its visible words are shorter. */ | |
| 848 | label?: string; | |
| 849 | /** Further classes for the `<details>` (a spacing utility such as `mt`). */ | |
| 850 | extra?: string; | |
| 851 | /** The `<details>`'s id, for a script or a link that finds it. */ | |
| 852 | id?: string; | |
| 853 | } | |
| 854 | ||
| 855 | /** What a person sometimes needs, closed until asked for: a quiet chevron summary over its body. */ | |
| 856 | export function disclosure(summary: Html | string, body: Html | string, o: DisclosureOptions = {}): Html { | |
| 857 | return html`<details${o.id ? html` id="${o.id}"` : null} class="disclosure${o.extra ? ` ${o.extra}` : ""}"> | |
| 858 | <summary${o.small ? html` class="muted small"` : null}${o.label ? html` aria-label="${o.label}"` : null}>${summary}</summary> | |
| 859 | ${body} | |
| 860 | </details>`; | |
| 861 | } | |
| 862 | ||
| 844 | 863 | export interface RowOptions { |
| 845 | 864 | href?: string; |
| 846 | 865 | title: Html | string; |
test/board-notes.test.ts +1 −1
| @@ -21,7 +21,7 @@ describe("the board's notes", () => { | ||
| 21 | 21 | |
| 22 | 22 | test("closed by default, one line each, escaped", () => { |
| 23 | 23 | const out = notesRegion([{ key: "k", need: true, line: html`<a>${"<b>"}</a>` }], live).text; |
| 24 | expect(out).toContain('<details id="notes-list" class="quiet-details">'); | |
| 24 | expect(out).toContain('<details id="notes-list" class="disclosure">'); | |
| 25 | 25 | expect(out).not.toMatch(/<details[^>]*open/); |
| 26 | 26 | expect(out).toContain("<b>"); |
| 27 | 27 | }); |
test/board.test.ts +1 −1
| @@ -578,7 +578,7 @@ describe("the board's forge strip", () => { | ||
| 578 | 578 | const text = await board("root"); |
| 579 | 579 | const top = text.slice(text.indexOf('<div class="board"'), text.indexOf('class="board-grid"')); |
| 580 | 580 | // Closed by default; one summary, counted. |
| 581 | expect(top).toMatch(/<details id="notes-list" class="quiet-details">/); | |
| 581 | expect(top).toMatch(/<details id="notes-list" class="disclosure">/); | |
| 582 | 582 | expect(top).not.toMatch(/<details[^>]*\sopen/); |
| 583 | 583 | expect(top).toMatch(/<summary[^>]*>\s*\d+ notes · <span[^>]*>\s*<span\s+class="dot"\s+data-tone="attn"\s*><\/span> \d+ need you<\/span>\s*<\/summary>/); |
| 584 | 584 | expect(top.match(/<li>/g)?.length).toBeGreaterThanOrEqual(8); |
test/disclosure.test.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * The disclosure (#3925): one §5 component for "closed until asked for", | |
| 3 | * drawn by `disclosure()` in ui.ts. Its markup, its escaping, and that the | |
| 4 | * page-local name it replaced is gone from every file under src/web. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { readdirSync, readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { html } from "../src/web/html.ts"; | |
| 11 | import { disclosure } from "../src/web/ui.ts"; | |
| 12 | ||
| 13 | describe("disclosure", () => { | |
| 14 | test("draws a <details> whose summary and body are what it was given", () => { | |
| 15 | const out = disclosure("Details", html`<p>body</p>`).toString(); | |
| 16 | expect(out).toMatch(/^<details class="disclosure">\s*<summary>Details<\/summary>\s*<p>body<\/p>\s*<\/details>$/); | |
| 17 | }); | |
| 18 | ||
| 19 | test("a small summary, an accessible name and further classes", () => { | |
| 20 | const out = disclosure("Tell · more", "", { small: true, label: "Tell the crew, dismiss or mute", extra: "mt" }).toString(); | |
| 21 | expect(out).toContain('<details class="disclosure mt">'); | |
| 22 | expect(out).toContain('<summary class="muted small" aria-label="Tell the crew, dismiss or mute">Tell · more</summary>'); | |
| 23 | expect(disclosure("Notes", "", { id: "notes-list" }).toString()).toContain('<details id="notes-list" class="disclosure">'); | |
| 24 | }); | |
| 25 | ||
| 26 | test("plain strings are escaped, Html is not", () => { | |
| 27 | const out = disclosure('<b>"x"</b>', "<script>", { label: '"><img>' }).toString(); | |
| 28 | expect(out).not.toContain("<b>"); | |
| 29 | expect(out).not.toContain("<script>"); | |
| 30 | expect(out).not.toContain('"><img>'); | |
| 31 | expect(out).toContain("<b>"); | |
| 32 | expect(disclosure(html`<b>x</b>`, "").toString()).toContain("<summary><b>x</b></summary>"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("quiet-details, the page-local class it replaced, appears nowhere under src/web", () => { | |
| 36 | const root = join(import.meta.dir, "..", "src", "web"); | |
| 37 | const files = readdirSync(root, { recursive: true, withFileTypes: true }) | |
| 38 | .filter((e) => e.isFile()) | |
| 39 | .map((e) => join(e.parentPath, e.name)); | |
| 40 | expect(files.length).toBeGreaterThan(10); | |
| 41 | const holders = files.filter((f) => readFileSync(f, "utf8").includes("quiet-details")); | |
| 42 | expect(holders).toEqual([]); | |
| 43 | }); | |
| 44 | }); |
test/home-feed.test.ts +1 −1
| @@ -140,7 +140,7 @@ describe("home is the feed", () => { | ||
| 140 | 140 | expect(text).toContain("on “Water the tomatoes at dawn”: Mulch them first"); |
| 141 | 141 | // Every post's actions sit behind one quiet control, and "because you |
| 142 | 142 | // own" — true of every post here — is not said again and again. |
| 143 | expect(text).toContain('class="quiet-details"'); | |
| 143 | expect(text).toContain('class="disclosure"'); | |
| 144 | 144 | expect(text).not.toContain("because you own alice/garden"); |
| 145 | 145 | // Your apps: a narrow column, and a tab on a phone. |
| 146 | 146 | expect(text).toContain('id="live-apps"'); |
test/looks.test.ts +1 −1
| @@ -858,7 +858,7 @@ describe("the look", () => { | ||
| 858 | 858 | ); |
| 859 | 859 | // The dark pair is named, and folded away until asked for. |
| 860 | 860 | expect(darkPage).toMatch( |
| 861 | /<details class="quiet-details">\s*<summary class="muted small">In the dark<\/summary>/, | |
| 861 | /<details class="disclosure">\s*<summary class="muted small">In the dark<\/summary>/, | |
| 862 | 862 | ); |
| 863 | 863 | // An older head's dark picture never sits under the shown commit's caption. |
| 864 | 864 | const stale = "b".repeat(40); |
test/review-waits.test.ts +1 −1
| @@ -197,7 +197,7 @@ describe("an agent pull that waits for a person's review says so", () => { | ||
| 197 | 197 | |
| 198 | 198 | test("the board and the autonomy settings say so", async () => { |
| 199 | 199 | const board = await page(aliceCookie, "/board"); |
| 200 | expect(board).toContain('<details id="notes-list" class="quiet-details">'); | |
| 200 | expect(board).toContain('<details id="notes-list" class="disclosure">'); | |
| 201 | 201 | expect(board).toContain("No reviewer: agent pulls wait for you"); |
| 202 | 202 | expect(board).toContain("<b>1</b> waiting since"); |
| 203 | 203 | const settings = await page(aliceCookie, "/alice/engine/settings"); |
test/studio-changes.test.ts +1 −1
| @@ -130,7 +130,7 @@ describe("the studio, continued", () => { | ||
| 130 | 130 | const at = `/alice/publictell/issues/${told.value.number}`; |
| 131 | 131 | const anon = await (await fetch(`${base}${at}`)).text(); |
| 132 | 132 | expect(anon).toContain("Anyone may read"); |
| 133 | expect(anon).toContain('<details class="quiet-details mt">'); | |
| 133 | expect(anon).toContain('<details class="disclosure mt">'); | |
| 134 | 134 | for (const verb of ["assignee", "labels", "dependencies", "state", "comments"]) |
| 135 | 135 | expect(anon, verb).not.toContain(`action="${at}/${verb}"`); |
| 136 | 136 | }); |
test/studio.test.ts +4 −4
| @@ -1092,10 +1092,10 @@ describe("the studio", () => { | ||
| 1092 | 1092 | expect(studio).not.toContain("?mode=dev"); |
| 1093 | 1093 | // Dev's controls, folded in: closed until asked for, never a mode. |
| 1094 | 1094 | const details = studio.slice( |
| 1095 | studio.indexOf('<details class="quiet-details'), | |
| 1096 | studio.indexOf("</details>", studio.indexOf('<details class="quiet-details')), | |
| 1095 | studio.indexOf('<details class="disclosure'), | |
| 1096 | studio.indexOf("</details>", studio.indexOf('<details class="disclosure')), | |
| 1097 | 1097 | ); |
| 1098 | expect(details).toMatch(/^<details class="quiet-details mt">/); | |
| 1098 | expect(details).toMatch(/^<details class="disclosure mt">/); | |
| 1099 | 1099 | expect(details).toContain("<h2>Labels</h2>"); |
| 1100 | 1100 | expect(details).toContain("<h2>Assignee</h2>"); |
| 1101 | 1101 | // What it waits for stays in view, with the form beside it. |
| @@ -1112,7 +1112,7 @@ describe("the studio", () => { | ||
| 1112 | 1112 | ]) { |
| 1113 | 1113 | const text = await dev.text(); |
| 1114 | 1114 | expect(text).toContain('data-word="working"'); |
| 1115 | expect(text).toContain('<details class="quiet-details mt">'); | |
| 1115 | expect(text).toContain('<details class="disclosure mt">'); | |
| 1116 | 1116 | } |
| 1117 | 1117 | }); |
| 1118 | 1118 |
new above
offline