Align Meet web lobby and calls with native layouts
14 files changed,
+267
−24
go.mod+1 −1go.sum+2 −2implementation/web-native-consistency/design.json+12 −0internal/meet/render.go+26 −1internal/meet/static/call-layout.js+61 −0internal/meet/static/meet-shell.css+12 −0internal/meet/static/meet.css+64 −0internal/meet/templates/join.html+9 −5internal/meet/templates/layout.html+2 −0internal/meet/templates/room.html+20 −12internal/meet/templates/signin.html+4 −0internal/meettest/browser_test.go+1 −0internal/meettest/room_test.go+2 −2test/browser/call.mjs+51 −1
CI passed — run details
diff --git a/go.mod b/go.mod| index 63dbe54..057509b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,6 +3,7 @@ module amadan.net/oficina/meet |
| go 1.26.0 |
| require ( |
| + amadan.net/oficina/estilo.git v0.0.0-20260912215353-63070a89b55f |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| @@ -12,7 +13,6 @@ require ( |
| ) |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c // indirect |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
diff --git a/go.sum b/go.sum| index e5fab7f..eeb3f6f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,5 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts= |
| -amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/estilo.git v0.0.0-20260912215353-63070a89b55f h1:SEyU4OQDm3iih5m0YF50RVcZe3hUov2xTfPY5t5vwos= |
| +amadan.net/oficina/estilo.git v0.0.0-20260912215353-63070a89b55f/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
diff --git a/implementation/web-native-consistency/design.json b/implementation/web-native-consistency/design.json| new file mode 100644 |
| index 0000000..b311bee |
| --- /dev/null |
| +++ b/implementation/web-native-consistency/design.json |
| @@ -0,0 +1,12 @@ |
| +{ |
| + "brief": "Make Meet web and native UI fairly consistent, using frontend-design skill and shared Estilo primitives.", |
| + "baseline": "Native MeetAppearance, MeetLobby and MacCallView at 9225ec2; preserve existing web authentication, device and media behavior.", |
| + "palette": {"surface": "#ffffff", "canvas": "#f8f9fb", "text": "#171b24", "secondary": "#586174", "native_teal_reference": "#087d8a", "video": "#000000"}, |
| + "tokens": "Use existing day tokens for adaptive surfaces, typography, controls and focus; retain day accent rather than introduce a global hue change. Native reference teal remains in the Meet icon. Black video surface matches MacVideoTile.", |
| + "type": "System UI stack, 32px lobby heading, 24px meeting heading, 16px controls and 14px supporting text. Native familiarity and OS font rendering are the deliberate choice.", |
| + "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.", |
| + "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/render.go b/internal/meet/render.go| index 0060189..91da738 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -4,6 +4,10 @@ import ( |
| "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| + "io/fs" |
| + "testing/fstest" |
| + |
| + "amadan.net/oficina/estilo.git/callui" |
| "html/template" |
| "net/http" |
| @@ -16,7 +20,28 @@ import ( |
| //go:embed templates static |
| var appFS embed.FS |
| -var assets = rastrillo.NewAssets(appFS) |
| +var assets = rastrillo.NewAssets(callAssets()) |
| + |
| +func callAssets() fs.FS { |
| + files := fstest.MapFS{"static/callui.css": &fstest.MapFile{Data: callui.CSS()}} |
| + if err := fs.WalkDir(appFS, "static", func(path string, entry fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if entry.IsDir() { |
| + return nil |
| + } |
| + data, err := appFS.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + files[path] = &fstest.MapFile{Data: data} |
| + return nil |
| + }); err != nil { |
| + panic(err) |
| + } |
| + return files |
| +} |
| var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share"} |
diff --git a/internal/meet/static/call-layout.js b/internal/meet/static/call-layout.js| new file mode 100644 |
| index 0000000..5273d13 |
| --- /dev/null |
| +++ b/internal/meet/static/call-layout.js |
| @@ -0,0 +1,61 @@ |
| +// Layout owns presentation only. room.js continues to own every media stream. |
| +(() => { |
| + 'use strict'; |
| + const room = document.getElementById('room'); |
| + const tiles = document.getElementById('tiles'); |
| + const controls = document.getElementById('video-layout'); |
| + const focus = document.getElementById('layout-focus'); |
| + const grid = document.getElementById('layout-grid'); |
| + const chat = document.getElementById('chat'); |
| + const toggle = document.getElementById('toggle-chat'); |
| + const workspace = document.getElementById('call-workspace'); |
| + const log = document.getElementById('chat-log'); |
| + const unreadLabel = document.getElementById('chat-unread'); |
| + if (!room || !tiles || !controls || !chat || !toggle || !workspace) return; |
| + let mode = 'focus'; |
| + let unread = 0; |
| + let expanded = true; |
| + function layout() { |
| + tiles.dataset.layout = mode; |
| + focus.setAttribute('aria-pressed', String(mode === 'focus')); |
| + grid.setAttribute('aria-pressed', String(mode === 'grid')); |
| + 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')); |
| + for (const tile of all) tile.toggleAttribute('data-focused', tile === main); |
| + } |
| + function chatState() { |
| + // Mount readiness belongs to room.js; hiding the visual sidebar never |
| + // changes its hidden attribute, draft, admission or send state. |
| + toggle.hidden = chat.hidden; |
| + workspace.setAttribute('estilo-call-workspace', expanded && !chat.hidden ? 'sidebar' : ''); |
| + chat.classList.toggle('meet-chat-collapsed', !expanded); |
| + toggle.setAttribute('aria-expanded', String(expanded)); |
| + toggle.setAttribute('aria-label', unread ? `Chat (${unread} new)` : 'Chat'); |
| + unreadLabel.textContent = unread ? ` (${unread})` : ''; |
| + } |
| + focus.addEventListener('click', () => { mode = 'focus'; layout(); }); |
| + grid.addEventListener('click', () => { mode = 'grid'; layout(); }); |
| + toggle.addEventListener('click', () => { |
| + expanded = !expanded; |
| + if (expanded) unread = 0; |
| + chatState(); |
| + if (expanded) document.getElementById('chat-text')?.focus(); |
| + }); |
| + new MutationObserver(layout).observe(tiles, {childList: true}); |
| + new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']}); |
| + if (log) new MutationObserver(records => { |
| + if (!expanded) { |
| + for (const record of records) { |
| + for (const node of record.addedNodes) { |
| + if (node.nodeType === 1 && !node.classList.contains('chat-line-self')) unread++; |
| + } |
| + } |
| + chatState(); |
| + } |
| + }).observe(log, {childList: true}); |
| + controls.hidden = false; |
| + layout(); |
| + chatState(); |
| +})(); |
diff --git a/internal/meet/static/meet-shell.css b/internal/meet/static/meet-shell.css| new file mode 100644 |
| index 0000000..1047e66 |
| --- /dev/null |
| +++ b/internal/meet/static/meet-shell.css |
| @@ -0,0 +1,12 @@ |
| +/* Shared day tokens keep the browser's OS theme and suite preferences. */ |
| +body { background: var(--rst-bg); } |
| +[rst-shell-topbar] { --rst-page-max: 80rem; } |
| +[rst-shell-bar] { min-block-size: 4rem; } |
| +[rst-shell-brand] { min-block-size: 44px; } |
| +[rst-page] { max-inline-size: 80rem; margin-inline: auto; } |
| +.meet-lobby { max-inline-size: 32rem; margin: clamp(1rem, 8vh, 5rem) auto; } |
| +.meet-lobby [estilo-call-panel] { margin-block-start: 1.5rem; padding: clamp(1rem, 4vw, 1.5rem); } |
| +.meet-lobby h1 { font-size: 2rem; letter-spacing: -0.025em; } |
| +.meet-lobby-heading h1 { font-size: 2rem; } |
| +[estilo-call-header] h1 { font-size: 1.5rem; } |
| +[estilo-call-header] [rst-page-header] { margin: 0; } |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| index 6b4fa68..ec23ec6 100644 |
| --- a/internal/meet/static/meet.css |
| +++ b/internal/meet/static/meet.css |
| @@ -192,3 +192,67 @@ |
| font-weight: 600; |
| margin-block: var(--rst-sp-2); |
| } |
| + |
| +/* Match the native stage geometry while retaining every live video element. */ |
| +.meet-grid { |
| + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); |
| + 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'] .meet-tile:not([data-focused]) { aspect-ratio: 16 / 10; } |
| +.meet-tile { |
| + margin: 0; |
| + min-inline-size: 0; |
| + aspect-ratio: 16 / 9; |
| + border-radius: 16px; |
| + background: #000; |
| +} |
| +.meet-tile-name { flex-wrap: wrap; border-radius: 14px; } |
| +.meet-tile-name button, .meet-tile-name a { |
| + min-block-size: 24px; |
| + min-inline-size: 24px; |
| +} |
| +.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; } |
| +#call-workspace .chat-log { min-block-size: 10rem; max-block-size: 24rem; } |
| +#call-workspace .meet-chat-collapsed { display: none; } |
| +.meet-device-layout { display: grid; gap: var(--rst-sp-4); align-items: start; } |
| +.meet-preview-wrap { border-radius: 20px; overflow: hidden; background: #000; } |
| +.meet-preview { display: block; max-width: none; aspect-ratio: 16 / 9; border: 0; border-radius: 0; } |
| +.meet-pickers { margin-block-start: 0; } |
| +.meet-pickers > div { flex: 1 1 12rem; min-inline-size: 0; } |
| +.meet-pickers select { max-inline-size: 100%; } |
| +.meet-queue li { flex-wrap: wrap; overflow-wrap: anywhere; } |
| +@media (min-width: 64rem) { |
| + .meet-device-layout { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } |
| +} |
| +@media (max-width: 35rem) { |
| + .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 { |
| + grid-row: 1; |
| + grid-column: 3; |
| + align-self: end; |
| + margin: 12px; |
| + box-shadow: 0 3px 16px #0006; |
| + z-index: 1; |
| +} |
| +.meet-controls form { margin: 0; } |
| +.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 { |
| + grid-column: 2; |
| + max-inline-size: 7rem; |
| + justify-self: end; |
| + margin: 6px; |
| + } |
| + .meet-controls #toggle-chat { margin-inline-start: 0; } |
| +} |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index 63cfc31..caad6d2 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -4,7 +4,7 @@ |
| {{end}} |
| {{define "content"}} |
| {{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| -{{template "page-header" dict "Title" .Title}} |
| +<div class="meet-lobby-heading">{{template "page-header" dict "Title" .Title}}</div> |
| {{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}} |
| @@ -12,18 +12,22 @@ |
| {{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> |
| - <video id="preview" muted autoplay playsinline aria-label="Your camera preview" class="meet-preview"></video> |
| + <div class="meet-device-layout"> |
| +<div class="meet-preview-wrap"><video id="preview" muted autoplay playsinline aria-label="Your camera preview" class="meet-preview"></video></div> |
| +<div estilo-call-panel> |
| <div id="pickers" class="meet-pickers"> |
| <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div> |
| <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div> |
| <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| </div> |
| - <div class="meet-controls"> |
| - <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">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">Camera<span id="toggle-camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| + <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="join" rst-btn="primary lg" disabled>Join the call</button> |
| </div> |
| <p id="status" class="meet-status" role="status"></p> |
| </div> |
| +</div> |
| +</div> |
| {{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| {{end}} |
diff --git a/internal/meet/templates/layout.html b/internal/meet/templates/layout.html| index 04b6d35..dd723d7 100644 |
| --- a/internal/meet/templates/layout.html |
| +++ b/internal/meet/templates/layout.html |
| @@ -10,6 +10,8 @@ |
| <title>{{block "title" .}}Meet{{end}}</title> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/callui.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/meet-shell.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| {{iconAssets}} |
| <script defer src="{{asset "static/select.js"}}"></script> |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| index 5f4b1cf..07da2da 100644 |
| --- a/internal/meet/templates/room.html |
| +++ b/internal/meet/templates/room.html |
| @@ -3,29 +3,37 @@ |
| <link rel="stylesheet" href="{{asset "static/chat.css"}}"> |
| <script defer src="{{asset "static/chat.js"}}"></script> |
| <script defer src="{{asset "static/room.js"}}"></script> |
| +<script defer src="{{asset "static/call-layout.js"}}"></script> |
| {{end}} |
| {{define "content"}} |
| {{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| +<div estilo-call-header> |
| {{template "page-header" dict "Title" .Title}} |
| +<div id="video-layout" estilo-call-segmented role="group" aria-label="Video layout" hidden> |
| +<button type="button" id="layout-focus" aria-pressed="true">Focus</button> |
| +<button type="button" id="layout-grid" aria-pressed="false">Grid</button> |
| +</div> |
| +</div> |
| {{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}} |
| + |
| <noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript> |
| <div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden> |
| - <div id="tiles" class="meet-grid"></div> |
| - <div id="controls" class="meet-controls"> |
| - <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">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">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">Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button> |
| - <button type="button" id="leave" rst-btn="danger">Leave</button> |
| + <div id="call-workspace" estilo-call-workspace="sidebar"> |
| + <div class="meet-stage"> |
| + <div id="tiles" class="meet-grid"></div> |
| </div> |
| - {{/* The chat panel (spec §6): one partial, one script, one |
| - stylesheet, none of which know anything about Meet — see the |
| - comment in partials/chat.html. chat.js loads before room.js in |
| - the head block, and two deferred scripts run in document order, |
| - so window.Chat is there when room.js mounts it. */}} |
| {{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> |
| +{{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> |
| + </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> |
| </div> |
diff --git a/internal/meet/templates/signin.html b/internal/meet/templates/signin.html| index 337d45f..1612233 100644 |
| --- a/internal/meet/templates/signin.html |
| +++ b/internal/meet/templates/signin.html |
| @@ -1,6 +1,8 @@ |
| {{define "title"}}Sign in{{end}} |
| {{define "content"}} |
| +<div class="meet-lobby"> |
| {{template "page-header" dict "Title" "Sign in to Meet"}} |
| +<div estilo-call-panel> |
| {{if .Sent}} |
| <p rst-notice>A sign in link should be on its way with a link to click.</p> |
| {{else}} |
| @@ -10,4 +12,6 @@ |
| <div rst-form-actions><button type="submit" rst-btn="primary lg">Send me a sign-in link</button></div> |
| </form> |
| {{end}} |
| +</div> |
| +</div> |
| {{end}} |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index edaee28..3e64427 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -80,6 +80,7 @@ const ( |
| // and is skipped from inside its own subtest with t.Skip carrying the |
| // exact failure — never deleted, and never quietly loosened. |
| var scenarios = []string{ |
| + "layout", |
| "devices", |
| "call", |
| "share", |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| index 1998811..9f5b927 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 > 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) |
| + 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) |
| } |
| // 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 6aa3d61..52b915d 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -31,7 +31,7 @@ |
| // candidates are the only ones there are, and an obfuscated one connects |
| // to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia |
| // with no picker, which the fake-ui flag does not cover (§5.4). |
| -import { writeSync } from 'node:fs'; |
| +import { writeSync, mkdirSync } from 'node:fs'; |
| import { createInterface } from 'node:readline'; |
| import { chromium } from 'playwright'; |
| @@ -3591,6 +3591,56 @@ const run = { |
| // The floor: both pages load with JavaScript switched off, say so in |
| // plain words, and show nothing that pretends to be a call. |
| + async layout() { |
| + const ctxA = await newContext('layout A', {cookie: cookieA}); |
| + const ctxB = await newContext('layout B', {cookie: cookieB}); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + const pair = await assertPair(pageA, pageB, 'layout A', 'layout B'); |
| + if (!pair) throw new Error('layout call did not connect'); |
| + await pageA.locator('#chat-text').fill('Draft kept while chat is closed'); |
| + await pageA.locator('#toggle-chat').click(); |
| + check(await pageA.locator('#chat').isHidden(), 'chat collapses'); |
| + await pageB.locator('#chat-text').fill('New message while hidden'); |
| + await pageB.locator('#chat-send').click(); |
| + check(await until(async () => (await pageA.locator('#chat-unread').innerText()).includes('1'), 5000), |
| + 'hidden chat reports unread arrivals'); |
| + await pageA.locator('#toggle-chat').focus(); |
| + await pageA.keyboard.press('Enter'); |
| + check(await pageA.locator('#chat-text').inputValue() === 'Draft kept while chat is closed', |
| + 'keyboard reopening preserves the draft'); |
| + check(await pageA.locator('#chat-log').innerText().then(x => x.includes('New message while hidden')), |
| + 'reopening preserves messages'); |
| + const out = process.env.MEET_DESIGN_SCREENSHOTS; |
| + if (out) mkdirSync(out, {recursive: true}); |
| + for (const width of [1200, 320]) { |
| + await pageA.setViewportSize({width, height: 900}); |
| + for (const colorScheme of ['light', 'dark']) { |
| + await pageA.emulateMedia({colorScheme, reducedMotion: 'reduce'}); |
| + for (const mode of ['focus', 'grid']) { |
| + await pageA.locator('#layout-' + mode).click(); |
| + check(await pageA.locator('#layout-' + mode).getAttribute('aria-pressed') === 'true', mode + ' selection is exposed'); |
| + check(await pageA.evaluate(() => document.documentElement.scrollWidth <= innerWidth), |
| + `${width}px ${colorScheme} ${mode} has no horizontal page overflow`); |
| + check(await pageA.locator('#controls button:visible').evaluateAll(buttons => buttons.every(button => { |
| + const r = button.getBoundingClientRect(); return r.width >= 44 && r.height >= 44; |
| + })), 'call controls retain 44px targets'); |
| + if (out) await pageA.screenshot({path: `${out}/room-${width}-${colorScheme}-${mode}.png`, fullPage: true}); |
| + } |
| + } |
| + } |
| + await assertPair(pageA, pageB, 'layout A after changes', 'layout B after changes'); |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + const holding = await ctxA.newPage(); |
| + await holding.setViewportSize({width: 320, height: 900}); |
| + await holding.goto(origin + '/m/' + sqid + '/join'); |
| + await holding.locator('#join').waitFor({state: 'visible'}); |
| + 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(); |
| + }, |
| + |
| async scriptless() { |
| const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false }); |
| const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.'; |