Public: anyone can read this; the crew can change it.
Design: a home feed card is the design system's card, and the fcard-head/acts/more classes leave §5d #3180
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3166-design-a-home-feed-card-is-the-design-sy → main 2 commits fork 2 threads · 2 unresolved
10 files
changed +89
−61
docs/DESIGN.md +1 −1
| @@ -395,7 +395,7 @@ gone, so this list only shrinks. | ||
| 395 | 395 | - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why` |
| 396 | 396 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 397 | 397 | - **`pages/milestones.ts`** — milestones and one limb opened: `.ms-actions` `.ms-bar` `.ms-count` `.ms-figures` `.ms-head` `.ms-limb` `.ms-limbs` `.ms-more` `.ms-move` `.ms-name` `.ms-planner` `.ms-rows` `.ms-section` `.ms-state` `.ms-top` `.ms-trim` `.ms-weight` |
| 398 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 398 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 399 | 399 | - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word` |
| 400 | 400 | |
| 401 | 401 | --- |
src/web/studio/feed.ts +24 −31
| @@ -19,7 +19,7 @@ import type { FeedItem } from "../../feed/rank.ts"; | ||
| 19 | 19 | import { html, type Html } from "../html.ts"; |
| 20 | 20 | import { fragment } from "../layout.ts"; |
| 21 | 21 | import { outboxJs } from "../outbox.ts"; |
| 22 | import { avatar, btn, empty, nameMark, timeAgo } from "../ui.ts"; | |
| 22 | import { avatar, btn, card, empty, nameMark, timeAgo } from "../ui.ts"; | |
| 23 | 23 | import { writableAppsOf } from "./asks.ts"; |
| 24 | 24 | |
| 25 | 25 | /** Events that can change what the feed says. */ |
| @@ -65,36 +65,30 @@ export function feedCard( | ||
| 65 | 65 | ): Html { |
| 66 | 66 | const steer = item.kind === "run" || item.kind === "cost"; |
| 67 | 67 | const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null; |
| 68 | return html`<article | |
| 69 | class="fcard" | |
| 70 | data-nav | |
| 71 | tabindex="0" | |
| 72 | data-item="${item.id}" | |
| 73 | data-app="${item.app}" | |
| 74 | data-kind="${item.kind}" | |
| 75 | > | |
| 76 | <div class="fcard-head"> | |
| 77 | ${avatar(item.actor ?? item.app)} | |
| 68 | const head = html`${avatar(item.actor ?? item.app)} | |
| 78 | 69 | <strong class="fcard-who">${item.actor ?? item.app}</strong |
| 79 | 70 | >${nameMark(item.actor === null ? undefined : marks.get(item.actor))} |
| 80 | 71 | <span class="fcard-app muted small">${item.app} · ${timeAgo(item.at)}</span> |
| 81 | 72 | <span class="spacer"></span> |
| 82 | <span class="badge">${KIND_LABEL[item.kind]}</span> | |
| 83 | </div> | |
| 84 | <p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p> | |
| 85 | ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null} | |
| 86 | <details class="fcard-more"> | |
| 73 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; | |
| 74 | const body = html`<p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p> | |
| 75 | ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null}`; | |
| 76 | const foot = html`<details class="quiet-details"> | |
| 87 | 77 | <summary class="small muted" aria-label="${target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute"}">${target ? "Steer" : "Tell"} · more</summary> |
| 88 | 78 | <form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form"> |
| 89 | 79 | <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea> |
| 90 | 80 | ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })} |
| 91 | 81 | </form> |
| 92 | <span class="fcard-acts"> | |
| 82 | <span class="bar"> | |
| 93 | 83 | <button type="button" class="btn btn-sm btn-ghost" data-feed="dismissed" title="Dismiss (x)">Dismiss</button> |
| 94 | 84 | ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button>`} |
| 95 | 85 | </span> |
| 96 | </details> | |
| 97 | </article>`; | |
| 86 | </details>`; | |
| 87 | return card(body, { | |
| 88 | head, | |
| 89 | foot, | |
| 90 | attrs: html`data-nav tabindex="0" data-item="${item.id}" data-app="${item.app}" data-kind="${item.kind}"`, | |
| 91 | }); | |
| 98 | 92 | } |
| 99 | 93 | |
| 100 | 94 | /** The composer's app value for "the forge": no app, so no slash to collide with one. */ |
| @@ -111,17 +105,16 @@ const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000; | ||
| 111 | 105 | * once; line breaks kept by CSS), marked "received". It has no controls. |
| 112 | 106 | */ |
| 113 | 107 | export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html { |
| 114 | return html`<article class="fcard fcard-thought" data-kind="thought" data-thought="${thought.id}"> | |
| 115 | <div class="fcard-head"> | |
| 116 | ${avatar(who)} | |
| 108 | const head = html`${avatar(who)} | |
| 117 | 109 | <strong class="fcard-who">${who}</strong> |
| 118 | 110 | <span class="fcard-app muted small">${timeAgo(thought.receivedAt)}</span> |
| 119 | 111 | <span class="spacer"></span> |
| 120 | <span class="badge">Received</span> | |
| 121 | </div> | |
| 122 | <p class="fcard-reason fcard-text">${thought.text}</p> | |
| 123 | ${placement ? replyUnder(placement) : ""} | |
| 124 | </article>`; | |
| 112 | <span class="badge">Received</span>`; | |
| 113 | return card( | |
| 114 | html`<p class="fcard-reason fcard-text">${thought.text}</p> | |
| 115 | ${placement ? replyUnder(placement) : ""}`, | |
| 116 | { head, attrs: html`data-kind="thought" data-thought="${thought.id}"` }, | |
| 117 | ); | |
| 125 | 118 | } |
| 126 | 119 | |
| 127 | 120 | /** The planner's answer under a thought: one or two lines, `#N` linking to the issue it names. */ |
| @@ -322,7 +315,7 @@ ${outboxJs} | ||
| 322 | 315 | return queue('/api/v1/feed/interactions', { item: card.getAttribute('data-item'), kind: kind, app: card.getAttribute('data-app') }); |
| 323 | 316 | } |
| 324 | 317 | function gone(card) { |
| 325 | var cards = Array.prototype.slice.call(feed.querySelectorAll('.fcard')); | |
| 318 | var cards = Array.prototype.slice.call(feed.querySelectorAll('[data-item]')); | |
| 326 | 319 | var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1]; |
| 327 | 320 | card.hidden = true; |
| 328 | 321 | if (next) next.focus(); |
| @@ -377,7 +370,7 @@ ${outboxJs} | ||
| 377 | 370 | var c = e.target && e.target.closest ? e.target.closest('[data-chip]') : null; |
| 378 | 371 | if (c) { chip(c); return; } |
| 379 | 372 | var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null; |
| 380 | var card = t && t.closest('.fcard'); | |
| 373 | var card = t && t.closest('[data-item]'); | |
| 381 | 374 | if (!card) return; |
| 382 | 375 | if (t.hasAttribute('data-feed-open')) { send(card, 'opened'); return; } |
| 383 | 376 | act(card, t.getAttribute('data-feed')); |
| @@ -386,7 +379,7 @@ ${outboxJs} | ||
| 386 | 379 | // stays an ordinary post and waits for the network. |
| 387 | 380 | feed.addEventListener('submit', function (e) { |
| 388 | 381 | var form = e.target; |
| 389 | var card = form.closest && form.closest('.fcard'); | |
| 382 | var card = form.closest && form.closest('[data-item]'); | |
| 390 | 383 | var box = form.elements && form.elements.text; |
| 391 | 384 | if (!card || !box || !/\\/tell$/.test(form.getAttribute('action') || '')) return; |
| 392 | 385 | var text = box.value.trim(); |
| @@ -413,7 +406,7 @@ ${outboxJs} | ||
| 413 | 406 | document.addEventListener('keydown', function (e) { |
| 414 | 407 | if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return; |
| 415 | 408 | var a = document.activeElement; |
| 416 | if (!a || !a.matches || !a.matches('.fcard') || !feed.contains(a)) return; | |
| 409 | if (!a || !a.matches || !a.matches('[data-item]') || !feed.contains(a)) return; | |
| 417 | 410 | e.preventDefault(); |
| 418 | 411 | act(a, 'dismissed'); |
| 419 | 412 | }); |
src/web/studio/feed_style.ts +8 −14
| @@ -1,23 +1,18 @@ | ||
| 1 | 1 | /** The feed cards' slice of the served stylesheet (no imports: `style.ts` reads this). */ |
| 2 | 2 | |
| 3 | 3 | export const feedCss = /* css */ ` |
| 4 | /* A timeline of rows, divided, not a stack of boxes. */ | |
| 5 | .feed { display: grid; gap: 0; border-top: 1px solid var(--border); } | |
| 6 | .fcard { border-bottom: 1px solid var(--border); padding: 12px 4px; } | |
| 7 | .fcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 8 | .fcard[hidden] { display: none; } | |
| 9 | .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| 10 | .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; } | |
| 4 | /* A timeline of the design system's cards; the card's own margin spaces them. */ | |
| 5 | .feed { display: grid; gap: 0; } | |
| 6 | .feed > .card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 7 | .feed > .card[hidden] { display: none; } | |
| 8 | .feed .card-foot > details { flex: 1 1 100%; } | |
| 9 | .fcard-reason { margin: 0 0 4px; font-size: 1.05em; overflow-wrap: anywhere; } | |
| 11 | 10 | .fcard-text { white-space: pre-wrap; } |
| 12 | 11 | /* The planner's reply sits under the thought, indented, one or two lines. */ |
| 13 | 12 | .fcard-reply { margin: 4px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border); overflow-wrap: anywhere; } |
| 14 | 13 | .fcard-unblock { display: flex; align-items: center; gap: 4px 10px; flex-wrap: wrap; margin: 0; } |
| 15 | 14 | .fcard-unblock span { flex: 1 1 16ch; min-width: 0; overflow-wrap: anywhere; } |
| 16 | 15 | .fcard-who { font-weight: 600; } |
| 17 | /* One quiet control under the post: say something, dismiss, mute. */ | |
| 18 | .fcard-more summary { cursor: pointer; list-style: none; padding: 2px 0; } | |
| 19 | .fcard-more summary::-webkit-details-marker { display: none; } | |
| 20 | .fcard-acts { display: flex; gap: 4px; margin-top: 6px; } | |
| 21 | 16 | .fcard-why { margin: 0 0 4px; } |
| 22 | 17 | /* The composer: the home's one primary action, on top of the timeline. */ |
| 23 | 18 | .composer { display: grid; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--card, var(--bg)); } |
| @@ -48,8 +43,7 @@ export const feedCss = /* css */ ` | ||
| 48 | 43 | .fcard-form { display: grid; gap: 8px; margin-top: 6px; } |
| 49 | 44 | .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; } |
| 50 | 45 | @media (max-width: 600px) { |
| 51 | .fcard { padding: 12px 2px; } | |
| 52 | .fcard-acts .btn { padding: 8px 10px; } | |
| 53 | .fcard-more summary { padding: 10px 0; } | |
| 46 | .feed .bar .btn { padding: 8px 10px; } | |
| 47 | .feed .quiet-details > summary { min-height: 44px; display: flex; align-items: center; } | |
| 54 | 48 | } |
| 55 | 49 | `; |
src/web/studio/flow_card.ts +6 −9
| @@ -12,24 +12,21 @@ import { readStalled, type StalledStep } from "../../flow/stalled.ts"; | ||
| 12 | 12 | import type { User } from "../../core/users.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { html, type Html } from "../html.ts"; |
| 15 | import { btn, timeAgo } from "../ui.ts"; | |
| 15 | import { btn, card, timeAgo } from "../ui.ts"; | |
| 16 | 16 | |
| 17 | 17 | const BUTTON: Record<StalledStep["action"], string> = { run: "Run check", rerun: "Re-run" }; |
| 18 | 18 | |
| 19 | 19 | function stalledCard(s: StalledStep): Html { |
| 20 | 20 | const base = `/${s.ownerName}/${s.repoName}/pulls/${s.pull}`; |
| 21 | return html`<article class="fcard" data-flow-card="${s.repo}#${String(s.pull)}"> | |
| 22 | <div class="fcard-head"> | |
| 23 | <strong class="fcard-who">${s.stage}</strong> | |
| 24 | <span class="fcard-app muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span> | |
| 25 | </div> | |
| 26 | <p class="fcard-reason"><a href="${base}">${s.title}</a>${s.issue === null ? null : html` <span class="muted small">for #${String(s.issue)}</span>`}</p> | |
| 21 | const head = html`<strong class="fcard-who">${s.stage}</strong> | |
| 22 | <span class="fcard-app muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span>`; | |
| 23 | const body = html`<p class="fcard-reason"><a href="${base}">${s.title}</a>${s.issue === null ? null : html` <span class="muted small">for #${String(s.issue)}</span>`}</p> | |
| 27 | 24 | <form method="post" action="${base}/checks" class="fcard-unblock"> |
| 28 | 25 | <input type="hidden" name="next" value="/"> |
| 29 | 26 | ${btn(BUTTON[s.action], { size: "sm", icon: "play" })} |
| 30 | 27 | <span class="muted small">${s.unblock}</span> |
| 31 | </form> | |
| 32 | </article>`; | |
| 28 | </form>`; | |
| 29 | return card(body, { head, attrs: html`data-flow-card="${s.repo}#${String(s.pull)}"` }); | |
| 33 | 30 | } |
| 34 | 31 | |
| 35 | 32 | /** The cards, oldest stall first; nothing at all when nothing is stalled. Every one is shown: the |
src/web/studio/style.ts +3 −3
| @@ -62,14 +62,14 @@ const rowActions = part(/* css */ ` | ||
| 62 | 62 | const phone = part(/* css */ ` |
| 63 | 63 | @media (max-width: 600px) { |
| 64 | 64 | .studio-head { flex-wrap: wrap; } |
| 65 | .fcard-head { flex-wrap: wrap; row-gap: 2px; } | |
| 66 | .fcard-head .fcard-app { flex: 1 1 100%; } | |
| 65 | .feed .card-head { row-gap: 2px; } | |
| 66 | .feed .card-head .fcard-app { flex: 1 1 100%; } | |
| 67 | 67 | .studio-head h1 { flex: 1 1 100%; flex-wrap: wrap; overflow-wrap: anywhere; } |
| 68 | 68 | .studio-head .env-facts { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; } |
| 69 | 69 | .studio-head form, .studio-head form .btn { flex: 1 1 auto; } |
| 70 | 70 | .studio-tell .bar { flex-wrap: wrap; } |
| 71 | 71 | .studio-tell .bar .btn, .studio-ask-actions .btn { min-height: 44px; } |
| 72 | .fcard-acts .btn, .fcard-form .btn { min-height: 44px; } | |
| 72 | .feed .bar .btn, .fcard-form .btn { min-height: 44px; } | |
| 73 | 73 | .studio-ask-actions { display: flex; flex-direction: column; align-items: stretch; white-space: normal; width: 100%; } |
| 74 | 74 | .studio-word { white-space: normal; } |
| 75 | 75 | .shelf-meta { flex-wrap: wrap; } |
src/web/ui.ts +3
| @@ -772,12 +772,15 @@ export interface CardOptions { | ||
| 772 | 772 | pad?: boolean; |
| 773 | 773 | id?: string; |
| 774 | 774 | extra?: string; |
| 775 | /** Further attributes for the card itself (data hooks, tabindex), already escaped. */ | |
| 776 | attrs?: Html; | |
New option, untested. Add a test that attrs render on the card element and that the default output is unchanged. |
||
| 775 | 777 | } |
| 776 | 778 | |
| 777 | 779 | export function card(body: Html, o: CardOptions = {}): Html { |
| 778 | 780 | return html`<div |
| 779 | 781 | class="card${o.extra ? ` ${o.extra}` : ""}" |
| 780 | 782 | ${o.id ? html`id="${o.id}"` : null} |
| 783 | ${o.attrs ?? null} | |
| 781 | 784 | ${o.tone && o.tone !== "default" ? html`data-tone="${o.tone}"` : null} |
| 782 | 785 | > |
| 783 | 786 | ${o.head ? html`<div class="card-head">${o.head}</div>` : null} |
test/card-attrs.test.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { html } from "../src/web/html.ts"; | |
| 3 | import { card } from "../src/web/ui.ts"; | |
| 4 | import { feedCard } from "../src/web/studio/feed.ts"; | |
| 5 | ||
| 6 | const root = (s: string) => /^<div\b[^>]*>/.exec(s.trim())?.[0] ?? ""; | |
| 7 | ||
| 8 | describe("card() attrs", () => { | |
| 9 | test("attrs land on the .card element itself", () => { | |
| 10 | const out = card(html`<p>x</p>`, { attrs: html`data-item="e1" tabindex="0"` }).text; | |
| 11 | const tag = root(out); | |
| 12 | expect(tag).toContain('class="card"'); | |
| 13 | expect(tag).toContain('data-item="e1"'); | |
| 14 | expect(tag).toContain('tabindex="0"'); | |
| 15 | }); | |
| 16 | ||
| 17 | test("without attrs the output is the plain card", () => { | |
| 18 | const plain = card(html`<p>x</p>`).text; | |
| 19 | expect(card(html`<p>x</p>`, {}).text).toBe(plain); | |
| 20 | expect(root(plain).replace(/\s+/g, " ")).toBe('<div class="card" >'); | |
| 21 | expect(plain).not.toContain("data-"); | |
| 22 | expect(plain).not.toContain("tabindex"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a feed card carries its hooks on the same .card element", () => { | |
| 26 | const out = feedCard({ | |
| 27 | id: "event:e1", | |
| 28 | kind: "run", | |
| 29 | app: "a/b", | |
| 30 | actor: null, | |
| 31 | at: new Date().toISOString(), | |
| 32 | link: "/a/b/runs/r", | |
| 33 | what: "w", | |
| 34 | reason: "r", | |
| 35 | score: 1, | |
| 36 | }).text; | |
| 37 | const tag = root(out); | |
| 38 | expect(tag).toContain('class="card"'); | |
| 39 | for (const a of ['data-item="event:e1"', "data-nav", 'tabindex="0"']) expect(tag).toContain(a); | |
| 40 | }); | |
| 41 | }); |
test/feed-page.test.ts +1 −1
| @@ -86,7 +86,7 @@ describe("the feed on the home page", () => { | ||
| 86 | 86 | expect(text).toContain("alice/open"); |
| 87 | 87 | expect(text).not.toContain("<script>alert(1)</script>"); |
| 88 | 88 | expect(text).toContain("<script>alert(1)</script>"); |
| 89 | const order = [...text.matchAll(/class="fcard"[^>]*data-item="([^"]+)"/g)].map((m) => m[1]); | |
| 89 | const order = [...text.matchAll(/class="card"\s+data-nav[^>]*data-item="([^"]+)"/g)].map((m) => m[1]); | |
| 90 | 90 | expect(order).toEqual(readFeed(app.db, alice).items.map((i) => i.id)); |
| 91 | 91 | }); |
| 92 | 92 |
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="fcard-more"'); | |
| 143 | expect(text).toContain('class="quiet-details"'); | |
| 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/studio-phone.test.ts +1 −1
| @@ -6,7 +6,7 @@ describe("the studio at 360px", () => { | ||
| 6 | 6 | const phone = studioCss.slice(studioCss.indexOf("@media (max-width: 600px) {\n .studio-head")); |
| 7 | 7 | |
| 8 | 8 | test("every component the issue names has a phone rule", () => { |
| 9 | for (const sel of [".studio-head", ".studio-tell", ".fcard-head", ".studio-ask-actions", ".studio-word"]) { | |
| 9 | for (const sel of [".studio-head", ".studio-tell", ".feed .card-head", ".studio-ask-actions", ".studio-word"]) { | |
| 10 | 10 | expect(phone).toContain(sel); |
| 11 | 11 | } |
| 12 | 12 | }); |
new above
offline
The old phone rule gave the details summary a 44px target. Add one for
.feed .quiet-details > summaryso the Tell/Steer toggle stays thumb-sized at 360px.