web: focus modal, slice 8 of M3

Clicking a panel's network (cursor zoom-in, a real button for keyboard
users) or its menu's Focus item opens the modal: the same SVG at the
same global round rendered up to 90vw/85vh over the spec scrim, with a
second PlayerBar docked in the footer sharing the store. Only the page
PlayerBar owns the global keyboard map, so keys are never handled
twice.

role=dialog with aria-modal, labelled by the panel name, focus moves in
and is trapped, Esc/X/scrim close, and focus returns to the opener.
focusPanelId already lives in the URL, so shared links open
pre-focused; on mobile the modal becomes a full-screen sheet.
This commit is contained in:
Justin Visser 2026-06-10 17:55:36 +02:00
parent eb1f7427c4
commit 8929eeddb0
4 changed files with 260 additions and 3 deletions

View file

@ -22,6 +22,7 @@ const failed = computed(() => store.state.failedPanelIds.has(props.panel.id))
const menuItems = computed<PanelMenuItem[]>(() => [
{ key: 'edit', label: 'Edit' },
{ key: 'rename', label: 'Rename' },
{ key: 'focus', label: 'Focus' },
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
])
@ -34,6 +35,9 @@ function onMenuSelect(itemKey: string) {
focusLabelOnOpen.value = itemKey === 'rename'
store.state.editingPanelId = props.panel.id
break
case 'focus':
store.setFocusPanel(props.panel.id)
break
case 'duplicate':
store.duplicatePanel(props.panel.id)
kebabButton.value?.focus()
@ -136,7 +140,14 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
<div class="chips">
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
</div>
<NetworkView :panel="panel" />
<button
class="netbtn"
type="button"
:aria-label="`Enlarge ${panel.label}`"
@click="store.setFocusPanel(panel.id)"
>
<NetworkView :panel="panel" />
</button>
</template>
<template v-else>
<div class="skeleton" aria-hidden="true">
@ -273,6 +284,16 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
flex-wrap: wrap;
}
.netbtn {
display: block;
width: 100%;
border: none;
background: none;
padding: 0;
cursor: zoom-in;
border-radius: 8px;
}
.chip {
font-size: 11.5px;
font-weight: 550;