From 20608864f3c451041d069000aa6ef04022a9723f Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 18:31:49 +0800 Subject: [PATCH] fix(social): rebase the phone block onto 15d7e525 (#1147, #1149) #1139 changed plugins/social/client.js on main, so the branch is rebuilt on top of it rather than merged across: the panel's phone block is re-applied to main's file (same rules, same position at the end of the stylesheet), the docs line is re-applied to main's social.md. No change to the rules themselves. --- .../1147-group-phone-tap-targets.changed.md | 1 + docs/subsystems/social.md | 2 + plugins/social/client.js | 2 +- plugins/social/test/group-phone.test.js | 128 ++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1147-group-phone-tap-targets.changed.md create mode 100644 plugins/social/test/group-phone.test.js diff --git a/changelog.d/1147-group-phone-tap-targets.changed.md b/changelog.d/1147-group-phone-tap-targets.changed.md new file mode 100644 index 000000000..314291a0c --- /dev/null +++ b/changelog.d/1147-group-phone-tap-targets.changed.md @@ -0,0 +1 @@ +The social panel's group and dock surfaces are usable at phone width: below 640 px its buttons and inputs (including the invite-preview and waiting-room footers) are 40 px tall, its row controls at least 32 px, a long unbroken run in a held reply wraps inside its card, the dock clears the home indicator, and the phone block now closes the stylesheet so no later rule of equal specificity can override it (#1147, #1149). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index b7c492bae..2eb56a813 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -16,6 +16,8 @@ Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar * Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers). +At phone width (`<= 640 px`, the last block in `client.js's stylesheet`) this panel's buttons and inputs are 40 px, its row controls at least 32 px, long runs wrap inside their card, and the session dock clears the home indicator through `env(safe-area-inset-bottom,0px)` — 0 on this stack until the shell gains `viewport-fit=cover` (#647), so today it reads as a plain 6 px (#1147, #1149). + ## Does not own - Plugin runtime hooks/slots → [plugin-runtime](plugin-runtime.md). diff --git a/plugins/social/client.js b/plugins/social/client.js index 690e684ad..1e228af7f 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -255,7 +255,6 @@ window.__ModuleLoader__.load({ .fs-hold .fs-hold-decide{display:flex;gap:8px;flex:none} .fs-hold .fs-actions .fs-hold-always{order:-1;margin-right:auto;white-space:nowrap;flex:0 0 auto} .fs-hold[data-state] .fs-name{font-size:13px;line-height:18px;font-weight:500;color:var(--bt-ink-2,rgba(20,20,20,.6))} -@media (max-width:640px){.fs-hold .fs-btn{height:40px;padding:0 16px;font-size:14px}.fs-hold .fs-hold-decide{flex:1 1 auto;justify-content:flex-end}} .fs-row-wrap{position:relative;display:flex;align-items:center;gap:2px;min-width:0} .fs-row-wrap .fs-row,.fs-row-wrap > :first-child{flex:1 1 auto;min-width:0} .fs-remove{flex:none;width:20px;height:20px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--bt-ink-3,rgba(20,20,20,.36));font:inherit;font-size:14px;line-height:1;cursor:pointer;opacity:0} @@ -321,6 +320,7 @@ window.__ModuleLoader__.load({ .fs-wait-foot .fs-btn-primary{flex:1 1 auto;width:auto;min-width:0} .fs-wait[data-flybot=face-nobody] .fs-name{white-space:normal;overflow:visible;text-overflow:clip} .fs-fail{padding:0} +@media (max-width:640px){.fs-hold .fs-btn{height:40px;padding:0 16px;font-size:14px}.fs-hold .fs-hold-decide{flex:1 1 auto;justify-content:flex-end}.fs-btn,.fs-btn-sm{height:40px;padding:0 14px;font-size:13px}.fs-link,.fs-hold-more{display:inline-flex;align-items:center;min-height:40px;padding:0 2px;font-size:13px}.fs-icon{height:40px;min-width:40px}.fs-input,.fs-digits,.fs-link-input{height:40px;font-size:14px}.fs-paste{min-height:40px}.fs-remove{width:32px;height:32px}.fs-pending-item,.fs-past-head{min-height:40px}.fs-hold .fs-draft{overflow-wrap:anywhere}.fs-preview-foot .fs-btn,.fs-wait-foot .fs-btn{height:40px}.bt-flybot-session-dock{padding-bottom:calc(6px + env(safe-area-inset-bottom,0px))}.fs-group-strip{margin:6px 8px}} ` function useStyles() { useEffect(() => { diff --git a/plugins/social/test/group-phone.test.js b/plugins/social/test/group-phone.test.js new file mode 100644 index 000000000..015530f03 --- /dev/null +++ b/plugins/social/test/group-phone.test.js @@ -0,0 +1,128 @@ +// Group chat on a phone (#647): the surfaces a reader away from the desk actually touches. +// +// The owner's decision is that slice 1 of the mobile work is a PWA, and that the installability half +// (manifest, icons, `viewport-fit=cover`) lives in DSH's `dsh-web-frontend` package, not here. What this +// repository owns is the layout of its own group surfaces, and #362 only ever sized the held-reply +// Approve / Reject pair (#362's rule is still the first one in the block below). +// +// These cases pin the rest of that pass: at 375-640 px every control in a group chat is a 40 px tap +// target, and the invite / join row keeps its input and its button usable. 20-30 px controls fail even +// the loosest floor in the accessibility standards (WCAG 2.5.8 asks 24 px; the phone guides ask 44/48), +// and they were what the group strip, the invite row and the approval rows shipped. +// +// The assertion reads the shipped file, not a copy of the CSS: `client.js` is the artefact the browser +// loads, so a rule that only exists in a source comment would not count. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const SOURCE = readFileSync(new URL('../client.js', import.meta.url), 'utf8') +// One phone block, one line, closed before the newline — the shape `social.test.js` already pins. +const PHONE = SOURCE.match(/@media \(max-width:640px\)\{([^\n]*)\}\n/)?.[1] ?? '' + +const rule = (selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return PHONE.match(new RegExp(`(?:^|\\})${escaped}\\{([^}]*)\\}`))?.[1] ?? '' +} + +test('the group surfaces have exactly one phone block, at 640px', () => { + assert.ok(PHONE, 'a max-width:640px block exists') + assert.equal((SOURCE.match(/@media \(max-width:640px\)/g) ?? []).length, 1, 'one phone block, not two that can drift') + // Nothing leaks out of it: what follows the block is the next top-level rule. + assert.equal((PHONE.match(/\{/g) ?? []).length, (PHONE.match(/\}/g) ?? []).length, 'balanced braces inside the media block') +}) + +test('#362 keeps its contract: the held-reply Approve / Reject pair stays 40px and right-aligned', () => { + assert.match(rule('.fs-hold .fs-btn'), /height:40px/) + assert.match(rule('.fs-hold .fs-hold-decide'), /justify-content:flex-end/) +}) + +test('every group action button is a 40px tap target on a phone, small ones included', () => { + const button = rule('.fs-btn,.fs-btn-sm') + assert.match(button, /height:40px/, 'the shared button class and its small variant') + assert.match(button, /padding:0 14px/) + // The invite row and the approval card use these: Copy link, Invite by link, Add, Remove. + assert.match(PHONE, /\.fs-btn-sm\{/, 'the small variant is named, not left at its 26px desktop size') +}) + +test('text links and the "more" toggle become tappable rows instead of 12px words', () => { + const link = rule('.fs-link,.fs-hold-more') + assert.match(link, /min-height:40px/) + assert.match(link, /display:inline-flex/) + assert.match(link, /align-items:center/) +}) + +test('the invite / join inputs are 40px tall, so the button beside them is not standing alone', () => { + const inputs = rule('.fs-input,.fs-digits,.fs-link-input') + assert.match(inputs, /height:40px/) + assert.match(rule('.fs-paste'), /min-height:40px/, 'the multi-line paste box keeps auto height but a full tap area') +}) + +test('the two controls that were below every floor are sized up', () => { + assert.match(rule('.fs-icon'), /height:40px/) + assert.match(rule('.fs-icon'), /min-width:40px/) + assert.match(rule('.fs-remove'), /width:32px/, 'the 20x20 row remove button') + assert.match(rule('.fs-remove'), /height:32px/) +}) + +test('the approval and history rows are tappable, not 4px-padded text', () => { + assert.match(rule('.fs-pending-item,.fs-past-head'), /min-height:40px/) +}) + +test('no phone rule escapes the media query, so a desktop layout is unchanged by this pass', () => { + for (const selector of ['.fs-btn,.fs-btn-sm', '.fs-link,.fs-hold-more', '.fs-icon', '.fs-input,.fs-digits,.fs-link-input', '.fs-remove', '.fs-pending-item,.fs-past-head']) { + assert.ok(rule(selector), `${selector} lives inside the phone block`) + } + // The desktop sizes are still the ones above the block: a 30px button, a 26px small button, a 20px remove. + assert.match(SOURCE, /\.fs-btn\{[^}]*height:30px/, 'desktop button height is untouched') + assert.match(SOURCE, /\.fs-btn-sm\{[^}]*height:26px/, 'desktop small button height is untouched') + assert.match(SOURCE, /\.fs-remove\{[^}]*width:20px/, 'desktop remove button is untouched') +}) + +test('path 1 (message text) — a long unbroken run wraps inside the card instead of widening the column', () => { + // The group message *list* is core's; inside this plugin a peer's text reaches the reader as the + // held-reply card and as row text, so those are the two surfaces pinned here. + assert.match(rule('.fs-hold .fs-draft'), /overflow-wrap:anywhere/, 'a pasted URL or tool output breaks inside the bubble') + assert.match(SOURCE, /\.fs-msg\{[^}]*overflow-wrap:anywhere/, 'row text keeps the anywhere wrap it already had') + assert.match(SOURCE, /\.fs-hold\{[^}]*overflow:hidden/, 'and the card clips, so a break failure cannot push the column sideways') +}) + +test('path 2 (composer) — the input + button row wraps, and the dock clears the home indicator at inset 0', () => { + assert.match(SOURCE, /\.fs-invite-actions\{[^}]*flex-wrap:wrap/, 'the input and its button wrap at 375px instead of overflowing') + // The shipped shell has no `viewport-fit=cover`, so `env(safe-area-inset-bottom)` is 0 on this stack + // (#647). The dock rule is still correct there — and stays verifiable — only because it carries a + // 0px fallback: today it reads 6px, and it becomes a real inset the day the shell is fixed. + const envUses = PHONE.match(/env\([^)]*\)/g) ?? [] + assert.ok(envUses.length > 0, 'the dock clears the inset') + for (const use of envUses) assert.match(use, /,\s*0px\s*\)$/, use + ' needs a 0px fallback, or it is inert today') +}) + +test('the dock half of the phone pass lands here too, so #1149 closes with it (#1150)', () => { + assert.match(rule('.bt-flybot-session-dock'), /padding-bottom:calc\(6px \+ env\(safe-area-inset-bottom,0px\)\)/) + assert.match(rule('.fs-group-strip'), /margin:6px 8px/) + // One number for the link rows everywhere: the dock must not carry its own 32px override. + assert.doesNotMatch(PHONE, /\.bt-flybot-session-dock \.fs-link\{/) +}) + +test('path 3 (approval cards) — the action row wraps and each action keeps a 40px target', () => { + assert.match(SOURCE, /\.fs-hold \.fs-actions\{[^}]*flex-wrap:wrap/, 'four buttons wrap rather than overflow the card') + assert.match(rule('.fs-pending-item,.fs-past-head'), /min-height:40px/, 'the approval and history rows are tappable') +}) + +test('the phone block is the last rule in the sheet, so no later rule of equal specificity overrides it', () => { + // `:261` and `:300` used to redeclare `.fs-remove` (20px) and `.fs-link-input` (28px) *after* the + // media block, with the same specificity — the browser took the later one, so the block was not + // doing what it said. Order is the fix, and this is the case that fails if someone appends a rule + // after it again. + const at = SOURCE.indexOf('@media (max-width:640px)') + const blockEnd = SOURCE.indexOf('}\n', at) + const cssEnd = SOURCE.indexOf('`\n function useStyles') + assert.ok(at > 0 && blockEnd > at && cssEnd > blockEnd, 'the block sits inside the stylesheet') + assert.equal(SOURCE.slice(blockEnd + 2, cssEnd).trim(), '', 'nothing may follow the phone block') +}) + +test('the two footer buttons that out-specify `.fs-btn` are raised in the same block', () => { + // `.fs-preview-foot .fs-btn` (28px) and `.fs-wait-foot .fs-btn` (36px) are (0,2,0) against the + // block's (0,1,0), so `.fs-btn{height:40px}` cannot reach them however late it sits. + assert.match(rule('.fs-preview-foot .fs-btn,.fs-wait-foot .fs-btn'), /height:40px/) +})