Keep call overlays clear of shared screens and recovery controls
8 files changed,
+61
−23
implementation/web-native-consistency/design.json+1 −1internal/meet/icons/icons.go+6 −0internal/meet/static/call-layout.js+4 −2internal/meet/static/meet.css+17 −8internal/meet/templates/join.html+3 −3internal/meet/templates/room.html+7 −7internal/meettest/room_test.go+2 −2test/browser/call.mjs+21 −0
diff --git a/implementation/web-native-consistency/design.json b/implementation/web-native-consistency/design.json| index b311bee..ef61db0 100644 |
| --- a/implementation/web-native-consistency/design.json |
| +++ b/implementation/web-native-consistency/design.json |
| @@ -7,6 +7,6 @@ |
| "layout": "Left-aligned lobby: title and identity above a large preview and device controls. In-call: title and layout switch above video stage, optional chat sidebar to its right, stable controls below. At mobile widths chat stacks below the stage and controls wrap.", |
| "wireframe": "[Title Focus | Grid]\n[ Main video / smaller participants ][ Chat ]\n[ Mic Camera Share screen Chat Leave ]", |
| "review_against_brief": "Keep the video itself as the dominant surface. Avoid decorative cards around every setting or an unrelated marketing hero. Shared Estilo CSS owns reusable panel, call workspace, segmented controls and action target geometry. Meet owns media-tile layout and DOM wiring. This is a first adoption, not a claimed suite-wide interface.", |
| - "states": "Retain visible On/Muted/Off text and aria-pressed. Focus/grid changes never detach streams. Collapsing chat preserves its draft and log. No new identity, media permission or authority path.", |
| + "states": "Retain visible On/Muted/Off text and aria-pressed. Focus/grid changes never detach streams. Focus selects a shared screen first, otherwise the first remote participant; native participant selection remains a later enhancement. Collapsing chat preserves its draft and log. No new identity, media permission or authority path.", |
| "verification": "Full make ci and real two-browser media/chat gate; mobile 320px, desktop, keyboard, dark/light, script-off fallbacks; Fable source/design review and canary inspection before merge/promotion." |
| } |
diff --git a/internal/meet/icons/icons.go b/internal/meet/icons/icons.go| index b10d722..ce5c344 100644 |
| --- a/internal/meet/icons/icons.go |
| +++ b/internal/meet/icons/icons.go |
| @@ -20,6 +20,12 @@ package icons |
| import "html/template" |
| var icons = map[string]template.HTML{ |
| + "mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`, |
| + "video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`, |
| + "monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`, |
| + "message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`, |
| + "log-out": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`, |
| + |
| "alert-triangle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`, |
| // class carries "lucide-bot" alongside the app's own "icon" so the |
| // drive surface's robot badge (index.html, §8.1) can be told apart |
diff --git a/internal/meet/static/call-layout.js b/internal/meet/static/call-layout.js| index 5273d13..a4684c6 100644 |
| --- a/internal/meet/static/call-layout.js |
| +++ b/internal/meet/static/call-layout.js |
| @@ -22,7 +22,9 @@ |
| const all = Array.from(tiles.children); |
| const main = all.find(tile => tile.classList.contains('meet-tile-share')) || |
| all.find(tile => !tile.classList.contains('meet-tile-self')) || all[0]; |
| - tiles.toggleAttribute('data-remote-focus', !!main && !main.classList.contains('meet-tile-self')); |
| + tiles.toggleAttribute('data-pip', !!main && !main.classList.contains('meet-tile-self') && |
| + !main.classList.contains('meet-tile-share') && |
| + !main.querySelector('.meet-tile-note:not([hidden]), button:not([hidden])')); |
| for (const tile of all) tile.toggleAttribute('data-focused', tile === main); |
| } |
| function chatState() { |
| @@ -43,7 +45,7 @@ |
| chatState(); |
| if (expanded) document.getElementById('chat-text')?.focus(); |
| }); |
| - new MutationObserver(layout).observe(tiles, {childList: true}); |
| + new MutationObserver(layout).observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden']}); |
| new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']}); |
| if (log) new MutationObserver(records => { |
| if (!expanded) { |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| index ec23ec6..9d27a6f 100644 |
| --- a/internal/meet/static/meet.css |
| +++ b/internal/meet/static/meet.css |
| @@ -160,8 +160,7 @@ |
| and this inset outline. */ |
| .meet-controls button[aria-pressed='true'] { |
| font-weight: 700; |
| - outline: 2px solid var(--rst-text); |
| - outline-offset: -3px; |
| + |
| } |
| .meet-toggle-state { |
| @@ -199,7 +198,7 @@ |
| margin: 0; |
| } |
| .meet-grid[data-layout='focus'] { grid-template-columns: repeat(3, minmax(0, 1fr)); } |
| -.meet-grid[data-layout='focus'] [data-focused] { grid-column: 1 / -1; } |
| +.meet-grid[data-layout='focus'] [data-focused] { grid-column: 1 / -1; grid-row: 1; order: -1; } |
| .meet-grid[data-layout='focus'] .meet-tile:not([data-focused]) { aspect-ratio: 16 / 10; } |
| .meet-tile { |
| margin: 0; |
| @@ -215,7 +214,7 @@ |
| } |
| .meet-controls { margin: 0; } |
| .meet-toggle-state { font-variant: normal; letter-spacing: normal; opacity: 1; font-size: 0.8em; } |
| -.meet-controls #leave { margin-inline-start: auto; } |
| + |
| .meet-controls #toggle-chat[hidden] { display: none; } |
| .meet-stage { min-inline-size: 0; } |
| #call-workspace .chat { margin: 0; border-radius: 16px; } |
| @@ -235,8 +234,7 @@ |
| .meet-grid[data-layout='focus'] { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| .meet-controls #leave { margin-inline-start: 0; } |
| } |
| -.meet-grid[data-layout='focus'] [data-focused] { grid-row: 1; order: -1; } |
| -.meet-grid[data-layout='focus'][data-remote-focus] .meet-tile-self { |
| +.meet-grid[data-layout='focus'][data-pip] .meet-tile-self { |
| grid-row: 1; |
| grid-column: 3; |
| align-self: end; |
| @@ -248,11 +246,22 @@ |
| .meet-controls #toggle-chat { margin-inline-start: auto; } |
| .meet-controls #leave { margin-inline-start: 0; } |
| @media (max-width: 35rem) { |
| - .meet-grid[data-layout='focus'][data-remote-focus] .meet-tile-self { |
| + .meet-grid[data-layout='focus'][data-pip] .meet-tile-self { |
| grid-column: 2; |
| - max-inline-size: 7rem; |
| + max-inline-size: 60%; |
| justify-self: end; |
| margin: 6px; |
| } |
| .meet-controls #toggle-chat { margin-inline-start: 0; } |
| } |
| + |
| +.meet-preview[hidden] { display: none; } |
| +#call-workspace .meet-controls { grid-column: 1 / -1; } |
| +.meet-grid[data-layout='focus'][data-pip] [data-focused] .meet-tile-name { max-inline-size: 60%; } |
| +@media (min-width: 64rem) { |
| + #call-workspace .meet-controls { grid-row: 2; } |
| + #call-workspace[estilo-call-workspace='sidebar'] .chat { grid-column: 2; grid-row: 1; } |
| +} |
| +@media (max-width: 63.99rem) { |
| + #call-workspace .chat-log { min-block-size: 4rem; max-block-size: 14rem; } |
| +} |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index caad6d2..ed0e2e4 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -8,7 +8,7 @@ |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| -{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share</button></form>{{end}} |
| +{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false" estilo-call-actions><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}} |
| {{if .OpenElsewhere}}<p rst-notice>This meeting is already open in another tab. <a href="/m/{{.Sqid}}/takeover">Use this tab instead</a>.</p>{{end}} |
| <noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript> |
| <div id="devices" data-sqid="{{.Sqid}}" data-join-href="{{if .OpenElsewhere}}/m/{{.Sqid}}/takeover{{else}}/m/{{.Sqid}}{{end}}" hidden> |
| @@ -21,8 +21,8 @@ |
| <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| </div> |
| <div class="meet-controls" estilo-call-actions> |
| - <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| - <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>Camera<span id="toggle-camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| + <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| + <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button> |
| </div> |
| <p id="status" class="meet-status" role="status"></p> |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| index 07da2da..6bc84be 100644 |
| --- a/internal/meet/templates/room.html |
| +++ b/internal/meet/templates/room.html |
| @@ -24,15 +24,15 @@ |
| <div class="meet-stage"> |
| <div id="tiles" class="meet-grid"></div> |
| </div> |
| - {{template "chat-panel" .}} |
| - </div> |
| <div id="controls" class="meet-controls" estilo-call-actions> |
| - <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| - <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| - <button type="button" id="share" rst-btn aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button> |
| + <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| + <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| + <button type="button" id="share" rst-btn aria-pressed="false">{{icon "monitor"}}Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button> |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}} |
| - <button type="button" id="toggle-chat" rst-btn aria-expanded="true" aria-controls="chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>Chat<span id="chat-unread" aria-hidden="true"></span></button> |
| - <button type="button" id="leave" rst-btn="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>Leave</button> |
| + <button type="button" id="toggle-chat" rst-btn aria-expanded="true" aria-controls="chat" hidden>{{icon "message-circle"}}Chat<span id="chat-unread" aria-hidden="true"></span></button> |
| + <button type="button" id="leave" rst-btn="danger">{{icon "log-out"}}Leave</button> |
| + </div> |
| + {{template "chat-panel" .}} |
| </div> |
| <div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
| <p id="status" class="meet-status" role="status"></p> |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| index 9f5b927..e2cc078 100644 |
| --- a/internal/meettest/room_test.go |
| +++ b/internal/meettest/room_test.go |
| @@ -532,8 +532,8 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) { |
| if room < 0 || controls < 0 || queue < 0 { |
| t.Fatalf("room page shape changed: room %d controls %d queue %d", room, controls, queue) |
| } |
| - if room > panel || panel > controls || controls > queue { |
| - t.Fatalf("the panel belongs inside #room, beside the stage and before the controls and queue: room %d controls %d chat %d queue %d", room, controls, panel, queue) |
| + if room > controls || controls > panel || panel > queue { |
| + t.Fatalf("the panel belongs inside #room after the controls and before the queue: room %d controls %d chat %d queue %d", room, controls, panel, queue) |
| } |
| // The form's own shape: one labelled control, the browser's own |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 52b915d..6b3143b 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -3629,6 +3629,20 @@ const run = { |
| } |
| } |
| } |
| + await pageA.locator('#layout-focus').click(); |
| + await pageA.locator('[data-focused] .meet-tile-note').evaluate(note => { note.textContent = 'Connection recovery'; note.hidden = false; }); |
| + check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000), |
| + 'recovery captions disable the self overlay'); |
| + await pageA.locator('[data-focused] .meet-tile-note').evaluate(note => { note.textContent = ''; note.hidden = true; }); |
| + await pageB.locator('#share').click(); |
| + await pageA.locator('.meet-tile-share').waitFor({state: 'visible'}); |
| + check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000), |
| + 'screen share never gets a self-preview overlay'); |
| + const shareBounds = await pageA.locator('.meet-tile-share').boundingBox(); |
| + const selfBounds = await pageA.locator('.meet-tile-self').boundingBox(); |
| + check(selfBounds.y >= shareBounds.y + shareBounds.height - 1, 'self preview is outside shared screen content'); |
| + if (out) await pageA.screenshot({path: out + '/share-320.png', fullPage: true}); |
| + await pageB.locator('#share').click(); |
| await assertPair(pageA, pageB, 'layout A after changes', 'layout B after changes'); |
| await leaveRoom(pageA); |
| await leaveRoom(pageB); |
| @@ -3639,6 +3653,13 @@ const run = { |
| check(await holding.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'joining screen reflows at 320px'); |
| if (out) await holding.screenshot({path: out + '/join-320.png', fullPage: true}); |
| await ctxA.close(); await ctxB.close(); |
| + const noMedia = await newContext('layout no media', {cookie: cookieA}); |
| + await noMedia.addInitScript(() => Object.defineProperty(navigator, 'mediaDevices', {value: undefined})); |
| + const noPreview = await noMedia.newPage(); |
| + await noPreview.goto(origin + '/m/' + sqid + '/join'); |
| + await noPreview.locator('#devices').waitFor({state: 'visible'}); |
| + check(await noPreview.locator('#preview').isHidden(), 'no-media preview stays hidden despite author styles'); |
| + await noMedia.close(); |
| }, |
| async scriptless() { |