Forward arrow key presses to a remote presenter PC via network.
-
Art-Net Lighting
-
Drive the room lighting from the active
- screensaver. Nothing is sent until this is enabled and a URL is
- given — there is no default relay.
-
-
-
- Relay URL
-
-
-
- Target
-
-
- All fixtures — one flat colour
- Spatial — colours follow the wall
- Spot — follows the bright part of the wall
- Ripple
- Plasma
- Blobs
- Aurora
-
-
+
+
+ Layout
+
- Max Brightness
-
- 80%
+ Center gap Dual view only
+
+ 60px
-
-
Spot Depth
-
-
50%
+
+ Side borders Each side
+
+ 0px
-
+
-
-
-
Keyboard Shortcuts
-
-
-
-
-
diff --git a/input_viewer_electron/src/renderer/renderer.js b/input_viewer_electron/src/renderer/renderer.js
index 79b157d..70952b3 100644
--- a/input_viewer_electron/src/renderer/renderer.js
+++ b/input_viewer_electron/src/renderer/renderer.js
@@ -63,6 +63,15 @@ import {
// Snapshot thumbnails for the dropdown input rows (#242).
import { createThumbnailStore } from './input-thumbnails.js'
+// What the Settings panel reports about itself (nav status, status lines).
+import {
+ remoteKeyUrl,
+ inputKeyLabel,
+ remoteKeyboardStatus,
+ artnetStatus,
+ orphanBannerText
+} from './settings-status.js'
+
// Test-mode launch flags (#248).
import {
parseTestFlags,
@@ -162,6 +171,15 @@ const state = {
remoteKeyboardEnabled: false,
remoteKeyboardHost: '',
remoteKeyboardApiKey: '',
+ // Outcome of the most recent press, for the Settings status line. In memory
+ // only: it describes this session, not the configuration.
+ remoteKeyboardLast: null,
+ // Settings modal: which pane is showing, which inputs have their no-signal
+ // panel expanded (kept across re-renders), and the last capture result per
+ // device so the panel can say what happened.
+ settingsSection: 'inputs',
+ expandedRefPanels: new Set(),
+ captureResults: new Map(),
// Presenter tool debug overlay
presenterDebugEnabled: false,
// Experimental WebGPU compositing (issue #62). Off by default: the CSS
@@ -193,31 +211,29 @@ const elements = {
dropdownPanel: document.getElementById('dropdown-panel'),
updateNotification: document.getElementById('update-notification'),
updateMessage: document.getElementById('update-message'),
- // New dropdown elements
- shortcutsTable: document.getElementById('shortcuts-table'),
+ // Dropdown 2b: pickers over the wall and the capsule
+ wallPickers: document.getElementById('wall-pickers'),
legendTrigger: document.getElementById('legend-trigger'),
legendPanel: document.getElementById('legend-panel'),
legendGrid: document.getElementById('legend-grid'),
viewModeDual: document.getElementById('view-mode-dual'),
viewModeSingle: document.getElementById('view-mode-single'),
- dualColumns: document.getElementById('dual-columns'),
- leftInputList: document.getElementById('left-input-list'),
- rightInputList: document.getElementById('right-input-list'),
- singleInputList: document.getElementById('single-input-list'),
openSettingsBtn: document.getElementById('open-settings-btn'),
- // New settings modal elements
+ // Settings modal
settingsModal: document.getElementById('settings-modal'),
closeSettingsBtn: document.getElementById('close-settings-btn'),
+ settingsNavInputs: document.getElementById('settings-nav-inputs'),
+ settingsNavLayout: document.getElementById('settings-nav-layout'),
+ settingsNavRemote: document.getElementById('settings-nav-remote'),
+ settingsNavArtnet: document.getElementById('settings-nav-artnet'),
+ multiViewToggle: document.getElementById('multi-view-toggle'),
settingsInputList: document.getElementById('settings-input-list'),
+ layoutDiagram: document.getElementById('layout-diagram'),
settingsCenterGap: document.getElementById('settings-center-gap'),
settingsCenterGapValue: document.getElementById('settings-center-gap-value'),
settingsBorderWidth: document.getElementById('settings-border-width'),
settingsBorderWidthValue: document.getElementById('settings-border-width-value'),
- captureLeftBtn: document.getElementById('capture-left-btn'),
- captureRightBtn: document.getElementById('capture-right-btn'),
settingsAppVersion: document.getElementById('settings-app-version'),
- // Dropdown volume control elements
- dropdownInputVolumes: document.getElementById('dropdown-input-volumes'),
dropdownSystemVolume: document.getElementById('dropdown-system-volume'),
dropdownSystemVolumeValue: document.getElementById('dropdown-system-volume-value'),
// Cached label references (avoids DOM queries in hot paths)
@@ -233,6 +249,10 @@ const elements = {
remoteKeyboardFields: document.getElementById('remote-keyboard-fields'),
remoteKeyboardHost: document.getElementById('remote-keyboard-host'),
remoteKeyboardApiKey: document.getElementById('remote-keyboard-api-key'),
+ remoteKeyboardApiKeyReveal: document.getElementById('remote-keyboard-api-key-reveal'),
+ remoteKeyboardStatus: document.getElementById('remote-keyboard-status'),
+ artnetStatus: document.getElementById('artnet-status'),
+ artnetSaverCount: document.getElementById('artnet-saver-count'),
// Presenter tool debug overlay
presenterDebugToggle: document.getElementById('presenter-debug-toggle'),
artnetToggle: document.getElementById('artnet-toggle'),
@@ -306,6 +326,7 @@ async function saveSettings() {
presenterDebugEnabled: state.presenterDebugEnabled,
gpuCompositing: state.gpuCompositing,
inputs: state.settings.inputs,
+ multiView: state.settings.multiView !== false,
initialSetupComplete: state.settings.initialSetupComplete,
noSignalReferences: state.settings.noSignalReferences,
// Read from state.settings rather than state, like inputs above: these
@@ -354,6 +375,8 @@ function getDefaultSettings() {
rightVolume: 1.0,
systemVolume: 50,
layoutMode: null, // null means use screen-based detection
+ // Each half of the wall may show a different input. Mirrors main's default.
+ multiView: true,
initialSetupComplete: false,
noSignalReferences: null,
remoteKeyboardEnabled: false,
@@ -396,13 +419,17 @@ function isInputEnabled(deviceId) {
return true // Default to enabled
}
-// Set custom name for input
+// Set custom name for input. An empty name clears it, so the hardware label
+// (shown as the field's placeholder) applies again.
function setInputName(deviceId, name) {
if (!state.settings.inputs[deviceId]) {
state.settings.inputs[deviceId] = { enabled: true }
}
- state.settings.inputs[deviceId].name = name
+ const trimmed = typeof name === 'string' ? name.trim() : ''
+ state.settings.inputs[deviceId].name = trimmed || null
saveSettings()
+ renderDropdownInputLists()
+ if (isSettingsOpen()) renderLayoutDiagram()
}
// Toggle input enabled/disabled
@@ -416,6 +443,94 @@ function toggleInputEnabled(deviceId) {
renderSettingsInputList()
}
+/**
+ * Multi-view: may the two halves show different inputs?
+ *
+ * Off makes dual view behave like the number keys always have -- one input on both
+ * halves -- and gives the dropdown a single row of inputs that sets both.
+ */
+function isMultiView() {
+ return state.settings?.multiView !== false
+}
+
+function setMultiView(on) {
+ state.settings.multiView = Boolean(on)
+ saveSettings()
+ // Turning it off must not leave the halves showing two inputs: from now on they
+ // are always the same, so bring the right half in line with the left.
+ if (!on && state.leftDeviceId && state.rightDeviceId !== state.leftDeviceId) {
+ selectInputForSide(state.leftDeviceId, 'right')
+ }
+ updateMultiViewUI()
+ renderDropdownInputLists()
+}
+
+function updateMultiViewUI() {
+ setSwitch(elements.multiViewToggle, isMultiView())
+}
+
+/**
+ * Write a range slider's value into --fill, which the track's gradient reads to
+ * paint the part left of the thumb blue. Chromium has no pseudo-element for that
+ * part, so it has to come from here. Programmatic `value =` fires no input event,
+ * so code that sets a value also calls this (paintAllRangeFills after a render).
+ */
+function paintRangeFill(el) {
+ const min = Number(el.min) || 0
+ const max = el.max === '' ? 100 : Number(el.max)
+ const pct = Math.max(0, Math.min(100, max > min ? ((Number(el.value) - min) / (max - min)) * 100 : 0))
+ el.style.setProperty('--fill', `${pct}%`)
+
+ // Volume sliders also drive the speaker icon beside them.
+ const volumeRow = el.closest?.('.picker-volume, .capsule-volume')
+ if (volumeRow) ensureVolumeIcon(volumeRow, el).dataset.level = String(volumeLevel(pct))
+}
+
+/**
+ * How many sound waves the speaker shows: 0 is muted, then one, two or three
+ * as the volume passes a third and two thirds. Exported for tests.
+ */
+function volumeLevel(percent) {
+ if (percent <= 0) return 0
+ if (percent <= 33) return 1
+ if (percent <= 66) return 2
+ return 3
+}
+
+// Speaker, three waves and a mute cross. The waves are arcs around the speaker's
+// mouth; pathLength="1" lets the CSS draw each one in with a single dash value.
+const VOLUME_ICON_SVG =
+ '
' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' '
+
+/** The speaker icon in a volume row, created in front of the slider if missing. */
+function ensureVolumeIcon(row, slider) {
+ let icon = row.querySelector('.vol-icon')
+ if (!icon) {
+ const holder = document.createElement('span')
+ holder.innerHTML = VOLUME_ICON_SVG
+ icon = holder.firstChild
+ row.insertBefore(icon, slider)
+ }
+ return icon
+}
+
+function paintAllRangeFills(root = document) {
+ for (const el of root.querySelectorAll('input[type="range"]')) paintRangeFill(el)
+}
+
+/** Reflect a boolean on a role="switch" button (the class is what CSS styles). */
+function setSwitch(el, on) {
+ if (!el) return
+ el.classList.toggle('active', on)
+ el.setAttribute('aria-checked', on ? 'true' : 'false')
+}
+
// =============================================================================
// Freeze Frame
// =============================================================================
@@ -636,6 +751,9 @@ async function getVideoDevices() {
}
renderDropdownInputLists()
+ // Hot-plug while Settings is open: a card coming or going changes the rows,
+ // the Key column and the orphaned-reference banner.
+ if (isSettingsOpen()) renderSettingsInputList()
return state.devices
} catch (error) {
console.error(`[Video] device enumeration failed: ${error?.name}: ${error?.message}`)
@@ -1375,6 +1493,7 @@ async function syncSystemVolume() {
elements.dropdownSystemVolume.value = volume
elements.dropdownSystemVolumeValue.textContent = `${volume}%`
}
+ if (elements.dropdownSystemVolume) paintRangeFill(elements.dropdownSystemVolume)
}
/**
@@ -1987,12 +2106,11 @@ function setLayout(mode) {
// Update view mode button states in dropdown
elements.viewModeDual.classList.toggle('active', mode === 'dual')
elements.viewModeSingle.classList.toggle('active', mode === 'single')
+ elements.viewModeDual.setAttribute('aria-pressed', mode === 'dual' ? 'true' : 'false')
+ elements.viewModeSingle.setAttribute('aria-pressed', mode === 'single' ? 'true' : 'false')
- // Update dropdown input list visibility
- updateDropdownVisibility()
-
- // Update volume controls to show correct inputs
- renderDropdownVolumeControls()
+ // The pickers follow the layout: one per half in dual view, one in single.
+ renderDropdownInputLists()
// Cancel any pending hide from a previous switch. Without this, switching
// single -> dual -> single inside the animation window leaves the earlier timer
@@ -2045,6 +2163,9 @@ function setCenterGap(gap) {
state.settings.centerGap = gap
elements.centerDivider.style.width = `${gap}px`
elements.settingsCenterGapValue.textContent = `${gap}px`
+ // The pickers' gap column matches the divider.
+ document.documentElement.style.setProperty('--center-gap', `${gap}px`)
+ renderLayoutDiagram()
debouncedSaveSettings()
}
@@ -2053,9 +2174,49 @@ function setBorderWidth(width) {
state.settings.borderWidth = width
document.documentElement.style.setProperty('--border-width', `${width}px`)
elements.settingsBorderWidthValue.textContent = `${width}px`
+ renderLayoutDiagram()
debouncedSaveSettings()
}
+/**
+ * The flat drawing of the wall in Settings > Layout: side borders, both halves
+ * with what is on them, and the centre gap, to scale against the real window
+ * width. Single view draws one block, since the gap does not apply there.
+ */
+function renderLayoutDiagram() {
+ const box = elements.layoutDiagram
+ if (!box) return
+ box.innerHTML = ''
+ const total = Math.max(1, window.innerWidth || 6000)
+ const border = state.borderWidth || 0
+ const gap = state.layoutMode === 'dual' ? (state.centerGap || 0) : 0
+ const halves = state.layoutMode === 'dual' ? 2 : 1
+ const half = Math.max(1, (total - border * 2 - gap) / halves)
+
+ const block = (className, grow, text) => {
+ const el = document.createElement('div')
+ el.className = className
+ el.style.flex = `${grow} 1 0`
+ if (text) {
+ el.textContent = text
+ el.title = text
+ }
+ return el
+ }
+ const nameOf = (id) => {
+ const d = state.devices.find(x => x.deviceId === id)
+ return d ? getInputName(id, d.label || 'Input') : 'Nothing'
+ }
+
+ if (border > 0) box.appendChild(block('layout-border', border))
+ box.appendChild(block('layout-half', half, nameOf(state.leftDeviceId)))
+ if (halves === 2) {
+ if (gap > 0) box.appendChild(block('layout-gap', gap))
+ box.appendChild(block('layout-half', half, nameOf(state.rightDeviceId)))
+ }
+ if (border > 0) box.appendChild(block('layout-border', border))
+}
+
// =============================================================================
// Input Selection
// =============================================================================
@@ -2082,36 +2243,23 @@ async function selectInput(index, side = 'both') {
renderDropdownInputLists()
}
+let inputNameTimer = null
+
function showInputName(name) {
+ // The open pickers already name what is on each half, in the same spot the
+ // operator is looking; a toast underneath them would only be hidden by them.
+ if (state.dropdownOpen) return
elements.inputNameText.textContent = name
elements.inputNameOverlay.classList.remove('hidden')
-
- // Remove after animation
- setTimeout(() => {
+
+ // One timer, restarted: rapid switching used to leave earlier timers to hide
+ // the newest name early.
+ clearTimeout(inputNameTimer)
+ inputNameTimer = setTimeout(() => {
elements.inputNameOverlay.classList.add('hidden')
}, 2000)
}
-// =============================================================================
-// UI Rendering
-// =============================================================================
-
-/**
- * Update dropdown visibility based on layout mode
- */
-function updateDropdownVisibility() {
- if (state.layoutMode === 'dual') {
- elements.dualColumns.classList.remove('hidden')
- elements.singleInputList.classList.add('hidden')
- } else {
- elements.dualColumns.classList.add('hidden')
- elements.singleInputList.classList.remove('hidden')
- }
-}
-
-/**
- * Render the simplified dropdown input lists (enabled inputs only)
- */
// =============================================================================
// Art-Net frame observer
// =============================================================================
@@ -2540,17 +2688,28 @@ function closeLegend () {
function updateLegendState () {
elements.legendPanel.classList.toggle('touch-open', state.legendOpen)
elements.legendTrigger.classList.toggle('touch-open', state.legendOpen)
+ elements.legendTrigger.setAttribute('aria-expanded', state.legendOpen ? 'true' : 'false')
+}
+
+// Line icons for the view toggle: two panes side by side, and one pane.
+const VIEW_ICONS = {
+ 'layout-dual': '
',
+ 'layout-single': '
',
}
/**
- * Label the dropdown's view-mode buttons and render the Settings table.
+ * Label the capsule's view-mode buttons with their icon, a name for screen
+ * readers, and the key chip.
*
- * Both read from the same SHORTCUTS list as the keydown handler (#258), so the
- * three cannot disagree. Called once at startup; nothing here changes with
- * state, unlike the input rows which re-render on every device change.
+ * Reads from the same SHORTCUTS list as the keydown handler (#258), so the two
+ * cannot disagree. Called once at startup; nothing here changes with state,
+ * unlike the input tiles which re-render on every device change.
+ *
+ * The Settings shortcut table this used to fill is gone: the legend at the
+ * bottom edge shows the same rows, and a second copy in Settings was only one
+ * more place to scroll past.
*/
function renderShortcutHints() {
- // Dual / Single buttons: the two dropdown controls that have a key.
const buttons = [
{ el: elements.viewModeDual, id: 'layout-dual', text: 'Dual' },
{ el: elements.viewModeSingle, id: 'layout-single', text: 'Single' },
@@ -2558,168 +2717,213 @@ function renderShortcutHints() {
for (const { el, id, text } of buttons) {
if (!el) continue
const shortcut = SHORTCUTS.find(sc => sc.id === id)
- el.textContent = text
+ el.innerHTML = VIEW_ICONS[id] || ''
+ const name = document.createElement('span')
+ name.className = 'sr-only'
+ name.textContent = text
+ el.appendChild(name)
+ el.title = shortcut ? `${text} view (${shortcut.chips.join(' / ')})` : `${text} view`
if (shortcut) el.appendChild(shortcutChips(shortcut))
}
+}
- // Settings table: every shortcut, in list order.
- const table = elements.shortcutsTable
- if (!table) return
- table.innerHTML = ''
- for (const shortcut of SHORTCUTS) {
- const row = document.createElement('tr')
-
- const keyCell = document.createElement('td')
- keyCell.appendChild(shortcutChips(shortcut, 'shortcut-keys'))
- row.appendChild(keyCell)
-
- const labelCell = document.createElement('td')
- labelCell.textContent = shortcut.label
- if (shortcut.note) {
- const note = document.createElement('span')
- note.className = 'shortcut-note'
- note.textContent = ` (${shortcut.note})`
- labelCell.appendChild(note)
- }
- row.appendChild(labelCell)
+/** Display name for a device, falling back to its hardware label. */
+function deviceName(deviceId, index) {
+ const device = state.devices.find(d => d.deviceId === deviceId)
+ if (!device) return null
+ return getInputName(deviceId, device.label || `Input ${index + 1}`)
+}
- table.appendChild(row)
+/**
+ * What the open dropdown shows over the wall, given the view and Multi-view.
+ *
+ * - dual + Multi-view: a picker per half, each setting its own half, no key chips
+ * (1-4 set BOTH halves, so a chip would document a different action).
+ * - dual without Multi-view: one picker across both halves that sets both, with
+ * chips -- a tap and the key now do the same thing.
+ * - single: one picker over the whole wall, with chips. With Multi-view on it
+ * sets the visible (left) half, which in single view is the whole picture.
+ *
+ * Exported for tests.
+ */
+function pickerPlan() {
+ if (state.layoutMode !== 'dual') {
+ return [{ side: isMultiView() ? 'left' : 'both', label: 'Whole wall', showKeys: true }]
}
+ if (!isMultiView()) {
+ return [{ side: 'both', label: 'Both halves', showKeys: true }]
+ }
+ return [
+ { side: 'left', label: 'Left half', showKeys: false },
+ { side: 'right', label: 'Right half', showKeys: false },
+ ]
}
+/**
+ * Render the pickers over the wall (dropdown 2b).
+ *
+ * Kept under its old name: every path that changes what the dropdown should show
+ * -- device change, input switch by click or key, rename, enable/disable, layout,
+ * Multi-view -- already calls this, which is what keeps it from going stale.
+ */
function renderDropdownInputLists() {
- // Clear lists
- elements.leftInputList.innerHTML = ''
- elements.rightInputList.innerHTML = ''
- elements.singleInputList.innerHTML = ''
-
- // Filter to enabled devices only
- const enabledDevices = state.devices.filter(d => isInputEnabled(d.deviceId))
+ const host = elements.wallPickers
+ if (!host) return
+ host.innerHTML = ''
- enabledDevices.forEach((device, index) => {
- const customName = getInputName(device.deviceId, device.label || `Input ${index + 1}`)
- const isLeftActive = device.deviceId === state.leftDeviceId
- const isRightActive = device.deviceId === state.rightDeviceId
-
- // The number key that selects this input, or null past the fourth (#258).
- // Null rather than an invented key: the wall can have more capture devices
- // than there are number keys, and labelling a fifth row '5' would promise a
- // binding that does not exist.
- const key = inputKeyFor(index)
-
- // Name via textContent, never innerHTML: this string is a device label from
- // capture hardware or a user-entered rename.
- //
- // `showKey` is false for the dual columns, and that is a correctness point
- // rather than a layout one. `1`-`4` call selectInput() with the default
- // side='both', which sets BOTH feeds; clicking a row in the Left column calls
- // selectInputForSide(id, 'left') and sets one. A chip on a per-side row would
- // therefore document a key that does something different from the control it
- // sits next to. In single view only one feed is shown, so setting both and
- // setting that one are the same thing to the operator, and the chip is honest.
- //
- // It also fixes the fit: a dual column is ~173px against the single list's
- // ~358px, and a name plus a chip left about 98px for the name.
- const buildOption = (className, isActive, side, showKey) => {
- const option = document.createElement('div')
- option.className = `${className}${isActive ? ' selected' : ''}`
- // The sweep finds rows by this rather than by held references (#242): rows
- // are rebuilt on any device or selection change, which can happen while a
- // sweep is still running.
- option.dataset.deviceId = device.deviceId
-
- // Snapshot tile. Rendered even before a still exists, so the row does not
- // change height when one lands -- an empty tile is the placeholder.
- const thumb = document.createElement('div')
- thumb.className = 'input-thumb'
- const cached = inputThumbnails.get(device.deviceId)
- if (cached) {
- thumb.style.backgroundImage = `url("${cached}")`
- thumb.classList.add('has-thumb')
- }
- option.appendChild(thumb)
+ const plan = pickerPlan()
+ host.style.gridTemplateColumns = plan.length === 2
+ ? 'minmax(0, 1fr) var(--center-gap, 60px) minmax(0, 1fr)'
+ : 'minmax(0, 1fr)'
- const label = document.createElement('span')
- label.className = 'input-option-label'
+ const enabledDevices = state.devices.filter(d => isInputEnabled(d.deviceId))
- const name = document.createElement('span')
- name.className = 'input-option-name'
- name.textContent = customName
- label.appendChild(name)
- if (key && showKey) label.appendChild(shortcutKeyChip(key))
- option.appendChild(label)
+ plan.forEach(({ side, label, showKeys }, i) => {
+ if (i === 1) {
+ const gap = document.createElement('div')
+ gap.className = 'wall-picker-gap'
+ host.appendChild(gap)
+ }
+ host.appendChild(buildPicker(side, label, showKeys, enabledDevices))
+ })
+ paintAllRangeFills(host)
+}
+
+function buildPicker(side, labelText, showKeys, enabledDevices) {
+ const currentId = side === 'right' ? state.rightDeviceId : state.leftDeviceId
+
+ const picker = document.createElement('section')
+ picker.className = 'wall-picker'
+ picker.dataset.side = side
+ picker.setAttribute('aria-label', labelText)
+
+ const label = document.createElement('div')
+ label.className = 'mono-label picker-label'
+ label.textContent = labelText
+ picker.appendChild(label)
+
+ // Name via textContent, never innerHTML: device labels come from capture
+ // hardware and names from a user rename.
+ const current = document.createElement('div')
+ current.className = 'picker-current'
+ const currentName = currentId ? deviceName(currentId, 0) : null
+ current.textContent = currentName || 'Nothing selected'
+ picker.appendChild(current)
+
+ const strip = document.createElement('div')
+ strip.className = 'picker-strip'
+ strip.setAttribute('role', 'group')
+ strip.setAttribute('aria-label', `Inputs for ${labelText.toLowerCase()}`)
+
+ if (enabledDevices.length === 0) {
+ const empty = document.createElement('p')
+ empty.className = 'picker-empty'
+ empty.textContent = state.devices.length === 0
+ ? 'No capture inputs found. Connect one, or check the camera permission.'
+ : 'Every input is switched off. Turn one on in Settings > Inputs.'
+ strip.appendChild(empty)
+ }
- option.addEventListener('click', () => {
- selectInputForSide(device.deviceId, side)
- })
- return option
+ enabledDevices.forEach((device, index) => {
+ // The number key that selects this input, or null past the fourth (#258):
+ // the wall can have more inputs than there are number keys.
+ const key = showKeys ? inputKeyFor(index) : null
+ const isActive = device.deviceId === currentId
+
+ const option = document.createElement('button')
+ option.type = 'button'
+ option.className = `input-option${isActive ? ' selected' : ''}`
+ option.setAttribute('aria-pressed', isActive ? 'true' : 'false')
+ // The thumbnail sweep finds tiles by this rather than by held references
+ // (#242): tiles are rebuilt on any device or selection change, which can
+ // happen while a sweep is still running.
+ option.dataset.deviceId = device.deviceId
+
+ // Snapshot tile. Rendered before a still exists, so nothing moves when one
+ // lands -- the empty tile is the placeholder.
+ const thumb = document.createElement('div')
+ thumb.className = 'input-thumb'
+ const cached = inputThumbnails.get(device.deviceId)
+ if (cached) {
+ thumb.style.backgroundImage = `url("${cached}")`
+ thumb.classList.add('has-thumb')
}
+ option.appendChild(thumb)
- elements.leftInputList.appendChild(
- buildOption('input-option', isLeftActive, 'left', false))
- elements.rightInputList.appendChild(
- buildOption('input-option', isRightActive, 'right', false))
- elements.singleInputList.appendChild(
- buildOption('single-input-option', isLeftActive, 'left', true))
+ const row = document.createElement('span')
+ row.className = 'input-option-label'
+ const name = document.createElement('span')
+ name.className = 'input-option-name'
+ name.textContent = getInputName(device.deviceId, device.label || `Input ${index + 1}`)
+ row.appendChild(name)
+ if (key) row.appendChild(shortcutKeyChip(key))
+ option.appendChild(row)
+
+ option.addEventListener('click', () => {
+ if (side === 'both') selectInputForBoth(device.deviceId)
+ else selectInputForSide(device.deviceId, side)
+ })
+ strip.appendChild(option)
})
+ picker.appendChild(strip)
+
+ picker.appendChild(buildPickerVolume(side))
+ return picker
}
/**
- * Render the dropdown volume controls for active inputs
+ * Volume for the half this picker controls. Volume belongs to a side, not to an
+ * input; a picker for both halves sets both.
*/
-function renderDropdownVolumeControls() {
- elements.dropdownInputVolumes.innerHTML = ''
-
- // Determine which inputs to show based on layout mode
- const inputsToShow = []
-
- if (state.layoutMode === 'dual') {
- // In dual mode, show both inputs (or one if same)
- if (state.leftDeviceId) {
- inputsToShow.push({ side: 'left', deviceId: state.leftDeviceId })
- }
- if (state.rightDeviceId && state.rightDeviceId !== state.leftDeviceId) {
- inputsToShow.push({ side: 'right', deviceId: state.rightDeviceId })
- }
- } else {
- // In single mode, show only the active input
- if (state.leftDeviceId) {
- inputsToShow.push({ side: 'left', deviceId: state.leftDeviceId })
- }
- }
+function buildPickerVolume(side) {
+ const row = document.createElement('label')
+ row.className = 'picker-volume'
- // Create volume row for each input
- inputsToShow.forEach(({ side, deviceId }) => {
- const device = state.devices.find(d => d.deviceId === deviceId)
- if (!device) return
-
- const name = getInputName(deviceId, device.label || 'Input')
- const volume = side === 'left' ? state.leftVolume : state.rightVolume
- const volumePercent = Math.round(volume * 100)
+ const text = document.createElement('span')
+ text.className = 'mono-label'
+ text.textContent = 'Volume'
+ row.appendChild(text)
+
+ const volume = side === 'right' ? state.rightVolume : state.leftVolume
+ const percent = Math.round(volume * 100)
+ const slider = document.createElement('input')
+ slider.type = 'range'
+ slider.min = '0'
+ slider.max = '100'
+ slider.value = String(percent)
+ slider.dataset.side = side
+ slider.setAttribute('aria-label', side === 'both' ? 'Volume, both halves'
+ : side === 'right' ? 'Volume, right half' : 'Volume')
+ row.appendChild(slider)
+
+ const value = document.createElement('span')
+ value.className = 'mono-value volume-value'
+ value.textContent = `${percent}%`
+ row.appendChild(value)
+
+ slider.addEventListener('input', (e) => {
+ const vol = parseInt(e.target.value, 10) / 100
+ if (side === 'left' || side === 'both') setLeftVolume(vol)
+ if (side === 'right' || side === 'both') setRightVolume(vol)
+ value.textContent = `${e.target.value}%`
+ })
+ return row
+}
- const row = document.createElement('div')
- row.className = 'volume-row'
- row.innerHTML = `
-
${name}
-
-
${volumePercent}%
- `
-
- // Volume slider event
- const slider = row.querySelector('input[type="range"]')
- const valueSpan = row.querySelector('.volume-value')
- slider.addEventListener('input', (e) => {
- const vol = parseInt(e.target.value) / 100
- if (side === 'left') {
- setLeftVolume(vol)
- } else {
- setRightVolume(vol)
- }
- valueSpan.textContent = `${e.target.value}%`
- })
+/** Volume lives in the pickers now; kept so existing callers still refresh it. */
+function renderDropdownVolumeControls() {
+ renderDropdownInputLists()
+}
- elements.dropdownInputVolumes.appendChild(row)
- })
+/**
+ * Put one input on both halves, as the number keys do, but with the same fade a
+ * click on one half gets.
+ */
+async function selectInputForBoth(deviceId) {
+ await Promise.all([
+ selectInputForSide(deviceId, 'left'),
+ selectInputForSide(deviceId, 'right'),
+ ])
}
/**
@@ -2759,77 +2963,156 @@ async function selectInputForSide(deviceId, side) {
showInputName(name)
saveSettings()
renderDropdownInputLists()
- renderDropdownVolumeControls()
+ // What is on the wall changed: the Settings rows' capture buttons and the
+ // layout drawing both depend on it.
+ if (isSettingsOpen()) {
+ renderSettingsInputList()
+ renderLayoutDiagram()
+ }
}
/**
- * Render the settings modal input list
+ * Which half a device is on screen in, for capturing its no-signal screen: the
+ * left half first (it is the visible one in single view), the right half only in
+ * dual view. Null when the device is not on the wall.
+ */
+function visibleSideOf(deviceId) {
+ if (state.leftDeviceId === deviceId) return 'left'
+ if (state.layoutMode === 'dual' && state.rightDeviceId === deviceId) return 'right'
+ return null
+}
+
+/**
+ * Render the Settings > Inputs table: Key, On, Name, Startup, No-signal.
+ *
+ * Built from elements throughout: names come from capture hardware or a rename,
+ * and this used to interpolate them into innerHTML, so a `"` or `<` in one broke
+ * the row.
*/
function renderSettingsInputList() {
- elements.settingsInputList.innerHTML = ''
+ const list = elements.settingsInputList
+ if (!list) return
+ list.innerHTML = ''
renderOrphanedReferences()
+ if (state.devices.length === 0) {
+ const empty = document.createElement('p')
+ empty.className = 'settings-description'
+ empty.textContent = 'No capture inputs found. Connect one, or check that the app ' +
+ 'is allowed to use the camera.'
+ list.appendChild(empty)
+ updateSettingsNav()
+ return
+ }
+
+ const head = document.createElement('div')
+ head.className = 'input-table-head'
+ for (const text of ['Key', 'On', 'Name', 'Startup', 'No-signal']) {
+ const cell = document.createElement('span')
+ cell.textContent = text
+ head.appendChild(cell)
+ }
+ list.appendChild(head)
+
+ const enabledIds = state.devices.filter(d => isInputEnabled(d.deviceId)).map(d => d.deviceId)
+
state.devices.forEach((device, index) => {
- const isEnabled = isInputEnabled(device.deviceId)
- const customName = getInputName(device.deviceId, device.label || `Input ${index + 1}`)
- const isDefault = state.defaultInputId === device.deviceId
+ const id = device.deviceId
+ const isEnabled = isInputEnabled(id)
+ const hardwareLabel = device.label || `Input ${index + 1}`
+ const customName = state.settings.inputs[id]?.name || ''
+ const displayName = customName || hardwareLabel
+ const isDefault = state.defaultInputId === id
+ const refs = getReferenceScreenshots(id)
const row = document.createElement('div')
- row.className = 'input-name-row'
-
- // Reference state for this input. A device with none is silently inert:
- // checkNoSignalFromSource returns false unconditionally and logs nothing,
- // so detection appears broken when it has simply never been configured.
- // Surfacing the count is the point (#161).
- const refs = getReferenceScreenshots(device.deviceId)
-
- row.innerHTML = `
-
${index + 1}
-
-
-
Default
-
- ${refs.length === 0 ? '⚠ No reference' : `${refs.length} reference${refs.length === 1 ? '' : 's'}`}
-
- `
-
- // Toggle switch event
- const toggleSwitch = row.querySelector('.toggle-switch')
- toggleSwitch.addEventListener('click', () => {
- toggleInputEnabled(device.deviceId)
- })
-
- // Name field events
- const nameField = row.querySelector('.input-name-field')
- nameField.addEventListener('change', (e) => {
- setInputName(device.deviceId, e.target.value)
- renderDropdownInputLists() // Update dropdown with new name
- })
+ row.className = `input-name-row${isEnabled ? '' : ' disabled'}`
+ row.dataset.deviceId = id
+
+ // Key: the dropdown / number-key numbering, which counts enabled inputs only.
+ const key = document.createElement('span')
+ key.className = 'input-key'
+ key.textContent = inputKeyLabel(isEnabled ? enabledIds.indexOf(id) : -1)
+ row.appendChild(key)
+
+ // On
+ const toggle = document.createElement('button')
+ toggle.type = 'button'
+ toggle.className = 'toggle-switch'
+ toggle.setAttribute('role', 'switch')
+ toggle.setAttribute('aria-label', `Use ${displayName}`)
+ toggle.dataset.deviceId = id
+ setSwitch(toggle, isEnabled)
+ toggle.addEventListener('click', () => toggleInputEnabled(id))
+ row.appendChild(toggle)
+
+ // Name: empty means the hardware label, which is the placeholder.
+ const nameField = document.createElement('input')
+ nameField.type = 'text'
+ nameField.className = 'input-name-field'
+ nameField.value = customName
+ nameField.placeholder = hardwareLabel
+ nameField.spellcheck = false
+ nameField.dataset.deviceId = id
+ nameField.setAttribute('aria-label', `Name for ${hardwareLabel}`)
+ nameField.addEventListener('change', (e) => setInputName(id, e.target.value))
nameField.addEventListener('keydown', (e) => {
- e.stopPropagation()
- if (e.key === 'Enter') {
- e.target.blur()
- }
- })
-
- // Default button event
- const defaultBtn = row.querySelector('.default-btn')
- defaultBtn.addEventListener('click', () => {
- setDefaultInput(device.deviceId)
+ if (e.key === 'Enter') e.target.blur()
})
+ row.appendChild(nameField)
+
+ // Startup (was "Default"): a radio pill. Clicking the chosen one clears it.
+ const startup = document.createElement('button')
+ startup.type = 'button'
+ startup.className = 'pill-btn pill-btn-small startup-pill default-btn'
+ startup.setAttribute('role', 'radio')
+ startup.setAttribute('aria-checked', isDefault ? 'true' : 'false')
+ startup.title = isDefault
+ ? 'Shown at startup. Click to clear.'
+ : 'Show this input when the app starts'
+ startup.textContent = 'Startup'
+ startup.dataset.deviceId = id
+ startup.addEventListener('click', () => setDefaultInput(isDefault ? null : id))
+ row.appendChild(startup)
+
+ // No-signal badge, which expands the panel under the row.
+ const expanded = state.expandedRefPanels.has(id)
+ const badge = document.createElement('button')
+ badge.type = 'button'
+ badge.className = `pill-btn pill-btn-small ref-toggle-btn${refs.length === 0 ? ' warn' : ''}`
+ badge.setAttribute('aria-expanded', expanded ? 'true' : 'false')
+ badge.textContent = refs.length === 0
+ ? 'No reference'
+ : `${refs.length} reference${refs.length === 1 ? '' : 's'}`
+ badge.dataset.deviceId = id
+ row.appendChild(badge)
+
+ // A disabled input that is still on screen: it stays until the half switches.
+ if (!isEnabled && (state.leftDeviceId === id || state.rightDeviceId === id)) {
+ const note = document.createElement('span')
+ note.className = 'input-row-note'
+ note.textContent = 'Still on the wall until you switch'
+ row.appendChild(note)
+ }
- elements.settingsInputList.appendChild(row)
+ list.appendChild(row)
- // Expandable reference panel: thumbnails with per-reference delete.
const panel = document.createElement('div')
- panel.className = 'ref-panel hidden'
- renderReferencePanel(panel, device.deviceId)
- elements.settingsInputList.appendChild(panel)
-
- row.querySelector('.ref-toggle-btn').addEventListener('click', () => {
- panel.classList.toggle('hidden')
+ panel.className = `ref-panel${expanded ? '' : ' hidden'}`
+ panel.dataset.deviceId = id
+ renderReferencePanel(panel, id)
+ list.appendChild(panel)
+
+ badge.addEventListener('click', () => {
+ if (state.expandedRefPanels.has(id)) state.expandedRefPanels.delete(id)
+ else state.expandedRefPanels.add(id)
+ const open = state.expandedRefPanels.has(id)
+ panel.classList.toggle('hidden', !open)
+ badge.setAttribute('aria-expanded', open ? 'true' : 'false')
})
})
+
+ updateSettingsNav()
}
/**
@@ -2851,16 +3134,19 @@ function renderOrphanedReferences() {
const total = orphans.reduce((n, o) => n + o.count, 0)
const box = document.createElement('div')
box.className = 'ref-orphans'
-
- const text = document.createElement('span')
- text.textContent =
- `${total} reference${total === 1 ? '' : 's'} belong to ` +
- `${orphans.length} device${orphans.length === 1 ? '' : 's'} that ${orphans.length === 1 ? 'is' : 'are'} ` +
- 'not connected. If a device changed its id, re-capture its no-signal screen.'
+ box.setAttribute('role', 'note')
+
+ const text = document.createElement('div')
+ text.className = 'ref-orphans-text'
+ text.appendChild(document.createTextNode(orphanBannerText(total, orphans.length)))
+ const caveat = document.createElement('span')
+ caveat.textContent = 'Discarding also removes references of cards that are only unplugged right now.'
+ text.appendChild(caveat)
box.appendChild(text)
const btn = document.createElement('button')
- btn.className = 'ref-prune-btn'
+ btn.type = 'button'
+ btn.className = 'pill-btn pill-btn-small ref-prune-btn'
btn.textContent = 'Discard them'
btn.addEventListener('click', async () => {
pruneOrphanedReferences(state.devices.map((d) => d.deviceId))
@@ -2874,10 +3160,11 @@ function renderOrphanedReferences() {
}
/**
- * Fill a device's reference panel with thumbnails and delete buttons.
+ * Fill a device's no-signal panel: its reference thumbnails with a delete each,
+ * the notes, and the capture button.
*
- * References are stored as ImageData at the detect resolution, so a thumbnail
- * is just that data drawn to a small canvas -- no separate copy is kept.
+ * References are stored as ImageData at the detect resolution, so a thumbnail is
+ * just that data drawn to a small canvas -- no separate copy is kept.
*
* @param {HTMLElement} panel
* @param {string} deviceId
@@ -2891,55 +3178,91 @@ function renderReferencePanel(panel, deviceId) {
hint.className = 'ref-empty'
hint.textContent =
'No reference captured. Detection cannot fire for this input until one ' +
- 'exists: show its no-signal screen, then use Capture above.'
+ 'exists: show its no-signal screen, then capture it below.'
panel.appendChild(hint)
- return
- }
-
- const grid = document.createElement('div')
- grid.className = 'ref-grid'
-
- refs.forEach((ref, i) => {
- const item = document.createElement('div')
- item.className = 'ref-item'
+ } else {
+ const grid = document.createElement('div')
+ grid.className = 'ref-grid'
+
+ refs.forEach((ref, i) => {
+ const item = document.createElement('div')
+ item.className = 'ref-item'
+
+ const canvas = document.createElement('canvas')
+ canvas.width = ref.width
+ canvas.height = ref.height
+ canvas.className = 'ref-thumb'
+ canvas.getContext('2d')?.putImageData(ref, 0, 0)
+ canvas.title = `${ref.width}x${ref.height}`
+
+ const del = document.createElement('button')
+ del.type = 'button'
+ del.className = 'ref-delete'
+ del.textContent = '×'
+ del.title = 'Delete this reference'
+ del.setAttribute('aria-label', `Delete reference ${i + 1}`)
+ del.addEventListener('click', async () => {
+ removeReferenceScreenshot(deviceId, i)
+ state.settings.noSignalReferences = serializeReferences()
+ await saveSettings()
+ // Re-render the whole list: the row's count badge changes too.
+ renderSettingsInputList()
+ })
- const canvas = document.createElement('canvas')
- canvas.width = ref.width
- canvas.height = ref.height
- canvas.className = 'ref-thumb'
- canvas.getContext('2d').putImageData(ref, 0, 0)
- canvas.title = `${ref.width}x${ref.height}`
-
- const del = document.createElement('button')
- del.className = 'ref-delete'
- del.textContent = '×'
- del.title = 'Delete this reference'
- del.addEventListener('click', async () => {
- removeReferenceScreenshot(deviceId, i)
- state.settings.noSignalReferences = serializeReferences()
- await saveSettings()
- // Re-render the whole list: the row's count badge changes too.
- renderSettingsInputList()
+ item.appendChild(canvas)
+ item.appendChild(del)
+ grid.appendChild(item)
})
+ panel.appendChild(grid)
+
+ const note = document.createElement('p')
+ note.className = 'ref-note'
+ note.textContent = refs.length === 1
+ ? 'A frame matching this reference counts as no signal. Capture more if this ' +
+ 'card shows other no-signal screens (unsupported mode, HDCP error).'
+ : `A frame matching any of these ${refs.length} counts as no signal.`
+ panel.appendChild(note)
+ }
- item.appendChild(canvas)
- item.appendChild(del)
- grid.appendChild(item)
- })
+ // Capture: from whichever half this device is on. References belong to the
+ // device, so the half is only where the picture comes from.
+ const side = visibleSideOf(deviceId)
+ const row = document.createElement('div')
+ row.className = 'ref-capture-row'
- panel.appendChild(grid)
+ const btn = document.createElement('button')
+ btn.type = 'button'
+ btn.className = 'pill-btn primary ref-capture-btn'
+ btn.textContent = side === 'right' ? 'Capture from right half' : 'Capture from left half'
+ btn.disabled = !side
+ btn.addEventListener('click', async () => {
+ btn.disabled = true
+ const result = await captureNoSignalForSide(side)
+ state.captureResults.set(deviceId, result)
+ // Re-render: the badge count, the thumbnails and the message all change.
+ renderSettingsInputList()
+ })
+ row.appendChild(btn)
- const note = document.createElement('p')
- note.className = 'ref-note'
- note.textContent = refs.length === 1
- ? 'A frame matching this reference counts as no signal. Capture more if this ' +
- 'card shows other no-signal screens (unsupported mode, HDCP error).'
- : `A frame matching any of these ${refs.length} counts as no signal.`
- panel.appendChild(note)
+ const msg = document.createElement('span')
+ msg.className = 'ref-capture-msg'
+ msg.setAttribute('role', 'status')
+ if (!side) {
+ msg.textContent = 'Put this input on the wall first'
+ } else {
+ const last = state.captureResults.get(deviceId)
+ if (last) {
+ msg.textContent = last.message
+ msg.classList.add(last.ok ? 'ok' : 'error')
+ }
+ }
+ row.appendChild(msg)
+ panel.appendChild(row)
}
/**
- * Set the default input for startup
+ * Set or clear the input shown at startup. Null clears it, which the old
+ * "Default" button could not do.
*/
function setDefaultInput(deviceId) {
state.defaultInputId = deviceId
@@ -2952,27 +3275,143 @@ function setDefaultInput(deviceId) {
* Show the settings modal
*/
function showSettingsModal() {
+ // Settings is opened from the dropdown's capsule; the pickers would only sit
+ // behind it.
+ closeDropdown()
elements.settingsModal.classList.remove('hidden')
+ updateMultiViewUI()
renderSettingsInputList()
+ renderLayoutDiagram()
updateRemoteKeyboardUI()
updatePresenterDebugUI()
+ updateArtnetUI()
+ showSettingsSection(state.settingsSection)
+ paintAllRangeFills(elements.settingsModal)
+ startSettingsStatusRefresh()
+ showCursor()
+}
+
+function isSettingsOpen() {
+ return Boolean(elements.settingsModal) &&
+ !elements.settingsModal.classList.contains('hidden')
+}
+
+/** Show one pane of the Settings modal and mark its nav item current. */
+function showSettingsSection(name) {
+ state.settingsSection = name
+ const modal = elements.settingsModal
+ if (!modal) return
+ for (const section of modal.querySelectorAll('.settings-section')) {
+ section.classList.toggle('hidden', section.dataset.section !== name)
+ }
+ for (const item of modal.querySelectorAll('.settings-nav-item')) {
+ const current = item.dataset.section === name
+ item.classList.toggle('active', current)
+ if (current) item.setAttribute('aria-current', 'page')
+ else item.removeAttribute('aria-current')
+ }
+ modal.querySelector('.settings-body')?.scrollTo?.(0, 0)
+}
+
+/** A status dot, optionally followed by text. */
+function statusDot(tone, text) {
+ const frag = document.createDocumentFragment()
+ const dot = document.createElement('span')
+ dot.className = `status-dot${tone === 'ok' ? ' ok' : tone === 'warn' ? ' warn' : ''}`
+ frag.appendChild(dot)
+ if (text) frag.appendChild(document.createTextNode(text))
+ return frag
+}
+
+/**
+ * Nav-item status: Inputs counts enabled inputs with no no-signal reference;
+ * Remote keyboard and Art-Net say On/Off, green when configured and orange when
+ * switched on but incomplete or failing.
+ */
+function updateSettingsNav() {
+ const missing = state.devices
+ .filter(d => isInputEnabled(d.deviceId))
+ .filter(d => getReferenceScreenshots(d.deviceId).length === 0).length
+ if (elements.settingsNavInputs) {
+ elements.settingsNavInputs.innerHTML = ''
+ if (missing > 0) {
+ const count = document.createElement('span')
+ count.className = 'nav-count'
+ count.textContent = String(missing)
+ count.title = `${missing} input${missing === 1 ? '' : 's'} without a no-signal reference`
+ elements.settingsNavInputs.appendChild(count)
+ }
+ }
+
+ const rk = remoteKeyboardStatus(remoteKeyboardInfo())
+ if (elements.settingsNavRemote) {
+ elements.settingsNavRemote.innerHTML = ''
+ elements.settingsNavRemote.appendChild(statusDot(rk.tone, rk.nav))
+ }
+ const an = artnetStatus(state.settings, getArtnetSync()?.getStatus?.() ?? null)
+ if (elements.settingsNavArtnet) {
+ elements.settingsNavArtnet.innerHTML = ''
+ elements.settingsNavArtnet.appendChild(statusDot(an.tone, an.nav))
+ }
+}
+
+function remoteKeyboardInfo() {
+ return {
+ enabled: state.remoteKeyboardEnabled,
+ host: state.remoteKeyboardHost,
+ apiKey: state.remoteKeyboardApiKey,
+ last: state.remoteKeyboardLast,
+ }
+}
+
+function renderStatusLine(el, status) {
+ if (!el) return
+ el.innerHTML = ''
+ if (status.line) el.appendChild(statusDot(status.tone, status.line))
+}
+
+/**
+ * Keep the status lines current while Settings is open: Art-Net sends happen in
+ * the background, so its line cannot be updated only on input events.
+ */
+let settingsStatusTimer = null
+
+function refreshSettingsStatus() {
+ if (!isSettingsOpen()) {
+ stopSettingsStatusRefresh()
+ return
+ }
+ renderStatusLine(elements.remoteKeyboardStatus, remoteKeyboardStatus(remoteKeyboardInfo()))
+ renderStatusLine(elements.artnetStatus,
+ artnetStatus(state.settings, getArtnetSync()?.getStatus?.() ?? null))
+ updateSettingsNav()
+}
+
+function startSettingsStatusRefresh() {
+ refreshSettingsStatus()
+ if (settingsStatusTimer === null) {
+ settingsStatusTimer = setInterval(refreshSettingsStatus, 1000)
+ }
+}
+
+function stopSettingsStatusRefresh() {
+ if (settingsStatusTimer !== null) {
+ clearInterval(settingsStatusTimer)
+ settingsStatusTimer = null
+ }
}
/**
* Update the remote keyboard settings UI to reflect current state
*/
function updateRemoteKeyboardUI() {
- // Update toggle
- if (state.remoteKeyboardEnabled) {
- elements.remoteKeyboardToggle.classList.add('active')
- elements.remoteKeyboardFields.classList.remove('hidden')
- } else {
- elements.remoteKeyboardToggle.classList.remove('active')
- elements.remoteKeyboardFields.classList.add('hidden')
- }
+ setSwitch(elements.remoteKeyboardToggle, state.remoteKeyboardEnabled)
+ elements.remoteKeyboardFields.classList.toggle('hidden', !state.remoteKeyboardEnabled)
// Update input fields
elements.remoteKeyboardHost.value = state.remoteKeyboardHost || ''
elements.remoteKeyboardApiKey.value = state.remoteKeyboardApiKey || ''
+ renderStatusLine(elements.remoteKeyboardStatus, remoteKeyboardStatus(remoteKeyboardInfo()))
+ updateSettingsNav()
}
/**
@@ -2982,6 +3421,8 @@ function toggleRemoteKeyboard() {
state.remoteKeyboardEnabled = !state.remoteKeyboardEnabled
state.settings.remoteKeyboardEnabled = state.remoteKeyboardEnabled
updateRemoteKeyboardUI()
+ // The debug overlay belongs to this feature; it must not outlive it.
+ updatePresenterDebugUI()
saveSettings()
}
@@ -2991,6 +3432,8 @@ function toggleRemoteKeyboard() {
function setRemoteKeyboardHost(host) {
state.remoteKeyboardHost = host
state.settings.remoteKeyboardHost = host
+ state.remoteKeyboardLast = null
+ refreshSettingsStatus()
debouncedSaveSettings()
}
@@ -3000,9 +3443,22 @@ function setRemoteKeyboardHost(host) {
function setRemoteKeyboardApiKey(apiKey) {
state.remoteKeyboardApiKey = apiKey
state.settings.remoteKeyboardApiKey = apiKey
+ state.remoteKeyboardLast = null
+ refreshSettingsStatus()
debouncedSaveSettings()
}
+/** Show or hide the API key: it is typed blind otherwise. */
+function toggleApiKeyReveal() {
+ const field = elements.remoteKeyboardApiKey
+ const btn = elements.remoteKeyboardApiKeyReveal
+ if (!field || !btn) return
+ const reveal = field.type === 'password'
+ field.type = reveal ? 'text' : 'password'
+ btn.textContent = reveal ? 'Hide' : 'Show'
+ btn.setAttribute('aria-pressed', reveal ? 'true' : 'false')
+}
+
/**
* Art-Net settings panel (#59, spot mode in 3.0.0).
*
@@ -3049,9 +3505,14 @@ function renderArtnetSaverList() {
const configured = [...new Set(Object.values(mapping))]
.filter(v => typeof v === 'string' && (v.startsWith('scene:') || v.startsWith('effect:')))
+ let customised = 0
for (const saver of listScreensavers()) {
const row = document.createElement('div')
row.className = 'artnet-saver-row'
+ if (Object.hasOwn(mapping, saver)) {
+ customised++
+ row.classList.add('customised')
+ }
const name = document.createElement('span')
name.className = 'artnet-saver-name'
@@ -3081,11 +3542,18 @@ function renderArtnetSaverList() {
select.appendChild(opt)
}
select.value = current
+ select.setAttribute('aria-label', `Lighting for ${saver}`)
select.addEventListener('change', (e) => setArtnetSaverMode(saver, e.target.value))
row.appendChild(select)
list.appendChild(row)
}
+
+ if (elements.artnetSaverCount) {
+ elements.artnetSaverCount.textContent = customised === 0
+ ? 'all default'
+ : `${customised} customised`
+ }
}
/**
@@ -3105,12 +3573,15 @@ function setArtnetSaverMode(saver, mode) {
else mapping[saver] = mode
state.settings.artnetSceneBySaver = mapping
saveSettings()
+ renderArtnetSaverList()
}
function updateArtnetUI() {
const enabled = Boolean(state.settings.artnetEnabled)
- elements.artnetToggle.classList.toggle('active', enabled)
+ setSwitch(elements.artnetToggle, enabled)
elements.artnetFields.classList.toggle('hidden', !enabled)
+ renderStatusLine(elements.artnetStatus,
+ artnetStatus(state.settings, getArtnetSync()?.getStatus?.() ?? null))
elements.artnetUrl.value = state.settings.artnetUrl || ''
elements.artnetReleaseScene.value = state.settings.artnetReleaseScene || ''
@@ -3142,6 +3613,8 @@ function updateArtnetUI() {
elements.artnetSpotDepthValue.textContent = `${Math.round(spotDepth * 100)}%`
// Depth only means anything for the spot; every other target ignores it.
elements.artnetSpotDepthRow.classList.toggle('hidden', target !== 'effect:spot')
+ paintRangeFill(elements.artnetMaxBrightness)
+ paintRangeFill(elements.artnetSpotDepth)
renderArtnetSaverList()
}
@@ -3161,10 +3634,16 @@ function toggleArtnet() {
function setArtnetUrl(url) {
state.settings.artnetUrl = url.trim()
+ refreshSettingsStatus()
debouncedSaveSettings()
- refreshArtnetCatalogue()
+ // Asks the relay for its scene names. Debounced: this used to fire one status
+ // request per keystroke while a URL was being typed.
+ clearTimeout(artnetCatalogueTimer)
+ artnetCatalogueTimer = setTimeout(refreshArtnetCatalogue, 600)
}
+let artnetCatalogueTimer = null
+
function setArtnetTarget(target) {
state.settings.artnetTarget = target
// A target change switches between the flat-colour and effect paths. Release
@@ -3195,13 +3674,11 @@ function setArtnetReleaseScene(scene) {
* Update the presenter debug overlay visibility to reflect current state
*/
function updatePresenterDebugUI() {
- if (state.presenterDebugEnabled) {
- elements.presenterDebugToggle.classList.add('active')
- elements.presenterDebugOverlay.classList.remove('hidden')
- } else {
- elements.presenterDebugToggle.classList.remove('active')
- elements.presenterDebugOverlay.classList.add('hidden')
- }
+ setSwitch(elements.presenterDebugToggle, state.presenterDebugEnabled)
+ // Only while Remote Keyboard is on: its switch is hidden with the other remote
+ // keyboard fields, so an overlay left on would otherwise have no visible way off.
+ const show = state.presenterDebugEnabled && state.remoteKeyboardEnabled
+ elements.presenterDebugOverlay.classList.toggle('hidden', !show)
}
/**
@@ -3239,6 +3716,7 @@ function logPresenterDebug(message, status = '') {
*/
function hideSettingsModal() {
elements.settingsModal.classList.add('hidden')
+ stopSettingsStatusRefresh()
}
/**
@@ -3249,40 +3727,112 @@ function closeAllPanels() {
hideSettingsModal()
}
+/** True when Esc has something to close before it may touch fullscreen. */
+function anyPanelOpen() {
+ return isSettingsOpen() || state.dropdownOpen || state.legendOpen
+}
+
/**
- * Toggle dropdown open/close state
+ * Toggle dropdown open/close state (the touch path).
*/
function toggleDropdown() {
- state.dropdownOpen = !state.dropdownOpen
+ if (state.dropdownOpen) closeDropdown()
+ else openDropdown()
+}
+
+/**
+ * Open the controls over the wall.
+ *
+ * Both open paths land here -- hover on the trigger and a tap on it. Opening
+ * starts a thumbnail sweep (#242) and the system-volume poll, and keeps the
+ * cursor up for as long as the controls are showing.
+ */
+function openDropdown() {
+ if (isSettingsOpen()) return
+ const wasOpen = state.dropdownOpen
+ state.dropdownOpen = true
updateDropdownState()
- // Touch path into the dropdown (#242). Only on open; closing needs no sweep.
- if (state.dropdownOpen) {
- refreshInputThumbnails()
- startVolumePolling()
- } else {
- stopVolumePolling()
- }
+ showCursor()
+ armDropdownIdleClose()
+ if (wasOpen) return
+ closeLegend()
+ renderDropdownInputLists()
+ refreshInputThumbnails()
+ startVolumePolling()
}
/**
* Close the dropdown
*/
function closeDropdown() {
+ clearTimeout(dropdownIdleTimer)
+ dropdownIdleTimer = null
state.dropdownOpen = false
updateDropdownState()
stopVolumePolling()
}
+/**
+ * Close the controls when the pointer goes below them.
+ *
+ * "Below" is past the lowest picker row -- the volume slider, not the
+ * thumbnails, or reaching for the slider would close everything -- plus a
+ * margin, so overshooting the slider a little does not. The same line serves a
+ * tap on touch, which has no hover to leave with. Returns whether it closed.
+ */
+const CLOSE_BELOW_MARGIN_PX = 48
+
+function dropdownCloseLine() {
+ let bottom = 0
+ for (const row of elements.wallPickers?.querySelectorAll('.picker-strip, .picker-volume') ?? []) {
+ bottom = Math.max(bottom, row.getBoundingClientRect().bottom)
+ }
+ // Nothing measured (not laid out yet): never treat a position as below.
+ return bottom > 0 ? bottom + CLOSE_BELOW_MARGIN_PX : null
+}
+
+function closeDropdownIfBelow(clientY) {
+ if (!state.dropdownOpen) return false
+ const line = dropdownCloseLine()
+ if (line === null || clientY <= line) return false
+ closeDropdown()
+ return true
+}
+
+/**
+ * The controls cover the picture, so they must not stay up on an unattended
+ * wall: close them after a stretch with no pointer, touch or key activity.
+ */
+const DROPDOWN_IDLE_MS = 30_000
+let dropdownIdleTimer = null
+
+function armDropdownIdleClose() {
+ clearTimeout(dropdownIdleTimer)
+ dropdownIdleTimer = setTimeout(() => {
+ dropdownIdleTimer = null
+ if (state.dropdownOpen) closeDropdown()
+ }, DROPDOWN_IDLE_MS)
+}
+
/**
* Update dropdown CSS classes based on state
*/
function updateDropdownState() {
- elements.dropdownPanel.classList.toggle('touch-open', state.dropdownOpen)
- elements.dropdownTrigger.classList.toggle('touch-open', state.dropdownOpen)
+ const open = state.dropdownOpen
+ elements.dropdownPanel.classList.toggle('touch-open', open)
+ elements.dropdownTrigger.classList.toggle('touch-open', open)
+ elements.dropdownPanel.setAttribute('aria-hidden', open ? 'false' : 'true')
+ elements.dropdownTrigger.setAttribute('aria-expanded', open ? 'true' : 'false')
}
/**
- * Capture no-signal reference for a specific side
+ * Capture a no-signal reference from what one half of the wall is showing.
+ *
+ * Returns what happened rather than only logging it, so the Settings row can say
+ * so: a failed capture used to be silent.
+ *
+ * @param {'left'|'right'} side
+ * @returns {Promise<{ok: boolean, message: string}>}
*/
async function captureNoSignalForSide(side) {
const video = side === 'left' ? elements.leftVideo : elements.rightVideo
@@ -3290,12 +3840,12 @@ async function captureNoSignalForSide(side) {
if (!deviceId) {
console.error(`[Setup] No device selected for ${side}`)
- return
+ return { ok: false, message: 'Nothing is selected on that half.' }
}
if (!video || !video.srcObject || video.readyState < 2) {
console.error(`[Setup] Video feed not ready for ${side}`)
- return
+ return { ok: false, message: 'Capture failed: the feed is not showing a picture yet.' }
}
// Capture screenshot
@@ -3304,7 +3854,7 @@ async function captureNoSignalForSide(side) {
if (!imageData) {
console.error(`[Setup] Failed to capture screenshot for ${side}`)
- return
+ return { ok: false, message: 'Capture failed: could not read a frame from the feed.' }
}
// Save reference
@@ -3318,16 +3868,7 @@ async function captureNoSignalForSide(side) {
await saveSettings()
console.log(`[Setup] No-signal reference captured for ${side} (${deviceId})`)
-
- // Visual feedback - briefly change button text
- const btn = side === 'left' ? elements.captureLeftBtn : elements.captureRightBtn
- const originalText = btn.textContent
- btn.textContent = '✓ Captured!'
- btn.disabled = true
- setTimeout(() => {
- btn.textContent = originalText
- btn.disabled = false
- }, 1500)
+ return { ok: true, message: 'Captured.' }
}
// =============================================================================
@@ -3338,7 +3879,13 @@ function showCursor() {
document.body.classList.add('cursor-visible')
clearTimeout(state.cursorTimeout)
- state.cursorTimeout = setTimeout(() => {
+ state.cursorTimeout = setTimeout(function hideCursor() {
+ // Never hide it under open controls or Settings: both are used with the
+ // pointer, and an invisible one over a form is just lost.
+ if (state.dropdownOpen || isSettingsOpen()) {
+ state.cursorTimeout = setTimeout(hideCursor, state.cursorHideDelay)
+ return
+ }
document.body.classList.remove('cursor-visible')
}, state.cursorHideDelay)
}
@@ -3402,6 +3949,7 @@ function resetShakeDetection() {
*/
function handleMouseMove(event) {
showCursor()
+ if (state.dropdownOpen && !closeDropdownIfBelow(event.clientY)) armDropdownIdleClose()
// Only check for shake when screensaver is active
if (isScreensaverRunning()) {
@@ -3423,43 +3971,39 @@ function handleMouseMove(event) {
*/
async function sendRemoteKeypress(direction) {
if (!state.remoteKeyboardEnabled) return
- if (!state.remoteKeyboardHost || !state.remoteKeyboardApiKey) {
+ if (!state.remoteKeyboardHost?.trim() || !state.remoteKeyboardApiKey) {
logPresenterDebug(`${direction}: skipped (host/API key not set)`, 'error')
+ refreshSettingsStatus()
return
}
- const host = state.remoteKeyboardHost.trim()
- // Add http:// prefix and .local suffix if needed
- let url = host
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
- url = `http://${url}`
- }
- if (!url.includes('.') && !url.includes(':')) {
- url = `${url}.local`
- }
- url = `${url}/${direction}`
-
+ // Builds http://
[.local]/; see remoteKeyUrl for the .local rule.
+ const url = remoteKeyUrl(state.remoteKeyboardHost, direction)
logPresenterDebug(`${direction} → ${url}`)
+ // Through the main process: from the file:// renderer this request needs a CORS
+ // preflight that the presenter-PC device does not answer. See remote-key-send.
+ let result
try {
- const response = await fetch(url, {
- method: 'GET',
- headers: {
- 'X-API-Key': state.remoteKeyboardApiKey
- }
- })
-
- if (!response.ok) {
- console.warn(`[Remote Keyboard] Request failed: ${response.status}`)
- logPresenterDebug(`${direction}: failed (HTTP ${response.status})`, 'error')
- } else {
- console.log(`[Remote Keyboard] Sent: ${direction}`)
- logPresenterDebug(`${direction}: sent (HTTP ${response.status})`, 'ok')
- }
+ result = window.electronAPI?.remoteKeySend
+ ? await window.electronAPI.remoteKeySend({ url, apiKey: state.remoteKeyboardApiKey })
+ : { ok: false, error: 'not available outside the app' }
} catch (error) {
- console.warn(`[Remote Keyboard] Error: ${error.message}`)
- logPresenterDebug(`${direction}: error (${error.message})`, 'error')
+ result = { ok: false, error: error.message }
+ }
+
+ state.remoteKeyboardLast = { ...result, direction, at: Date.now() }
+ if (result.ok) {
+ console.log(`[Remote Keyboard] Sent: ${direction}`)
+ logPresenterDebug(`${direction}: sent (HTTP ${result.status})`, 'ok')
+ } else if (result.status) {
+ console.warn(`[Remote Keyboard] Request failed: ${result.status}`)
+ logPresenterDebug(`${direction}: failed (HTTP ${result.status})`, 'error')
+ } else {
+ console.warn(`[Remote Keyboard] Error: ${result.error}`)
+ logPresenterDebug(`${direction}: error (${result.error})`, 'error')
}
+ refreshSettingsStatus()
}
// =============================================================================
@@ -3504,13 +4048,19 @@ const SHORTCUT_ACTIONS = {
'fullscreen': () => window.electronAPI.toggleFullscreen(),
+ // One thing per press. With something open, Esc closes it and stops there:
+ // closing Settings on the wall used to drop it out of fullscreen in the same
+ // press. Only with nothing open does it unfreeze and leave fullscreen.
'escape': () => {
- closeAllPanels()
- closeLegend()
+ if (anyPanelOpen()) {
+ closeAllPanels()
+ closeLegend()
+ return
+ }
if (state.frozen) {
toggleFreeze() // Unfreeze on escape
}
- window.electronAPI.isFullscreen().then(isFs => {
+ window.electronAPI?.isFullscreen?.().then(isFs => {
if (isFs) window.electronAPI.toggleFullscreen()
})
},
@@ -3521,9 +4071,24 @@ const SHORTCUT_ACTIONS = {
'remote-forward': () => sendRemoteKeypress('right'),
}
+/**
+ * True while the key belongs to a form control rather than to the app.
+ *
+ * Not only text fields: with a focused (the Art-Net target, say) Q used
+ * to quit the app and D/S switched the layout behind the modal. Escape still
+ * gets through, so Esc closes Settings from inside a field.
+ */
+function isTypingTarget(target) {
+ if (!target) return false
+ const tag = target.tagName
+ return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' ||
+ target.isContentEditable === true
+}
+
function handleKeyDown(event) {
- // Don't handle if typing in an input
- if (event.target.tagName === 'INPUT') return
+ if (state.dropdownOpen) armDropdownIdleClose()
+ // Don't handle if typing in a form control, except Escape.
+ if (isTypingTarget(event.target) && event.key !== 'Escape') return
console.log(`[Key] pressed: "${event.key}" (code: ${event.code})`)
@@ -3553,29 +4118,31 @@ function setupEventListeners() {
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyDown)
- // Keep cursor visible when hovering dropdown
- elements.dropdownTrigger.addEventListener('mouseenter', () => {
- document.body.classList.add('cursor-visible')
- clearTimeout(state.cursorTimeout)
- // Hover path into the dropdown (#242). The panel's visibility is pure CSS
- // (#dropdown-trigger:hover + #dropdown-panel), so this listener -- which
- // already existed for the cursor -- is the only JS signal that it opened.
- refreshInputThumbnails()
- startVolumePolling()
- })
+ // Every range slider keeps its blue fill in step while it is dragged.
+ document.addEventListener('input', (e) => {
+ if (e.target?.type === 'range') paintRangeFill(e.target)
+ }, true)
- elements.dropdownPanel.addEventListener('mouseenter', () => {
- document.body.classList.add('cursor-visible')
- clearTimeout(state.cursorTimeout)
- startVolumePolling()
+ // Hover path into the controls: the tab at the top edge. They close when the
+ // pointer goes below the pickers, with Esc, or after a quiet stretch.
+ elements.dropdownTrigger.addEventListener('mouseenter', () => openDropdown())
+
+ // Keyboard path to the same tab.
+ elements.dropdownTrigger.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ e.stopPropagation()
+ toggleDropdown()
+ }
})
- elements.dropdownPanel.addEventListener('mouseleave', () => {
- showCursor() // Reset cursor timeout
- stopVolumePolling()
+ // A tap below the pickers closes them (touch has no hover to leave with); any
+ // other touch on the open controls counts as activity.
+ elements.dropdownPanel.addEventListener('pointerdown', (e) => {
+ if (!closeDropdownIfBelow(e.clientY)) armDropdownIdleClose()
})
- // View mode buttons in dropdown
+ // View mode buttons in the capsule
elements.viewModeDual.addEventListener('click', () => setLayout('dual'))
elements.viewModeSingle.addEventListener('click', () => setLayout('single'))
@@ -3584,6 +4151,13 @@ function setupEventListeners() {
showSettingsModal()
})
+ // Settings side nav
+ for (const item of elements.settingsModal.querySelectorAll('.settings-nav-item')) {
+ item.addEventListener('click', () => showSettingsSection(item.dataset.section))
+ }
+
+ elements.multiViewToggle?.addEventListener('click', () => setMultiView(!isMultiView()))
+
// Close settings modal
elements.closeSettingsBtn.addEventListener('click', () => {
hideSettingsModal()
@@ -3605,15 +4179,6 @@ function setupEventListeners() {
setBorderWidth(parseInt(e.target.value))
})
- // No-signal capture buttons
- elements.captureLeftBtn.addEventListener('click', () => {
- captureNoSignalForSide('left')
- })
-
- elements.captureRightBtn.addEventListener('click', () => {
- captureNoSignalForSide('right')
- })
-
// Remote keyboard settings
elements.remoteKeyboardToggle.addEventListener('click', toggleRemoteKeyboard)
@@ -3621,18 +4186,12 @@ function setupEventListeners() {
setRemoteKeyboardHost(e.target.value)
})
- elements.remoteKeyboardHost.addEventListener('keydown', (e) => {
- e.stopPropagation() // Prevent keyboard shortcuts while typing
- })
+ elements.remoteKeyboardApiKeyReveal?.addEventListener('click', toggleApiKeyReveal)
elements.remoteKeyboardApiKey.addEventListener('input', (e) => {
setRemoteKeyboardApiKey(e.target.value)
})
- elements.remoteKeyboardApiKey.addEventListener('keydown', (e) => {
- e.stopPropagation() // Prevent keyboard shortcuts while typing
- })
-
// Presenter tool debug overlay toggle
elements.presenterDebugToggle.addEventListener('click', togglePresenterDebug)
@@ -3659,12 +4218,9 @@ function setupEventListeners() {
setArtnetReleaseScene(e.target.value)
})
- // A URL or scene name contains letters that are themselves shortcuts -- D and
- // S switch layout, Q quits. Without this, typing a hostname reconfigures the
- // app underneath you.
- for (const field of [elements.artnetUrl, elements.artnetReleaseScene]) {
- field.addEventListener('keydown', (e) => { e.stopPropagation() })
- }
+ // Typing in these fields must not trigger shortcuts (D and S switch layout, Q
+ // quits). handleKeyDown ignores every form control, so nothing per-field is
+ // needed here any more -- and Escape still reaches it to close Settings.
// System volume slider in dropdown
elements.dropdownSystemVolume.addEventListener('input', async (e) => {
@@ -3689,6 +4245,15 @@ function setupEventListeners() {
showCursor()
})
+ // Keyboard path to the legend tab, like the dropdown's.
+ elements.legendTrigger.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ e.stopPropagation()
+ toggleLegend()
+ }
+ })
+
elements.legendTrigger.addEventListener('touchstart', (e) => {
e.preventDefault()
toggleLegend()
@@ -3703,24 +4268,14 @@ function setupEventListeners() {
}
}, { passive: true })
- // Touch support for dropdown
+ // Touch support for the controls. They cover the wall, so there is no
+ // "outside" to tap: a tap below the pickers closes them instead.
elements.dropdownTrigger.addEventListener('touchstart', (e) => {
e.preventDefault() // Prevent mouse events from firing
toggleDropdown()
showCursor()
}, { passive: false })
- // Close dropdown when tapping outside
- document.addEventListener('touchstart', (e) => {
- if (state.dropdownOpen) {
- const isInsideDropdown = elements.dropdownPanel.contains(e.target) ||
- elements.dropdownTrigger.contains(e.target)
- if (!isInsideDropdown) {
- closeDropdown()
- }
- }
- }, { passive: true })
-
// Device changes (when plugging/unplugging devices)
navigator.mediaDevices.addEventListener('devicechange', async () => {
console.log('Device change detected')
@@ -4365,6 +4920,11 @@ async function openInitialStreams(layoutMode) {
}
}
+ // Without Multi-view the halves always show the same input.
+ if (!isMultiView() && state.leftDeviceId) {
+ state.rightDeviceId = state.leftDeviceId
+ }
+
// Always start left stream
await startVideoStream(state.leftDeviceId, elements.leftVideo, 'left')
@@ -4616,5 +5176,26 @@ export {
toggleArtnet,
setArtnetSaverMode,
setArtnetTarget,
- setArtnetSpotDepth
+ setArtnetSpotDepth,
+ // Dropdown 2b, Multi-view and the rebuilt Settings panel.
+ pickerPlan,
+ openDropdown,
+ closeDropdown,
+ closeDropdownIfBelow,
+ toggleDropdown,
+ setMultiView,
+ isMultiView,
+ showSettingsModal,
+ hideSettingsModal,
+ isSettingsOpen,
+ showSettingsSection,
+ renderSettingsInputList,
+ renderLayoutDiagram,
+ setDefaultInput,
+ volumeLevel,
+ captureNoSignalForSide,
+ sendRemoteKeypress,
+ selectInputForSide,
+ selectInputForBoth,
+ setupEventListeners
}
diff --git a/input_viewer_electron/src/renderer/settings-status.js b/input_viewer_electron/src/renderer/settings-status.js
new file mode 100644
index 0000000..9aa2e0c
--- /dev/null
+++ b/input_viewer_electron/src/renderer/settings-status.js
@@ -0,0 +1,117 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2025-2026 Schuberg Philis / Lab271
+/**
+ * What the Settings panel says about itself: the nav-item status, the status
+ * lines under Remote keyboard and Art-Net, and the Key column of the Inputs
+ * table.
+ *
+ * Pure and DOM-free, so it is unit tested in the node environment. Before this
+ * existed neither integration showed anything in the UI: a wrong hostname or a
+ * dead relay was only visible in the log file.
+ */
+
+/**
+ * The URL a remote-keyboard press is sent to.
+ *
+ * A bare name like `space_keyboard` gets `.local` (mDNS). The check runs on the
+ * host as typed, BEFORE the scheme is added: the old code prefixed `http://`
+ * first, after which the "no dot, no colon" test could never pass and the suffix
+ * was never applied.
+ *
+ * @param {string} host as entered in Settings
+ * @param {'left'|'right'} direction
+ * @returns {string|null} null when there is no host
+ */
+export function remoteKeyUrl (host, direction) {
+ const h = String(host ?? '').trim()
+ if (!h) return null
+ const [, scheme, hostPart, rest] = h.match(/^(https?:\/\/)?([^/]*)(.*)$/i)
+ let name = hostPart
+ if (!name.includes('.') && !name.includes(':') && name.toLowerCase() !== 'localhost') {
+ name = `${name}.local`
+ }
+ return `${scheme || 'http://'}${name}${rest.replace(/\/+$/, '')}/${direction}`
+}
+
+/**
+ * Key column: the number key that selects this input, '·' for an enabled input
+ * past the fourth (there is no key for it), '–' for a disabled one.
+ *
+ * @param {number} enabledIndex position among ENABLED devices, or -1 if disabled
+ */
+export function inputKeyLabel (enabledIndex) {
+ if (enabledIndex < 0) return '–'
+ return enabledIndex < 4 ? String(enabledIndex + 1) : '·'
+}
+
+/**
+ * Remote keyboard status, for the nav dot and the line under the fields.
+ *
+ * @param {{enabled: boolean, host: string, apiKey: string,
+ * last: null|{ok: boolean, direction: string, status?: number,
+ * error?: string, at: number}}} rk
+ * @returns {{nav: string, tone: 'ok'|'warn'|'off', line: string}}
+ */
+export function remoteKeyboardStatus (rk) {
+ if (!rk.enabled) return { nav: 'Off', tone: 'off', line: '' }
+ if (!rk.host?.trim() || !rk.apiKey) {
+ return { nav: 'On', tone: 'warn', line: 'Set a hostname and an API key to send presses.' }
+ }
+ const last = rk.last
+ if (!last) return { nav: 'On', tone: 'ok', line: 'Ready. Nothing sent yet.' }
+ const when = clock(last.at)
+ if (last.ok) {
+ return { nav: 'On', tone: 'ok', line: `Sent ${last.direction} at ${when} (HTTP ${last.status}).` }
+ }
+ const why = last.status ? `HTTP ${last.status}` : (last.error || 'no response')
+ return { nav: 'On', tone: 'warn', line: `Last press failed at ${when}: ${why}.` }
+}
+
+/** True for an http(s) URL with a host. */
+export function isRelayUrl (url) {
+ try {
+ const u = new URL(String(url))
+ return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.hostname
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Art-Net status, from settings and the client's getStatus().
+ *
+ * @param {{artnetEnabled?: boolean, artnetUrl?: string}} settings
+ * @param {null|{sent: number, failures: number, lastError: string|null}} status
+ * @returns {{nav: string, tone: 'ok'|'warn'|'off', line: string}}
+ */
+export function artnetStatus (settings, status) {
+ if (!settings.artnetEnabled) return { nav: 'Off', tone: 'off', line: '' }
+ const url = settings.artnetUrl?.trim()
+ if (!url) return { nav: 'On', tone: 'warn', line: 'Not configured: enter the relay URL. Nothing is sent until you do.' }
+ if (!isRelayUrl(url)) return { nav: 'On', tone: 'warn', line: 'That is not an http(s) URL. Nothing is sent.' }
+ if (status?.failures > 0 && status.lastError) {
+ return { nav: 'On', tone: 'warn', line: `Last send failed: ${status.lastError}.` }
+ }
+ if (status?.sent > 0) {
+ return { nav: 'On', tone: 'ok', line: `Sending. ${status.sent} update${status.sent === 1 ? '' : 's'} so far.` }
+ }
+ return { nav: 'On', tone: 'ok', line: 'Ready. The lights follow the next screensaver.' }
+}
+
+/**
+ * The orphaned-reference banner, with the grammar right for one or several.
+ *
+ * @param {number} total references
+ * @param {number} devices devices they belong to
+ */
+export function orphanBannerText (total, devices) {
+ return `${total} reference${total === 1 ? ' belongs' : 's belong'} to ` +
+ `${devices} device${devices === 1 ? '' : 's'} that ${devices === 1 ? 'is' : 'are'} ` +
+ 'not connected. If a device changed its id, re-capture its no-signal screen.'
+}
+
+function clock (ms) {
+ const d = new Date(ms)
+ const p = (n) => String(n).padStart(2, '0')
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
+}
diff --git a/input_viewer_electron/src/renderer/styles.css b/input_viewer_electron/src/renderer/styles.css
index 9c9daf5..b9cee58 100644
--- a/input_viewer_electron/src/renderer/styles.css
+++ b/input_viewer_electron/src/renderer/styles.css
@@ -3,14 +3,37 @@
============================================================================= */
:root {
+ /* Visual language from the dropdown 2b / Settings handoff. System fonts only:
+ the handoff's brand face is licensed for internal use, and this repo and its
+ installers are public. */
+ --surface: rgba(14, 24, 34, 0.97);
+ --hairline: rgba(255, 255, 255, 0.16);
+ --well: #020C17;
+ --raised: #1B242E;
+ --sbp-blue: #1E80ED;
+ --turquoise: #1EE8ED;
+ --signal-green: #0DEBA5;
+ --signal-orange: #FF7000;
+ --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, Menlo, monospace;
+ --radius-pill: 999px;
+ /* Three rounded corners and one square one. */
+ --radius-container: 16px 16px 16px 2px;
+ --motion-ms: 200ms;
+ --ease: cubic-bezier(.2, 0, .2, 1);
+ --motion: var(--motion-ms) var(--ease);
+
--bg-color: #000000;
--text-color: #ffffff;
- --text-muted: rgba(255, 255, 255, 0.6);
- --accent-color: #0066ff;
- --panel-bg: rgba(30, 30, 30, 0.95);
- --panel-border: rgba(255, 255, 255, 0.1);
+ --text-muted: rgba(255, 255, 255, 0.64);
+ --accent-color: var(--sbp-blue);
+ --panel-bg: var(--surface);
+ --panel-border: var(--hairline);
--hover-color: rgba(255, 255, 255, 0.1);
--layout-gap: 20px;
+ /* Dropdown snapshot tiles: fixed, never the row width (#242). */
+ --input-thumb-w: 128px;
+ --input-thumb-h: 72px;
}
/* =============================================================================
@@ -24,7 +47,7 @@
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ font-family: var(--font-sans);
background-color: var(--bg-color);
color: var(--text-color);
overflow: hidden;
@@ -569,119 +592,159 @@ body.gpu-compositing #right-video {
}
/* =============================================================================
- Dropdown Panel
+ Shared controls (dropdown 2b and Settings)
============================================================================= */
-#dropdown-trigger {
- position: fixed;
- top: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 60px;
- height: 30px;
- display: flex;
+/* Buttons are pills; containers round three corners and square one
+ (--radius-container). Hit targets are at least 44px: this runs on a touch
+ wall as well as with a mouse. */
+.pill-btn {
+ display: inline-flex;
align-items: center;
justify-content: center;
- background-color: rgba(0, 0, 0, 0.5);
- border-radius: 0 0 12px 12px;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 20px;
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-pill);
+ background-color: var(--raised);
+ color: var(--text-color);
+ font: 700 14px/1 var(--font-sans);
+ letter-spacing: -0.02em;
cursor: pointer;
- opacity: 0;
- transition: opacity 0.3s ease;
- z-index: 100;
+ transition: background-color var(--motion), border-color var(--motion), opacity var(--motion);
-webkit-app-region: no-drag;
}
-body.cursor-visible #dropdown-trigger {
- opacity: 0.7;
+.pill-btn:hover {
+ border-color: rgba(255, 255, 255, 0.32);
}
-#dropdown-trigger:hover {
- opacity: 1;
+.pill-btn:disabled {
+ opacity: 0.45;
+ cursor: default;
}
-.dropdown-arrow {
- font-size: 14px;
- color: var(--text-color);
- transition: transform 0.3s ease;
+.pill-btn.primary {
+ background-color: var(--sbp-blue);
+ border-color: var(--sbp-blue);
}
-#dropdown-panel {
- position: fixed;
- top: 0;
- left: 50%;
- transform: translateX(-50%) translateY(-100%);
- background-color: var(--panel-bg);
- border: 1px solid var(--panel-border);
- border-top: none;
- border-radius: 0 0 12px 12px;
- padding: 0;
- z-index: 99;
- min-width: 320px;
- max-width: 400px;
- max-height: 80vh;
- overflow-y: auto;
- transition: transform 0.3s ease;
+.pill-btn-small {
+ min-height: 36px;
+ padding: 0 14px;
+ font-size: 12px;
+}
+
+.pill-btn svg,
+.icon-btn svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--text-color);
+ cursor: pointer;
+ transition: background-color var(--motion);
-webkit-app-region: no-drag;
}
-#dropdown-trigger:hover + #dropdown-panel,
-#dropdown-panel:hover {
- transform: translateX(-50%) translateY(0);
+.icon-btn:hover {
+ background-color: var(--raised);
}
-#dropdown-trigger:hover .dropdown-arrow,
-#dropdown-trigger:hover + #dropdown-panel ~ #dropdown-trigger .dropdown-arrow {
- transform: rotate(180deg);
+.mono-label {
+ font: 400 12px/1.2 var(--font-mono);
+ color: var(--turquoise);
+ letter-spacing: 0.02em;
}
-/* Keep cursor visible when panel is shown */
-body:has(#dropdown-trigger:hover),
-body:has(#dropdown-panel:hover) {
- cursor: default !important;
+.mono-value {
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
+ min-width: 44px;
+ text-align: right;
+ flex-shrink: 0;
}
-body:has(#dropdown-trigger:hover) *,
-body:has(#dropdown-panel:hover) * {
- cursor: default;
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
}
-/* Touch-friendly dropdown (state-based instead of hover) */
-@media (pointer: coarse) {
- #dropdown-trigger {
- width: 80px;
- height: 44px;
- }
+:focus-visible {
+ outline: 2px solid var(--sbp-blue);
+ outline-offset: 2px;
}
-/* State-based dropdown visibility for touch and programmatic control */
-#dropdown-trigger.touch-open + #dropdown-panel,
-#dropdown-panel.touch-open {
- transform: translateX(-50%) translateY(0);
+/* Range sliders: a 44px-tall hit area around a thin track. */
+input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ flex: 1;
+ min-width: 0;
+ height: 44px;
+ background: transparent;
+ cursor: pointer;
+ -webkit-app-region: no-drag;
+}
+
+/* Blue up to the thumb, grey after it. --fill is the value as a percentage,
+ written by paintRangeFill() in renderer.js: Chromium has no pseudo-element
+ for the filled part of a range track. */
+input[type="range"]::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: var(--radius-pill);
+ background: linear-gradient(to right,
+ var(--sbp-blue) 0 var(--fill, 0%),
+ rgba(255, 255, 255, 0.22) var(--fill, 0%) 100%);
}
-#dropdown-trigger.touch-open .dropdown-arrow {
- transform: rotate(180deg);
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 20px;
+ height: 20px;
+ margin-top: -8px;
+ border-radius: 50%;
+ background-color: var(--text-color);
+ box-shadow: 0 0 0 4px var(--sbp-blue);
}
-.dropdown-content {
- padding: 20px;
+kbd {
+ display: inline-block;
+ min-width: 22px;
+ padding: 3px 6px;
+ border-radius: 6px;
+ background-color: rgba(255, 255, 255, 0.12);
+ font: 400 12px/1.2 var(--font-mono);
+ text-align: center;
+ color: var(--text-color);
}
/* =============================================================================
- Shortcut legend (dropup)
+ Dropdown trigger
============================================================================= */
-/* The mirror of the dropdown above: same hover-to-reveal, same slide, same
- touch-open class, anchored to the bottom edge instead of the top. Rows come from
- shortcuts.js, so the legend cannot disagree with what the keys do (#258).
-
- Wider than the dropdown on purpose. The dropdown is a picker capped at 400px;
- this is a reference table, and the wall is 5:1 -- a grid that uses the width
- turns twelve rows into two or three short columns instead of one long list that
- runs off the bottom of a window. */
-#legend-trigger {
+#dropdown-trigger {
position: fixed;
- bottom: 0;
+ top: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
@@ -689,1173 +752,469 @@ body:has(#dropdown-panel:hover) * {
display: flex;
align-items: center;
justify-content: center;
- background-color: rgba(0, 0, 0, 0.5);
- /* Mirrored corners: rounded on the side away from the edge it hangs off. */
- border-radius: 12px 12px 0 0;
+ background-color: rgba(2, 12, 23, 0.6);
+ border-radius: 0 0 12px 12px;
cursor: pointer;
opacity: 0;
- transition: opacity 0.3s ease;
+ transition: opacity var(--motion);
z-index: 100;
-webkit-app-region: no-drag;
}
-body.cursor-visible #legend-trigger {
+body.cursor-visible #dropdown-trigger {
opacity: 0.7;
}
-.legend-arrow {
- font-size: 14px;
- color: var(--text-color);
- transition: transform 0.3s ease;
+#dropdown-trigger:hover {
+ opacity: 1;
}
-#legend-panel {
- position: fixed;
- bottom: 0;
- left: 50%;
- transform: translateX(-50%) translateY(100%);
- background-color: var(--panel-bg);
- border: 1px solid var(--panel-border);
- border-bottom: none;
- border-radius: 12px 12px 0 0;
- z-index: 99;
- /* An explicit width, not just a max.
-
- A fixed-position element is shrink-to-fit, so `max-width` caps it but never
- expands it -- and `grid-template-columns: repeat(auto-fit, ...)` needs a
- DEFINITE width to work out how many columns fit. With only a max the panel
- sized to its content's minimum: measured 467px wide and one column at a
- 1500px viewport, which is the long-list-running-off-the-bottom this grid
- exists to avoid. */
- width: min(1100px, 92vw);
- max-height: 60vh;
- overflow-y: auto;
- transition: transform 0.3s ease;
- -webkit-app-region: no-drag;
+/* The capsule takes the trigger's place while the controls are open. Qualified
+ with body so it outranks the cursor-visible rule above. */
+body #dropdown-trigger.touch-open {
+ opacity: 0;
+ pointer-events: none;
}
-#legend-trigger:hover + #legend-panel,
-#legend-panel:hover {
- transform: translateX(-50%) translateY(0);
+.dropdown-arrow {
+ font-size: 12px;
+ color: var(--text-color);
}
-#legend-trigger:hover .legend-arrow {
- transform: rotate(180deg);
+@media (pointer: coarse) {
+ #dropdown-trigger {
+ width: 80px;
+ height: 44px;
+ }
}
-/* State-based, for touch and programmatic control. Mirrors the dropdown. */
-#legend-trigger.touch-open + #legend-panel,
-#legend-panel.touch-open {
- transform: translateX(-50%) translateY(0);
-}
+/* =============================================================================
+ Dropdown 2b: controls over the wall
+ ============================================================================= */
-#legend-trigger.touch-open .legend-arrow {
- transform: rotate(180deg);
+/* Fades only: no slide. visibility is delayed on the way out so the fade plays
+ before the layer stops taking clicks. */
+#dropdown-panel {
+ position: fixed;
+ inset: 0;
+ z-index: 99;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity var(--motion), visibility 0s linear var(--motion-ms);
+ -webkit-app-region: no-drag;
}
-/* Keep the cursor up while the legend is open, same as the dropdown does. */
-body:has(#legend-trigger:hover),
-body:has(#legend-panel:hover) {
- cursor: default !important;
+#dropdown-panel.touch-open {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transition: opacity var(--motion), visibility 0s;
}
-body:has(#legend-trigger:hover) *,
-body:has(#legend-panel:hover) * {
+#dropdown-panel.touch-open,
+#dropdown-panel.touch-open * {
cursor: default;
}
-@media (pointer: coarse) {
- #legend-trigger {
- width: 80px;
- height: 44px;
- }
+/* Covers exactly the area the feeds cover: inside the side borders. Columns are
+ set from JS (renderDropdownInputLists): one picker per half with the centre
+ gap between them, or one picker across the whole width. */
+#wall-pickers {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: var(--border-width, 0px);
+ right: var(--border-width, 0px);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
}
-.legend-content {
- padding: 20px 24px;
+.wall-picker {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 14px;
+ min-width: 0;
+ /* Room above for the capsule. */
+ padding: 84px clamp(16px, 3vw, 48px) 24px;
+ background: linear-gradient(to bottom,
+ rgba(2, 12, 23, 0.94) 0%,
+ rgba(2, 12, 23, 0.82) 62%,
+ rgba(2, 12, 23, 0.2) 100%);
+ overflow: hidden;
}
-.legend-title {
- font-size: 11px;
- font-weight: 600;
- margin-bottom: 14px;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 0.5px;
+/* The centre-gap column between two pickers: the same scrim, nothing in it. */
+.wall-picker-gap {
+ background: linear-gradient(to bottom,
+ rgba(2, 12, 23, 0.94) 0%,
+ rgba(2, 12, 23, 0.82) 62%,
+ rgba(2, 12, 23, 0.2) 100%);
}
-/* auto-fit rather than a fixed column count: one rule covers the 6000px wall and
- a small dev window, wrapping to however many columns the width allows. */
-.legend-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 8px 24px;
+.picker-current {
+ max-width: 100%;
+ font: 800 22px/1.2 var(--font-sans);
+ letter-spacing: -0.03em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.legend-row {
+/* Scrolls sideways when the inputs don't fit (5+ on a laptop, say). */
+.picker-strip {
display: flex;
- /* Top-aligned, not centred: a label that wraps to two lines should keep its key
- chip beside the FIRST line rather than floating against the middle. */
- align-items: flex-start;
- gap: 10px;
- /* min-width:0 so a long label wraps inside its cell rather than widening the
- track -- the same grid trap that overflowed the dropdown columns. */
- min-width: 0;
+ gap: 12px;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 4px 4px 10px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
-/* Fixed width so the labels line up in a column instead of ragging against
- whatever the key chips happen to measure. */
-.legend-keys {
- flex-shrink: 0;
- width: 84px;
+.input-option {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ width: calc(var(--input-thumb-w) + 8px);
+ padding: 4px;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ color: var(--text-muted);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color var(--motion), color var(--motion);
}
-/* Wraps rather than truncates. Measured at 1100px wide: three of the twelve
- labels ellipsised, including both "Presentation: ... (if the remote keyboard is
- enabled)" rows -- so the caveat, which is the only reason those rows are
- confusing without it, was the part being cut. A legend that hides what a key
- does defeats itself; uneven row heights are the cheaper cost. */
-.legend-label {
- font-size: 12px;
+.input-option:hover {
+ background-color: rgba(255, 255, 255, 0.06);
color: var(--text-color);
- min-width: 0;
- line-height: 1.5;
}
-.legend-note {
- color: var(--text-muted);
+.input-option.selected {
+ color: var(--text-color);
}
-.dropdown-divider {
- height: 1px;
- background: var(--panel-border);
- margin: 16px 0;
+/* Fixed 128x72, never the row width (#242). */
+.input-thumb {
+ flex-shrink: 0;
+ width: var(--input-thumb-w);
+ height: var(--input-thumb-h);
+ border-radius: 6px;
+ background-color: var(--well);
+ background-size: cover;
+ background-position: center;
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ transition: outline-color var(--motion);
}
-/* =============================================================================
- Dropdown - Volume Controls
- ============================================================================= */
+.input-thumb.has-thumb {
+ background-color: #000;
+}
-.dropdown-volume-section {
- display: flex;
- flex-direction: column;
- gap: 10px;
+.input-option.selected .input-thumb {
+ outline-color: var(--sbp-blue);
}
-.volume-row {
+.input-option-label {
display: flex;
align-items: center;
- gap: 10px;
- padding: 6px 8px;
- border-radius: 6px;
- background: rgba(0, 0, 0, 0.15);
+ gap: 6px;
+ width: 100%;
+ min-width: 0;
}
-.volume-row .volume-label {
- font-size: 12px;
- width: 80px;
- flex-shrink: 0;
- color: var(--text-color);
+/* min-width: 0 or a long capture-card label holds the row open and pushes the
+ chip out instead of ellipsising (flex's min-width: auto). */
+.input-option-name {
+ flex: 1;
+ min-width: 0;
+ font: 700 13px/1.3 var(--font-sans);
+ letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-.volume-row input[type="range"] {
- flex: 1;
- height: 4px;
- accent-color: var(--accent-color);
+.picker-empty {
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
+.picker-volume {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: min(420px, 100%);
}
-.volume-row .volume-value {
- font-size: 11px;
+.picker-volume .mono-label {
color: var(--text-muted);
- width: 36px;
- text-align: right;
flex-shrink: 0;
}
-.volume-row.system-volume {
- margin-top: 4px;
- background: rgba(255, 255, 255, 0.05);
+/* Capsule: view toggle, output volume, settings. */
+.wall-capsule {
+ position: absolute;
+ top: 14px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 6px;
+ background-color: var(--surface);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-pill);
+ max-width: calc(100vw - 24px);
}
-.dropdown-section {
- margin-bottom: 20px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--panel-border);
-}
-
-.dropdown-section:last-of-type {
- margin-bottom: 0;
- padding-bottom: 0;
- border-bottom: none;
-}
-
-.dropdown-section h4 {
- margin: 0 0 12px;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-muted);
-}
-
-.dropdown-section table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.dropdown-section td {
- padding: 6px 0;
- font-size: 13px;
-}
-
-.dropdown-section td:first-child {
- width: 80px;
-}
-
-.dropdown-footer {
- display: flex;
- justify-content: space-between;
- font-size: 11px;
- color: var(--text-muted);
- padding-top: 12px;
-}
-
-/* Setting rows in dropdown */
-.setting-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 10px;
-}
-
-.setting-row:last-child {
- margin-bottom: 0;
-}
-
-.setting-row label {
- flex: 1;
- font-size: 13px;
- min-width: 80px;
-}
-
-.setting-row input[type="range"] {
- width: 80px;
- accent-color: var(--accent-color);
-}
-
-.setting-row span {
- width: 45px;
- font-size: 12px;
- color: var(--text-muted);
- text-align: right;
-}
-
-kbd {
- display: inline-block;
- padding: 4px 8px;
- background-color: rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- font-family: monospace;
- font-size: 12px;
-}
-
-/* =============================================================================
- Dropdown - View Mode Toggle
- ============================================================================= */
-
-.view-mode-row {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- margin-bottom: 16px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--panel-border);
+.view-toggle {
+ display: flex;
+ gap: 2px;
+ padding: 2px;
+ border-radius: var(--radius-pill);
+ background-color: var(--well);
}
.view-mode-btn {
- flex: 1;
- padding: 10px 16px;
- background: rgba(0, 0, 0, 0.2);
- border: 1px solid transparent;
- border-radius: 6px;
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.15s;
- text-align: center;
-}
-
-.view-mode-btn:hover {
- background: var(--hover-color);
-}
-
-.view-mode-btn.active {
- background: var(--accent-color);
- color: white;
-}
-
-/* =============================================================================
- Dropdown - Column Layout (Dual Mode)
- ============================================================================= */
-
-.column-layout {
- display: grid;
- /* minmax(0, 1fr), not 1fr. `1fr` is minmax(auto, 1fr) and the auto minimum is
- the item's min-content size, so any descendant that cannot wrap pins the
- track open. A `white-space: nowrap` device name did exactly that: the tracks
- computed to 339.758px each inside a 358px panel and the columns overflowed
- the dropdown by ~320px. Same trap as flex `min-width: auto`, one level up. */
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 12px;
- margin-bottom: 16px;
-}
-
-.column {
- background: rgba(0, 0, 0, 0.2);
- border-radius: 8px;
- padding: 12px;
-}
-
-.column-header {
- font-size: 11px;
- font-weight: 600;
- margin-bottom: 10px;
- text-align: center;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* =============================================================================
- Dropdown - Input Options
- ============================================================================= */
-
-.input-option {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 12px;
- border-radius: 6px;
- cursor: pointer;
- margin-bottom: 4px;
- transition: all 0.15s;
-}
-
-.input-option:last-child {
- margin-bottom: 0;
-}
-
-.input-option:hover {
- background-color: var(--hover-color);
-}
-
-.input-option.selected {
- background-color: var(--accent-color);
-}
-
-.input-option .num {
- font-size: 11px;
- font-weight: 600;
- opacity: 0.6;
- width: 16px;
-}
-
-.input-option.selected .num {
- opacity: 1;
-}
-
-.input-option .name {
- font-size: 13px;
-}
-
-/* =============================================================================
- Dropdown - Single Input List (Single Mode)
- ============================================================================= */
-
-.single-input-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 16px;
-}
-
-.single-input-option {
- display: flex;
+ display: inline-flex;
align-items: center;
- gap: 10px;
- padding: 12px 14px;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.single-input-option:hover {
- background-color: var(--hover-color);
-}
-
-.single-input-option.selected {
- background-color: var(--accent-color);
-}
-
-.single-input-option .num {
- font-size: 12px;
- font-weight: 600;
- opacity: 0.6;
- width: 18px;
-}
-
-.single-input-option.selected .num {
- opacity: 1;
-}
-
-.single-input-option .name {
- font-size: 14px;
-}
-
-/* =============================================================================
- Dropdown - Settings Button
- ============================================================================= */
-
-.settings-btn {
- width: 100%;
- padding: 10px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- color: var(--text-muted);
- font-size: 12px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- transition: all 0.15s;
-}
-
-.settings-btn:hover {
- background: var(--hover-color);
- color: var(--text-color);
-}
-
-.settings-btn .icon {
- font-size: 14px;
-}
-
-/* =============================================================================
- Settings Modal
- ============================================================================= */
-
-.settings-modal-content {
- background: var(--panel-bg);
- border-radius: 12px;
- padding: 24px;
- backdrop-filter: blur(20px);
- border: 1px solid var(--panel-border);
- max-width: 500px;
- width: 90%;
- max-height: 90vh;
- overflow-y: auto;
-}
-
-.modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 24px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--panel-border);
-}
-
-.modal-header h2 {
- font-size: 18px;
- font-weight: 500;
- margin: 0;
-}
-
-.close-btn {
- width: 32px;
- height: 32px;
+ gap: 6px;
+ height: 40px;
+ padding: 0 10px;
+ border: 0;
+ border-radius: var(--radius-pill);
background: transparent;
- border: none;
- border-radius: 6px;
color: var(--text-muted);
- font-size: 18px;
cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.15s;
+ transition: background-color var(--motion), color var(--motion);
}
-.close-btn:hover {
- background: var(--hover-color);
- color: var(--text-color);
-}
-
-.settings-section {
- margin-bottom: 28px;
-}
-
-.settings-section:last-of-type {
- margin-bottom: 0;
-}
-
-.settings-section h3 {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin: 0 0 14px 0;
-}
-
-/* =============================================================================
- Settings Modal - Input Name List
- ============================================================================= */
-
-.input-name-list {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.input-name-row {
- display: flex;
- align-items: center;
- gap: 12px;
+.view-mode-btn svg {
+ width: 22px;
+ height: 22px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.8;
+ stroke-linejoin: round;
}
-.input-name-row .num {
- width: 24px;
- height: 24px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.input-name-row input[type="text"] {
- flex: 1;
- padding: 10px 12px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--panel-border);
- border-radius: 6px;
+.view-mode-btn.active {
+ background-color: var(--sbp-blue);
color: var(--text-color);
- font-size: 13px;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.input-name-row input[type="text"]:focus {
- border-color: var(--accent-color);
}
-.input-name-row .default-btn {
- padding: 8px 12px;
- background: transparent;
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- color: var(--text-muted);
- font-size: 11px;
- cursor: pointer;
- transition: all 0.15s;
- white-space: nowrap;
-}
-
-.input-name-row .default-btn:hover {
- background: var(--hover-color);
-}
-
-.input-name-row .default-btn.active {
- background: rgba(245, 158, 11, 0.2);
- border-color: #f59e0b;
- color: #f59e0b;
-}
-
-.input-name-row.disabled input[type="text"] {
- opacity: 0.4;
-}
-
-.input-name-row.disabled .default-btn {
- opacity: 0.4;
- pointer-events: none;
-}
-
-/* =============================================================================
- Settings Modal - Slider Rows
- ============================================================================= */
-
-.slider-row {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-bottom: 16px;
-}
-
-.slider-row:last-child {
- margin-bottom: 0;
-}
-
-.slider-row label {
- width: 100px;
- font-size: 13px;
- flex-shrink: 0;
-}
-
-.slider-row input[type="range"] {
- flex: 1;
- accent-color: var(--accent-color);
-}
-
-.slider-row .value {
- width: 50px;
- text-align: right;
- font-size: 12px;
- color: var(--text-muted);
-}
-
-/* =============================================================================
- Settings Modal - Audio Controls
- ============================================================================= */
-
-.audio-group {
- background: rgba(0, 0, 0, 0.2);
- border-radius: 8px;
- padding: 14px;
- margin-bottom: 12px;
-}
-
-.audio-group:last-child {
- margin-bottom: 0;
-}
-
-.audio-label {
- font-size: 12px;
- color: var(--text-muted);
- margin-bottom: 12px;
- font-weight: 500;
-}
-
-/* =============================================================================
- Settings Modal - No-Signal Setup Card
- ============================================================================= */
-
-.setup-card {
- background: rgba(0, 0, 0, 0.2);
- border-radius: 8px;
- padding: 16px;
-}
-
-.setup-card p {
- font-size: 12px;
- color: var(--text-muted);
- margin: 0 0 12px 0;
- line-height: 1.5;
-}
-
-.setup-btn-row {
- display: flex;
- gap: 10px;
-}
-
-.setup-btn-row button {
- flex: 1;
- padding: 10px 16px;
- border-radius: 6px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-/* =============================================================================
- Settings Modal - Shortcuts Table
- ============================================================================= */
-
-/* =============================================================================
- Shortcut hints (#258)
- ============================================================================= */
-
-/* The key chip beside a dropdown control. Quieter than the kbd chips in the
- Settings table -- this is a hint next to a control the operator is already
- looking at, not a reference they came to read -- but only just.
-
- The first attempt used 10px at opacity 0.55, which looked right on a laptop
- and disappeared entirely on the wall: 6000x1200 in a lit room, and 12px is
- already this UI's floor for body text. A hint nobody can read fails at the one
- thing the hints are for, so these are sized off the app's existing 12px rather
- than shrunk until they stopped competing. */
-.shortcut-hint {
- margin-left: auto;
- flex-shrink: 0;
- opacity: 0.7;
-}
-
-.shortcut-hint kbd {
- padding: 2px 6px;
- font-size: 11px;
-}
-
-/* On a selected row or the active view-mode button the chip sits on the accent
- colour, where a translucent white loses most of its contrast. */
-.input-option.selected .shortcut-hint,
-.single-input-option.selected .shortcut-hint,
-.view-mode-btn.active .shortcut-hint {
- opacity: 0.9;
-}
-
-.input-option.selected .shortcut-hint kbd,
-.single-input-option.selected .shortcut-hint kbd,
-.view-mode-btn.active .shortcut-hint kbd {
- background-color: rgba(255, 255, 255, 0.22);
-}
-
-/* Inside a view-mode button the hint follows the label rather than being pushed
- to the far edge -- the button is centred text, so margin-left:auto would drag
- the chip away from the word it belongs to. */
-.view-mode-btn .shortcut-hint {
- margin-left: 6px;
-}
-
-/* The name takes the room the chip does not, so a long device label truncates
- instead of pushing the chip out of the row. */
-/* =============================================================================
- Input thumbnails (#242)
- ============================================================================= */
-
-/* A fixed tile size, and a different arrangement per list, because the two lists
- are very different widths.
-
- The first attempt made the tile the full row width. Measured at a 1280x800
- window that gave 292x164 tiles and 218px rows in the single list -- 1166px of
- content in a 640px panel, so a four-input picker scrolled. Absurd for something
- you hover over to choose an input.
-
- A fixed 128x72 is enough to recognise what an input is showing, and it changes
- what fits: in the single list (~322px rows) the name sits BESIDE the tile; in a
- dual column (~149px rows) it goes below. Two rules, each matching its width,
- rather than one rule that is wrong in one of them. */
-:root {
- --input-thumb-w: 128px;
- --input-thumb-h: 72px;
-}
-
-/* Dual columns: tile above, name below. No room for anything else at ~149px. */
-.input-option {
- flex-direction: column;
- align-items: stretch;
-}
-
-/* Single list: tile left, name and chip filling the rest. */
-.single-input-option {
- flex-direction: row;
- align-items: center;
-}
-
-.input-thumb {
- width: var(--input-thumb-w);
- height: var(--input-thumb-h);
- border-radius: 4px;
- background-color: rgba(0, 0, 0, 0.5);
- border: 1px solid var(--panel-border);
- /* cover, matching the crop snapshotFromVideo() already applies, so a 4:3
- capture is cropped rather than squashed in both places. */
- background-size: cover;
- background-position: center;
- /* Never absorb the row's slack: the name is what should flex. */
- flex-shrink: 0;
-}
-
-/* The tile is rendered before any still exists and is its own placeholder, so a
- row does not change height when a snapshot lands. This only marks the arrival,
- for anything that wants to style it differently. */
-.input-thumb.has-thumb {
- border-color: transparent;
-}
-
-/* Name and chip on one line. This is the flex row that
- `.shortcut-hint { margin-left: auto }` pushes the chip to the end of. */
-.input-option-label {
+.capsule-volume {
display: flex;
align-items: center;
gap: 8px;
- /* min-width:0 so a long name ellipsises inside this box rather than widening
- it and pushing the chip out -- the same reason it is on the name itself. */
+ width: 250px;
min-width: 0;
- flex: 1 1 auto;
-}
-
-/* Truncation applies only where a chip shares the row -- the single-view list.
- The dual columns carry no chip (see buildOption in renderer.js: the number keys
- set both feeds, so a per-side row must not advertise one), so their names wrap
- as they did before the chips existed. At ~173px a column has no room to both
- truncate and stay readable. */
-.single-input-option .input-option-name {
- /* min-width:0 is what actually lets a flex item shrink below its content
- width; without it the default min-width:auto keeps the name at full size and
- pushes the chip out of the row instead of ellipsising. */
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.shortcut-note {
- color: var(--text-muted);
-}
-
-.shortcuts-table {
- width: 100%;
- font-size: 12px;
- border-collapse: collapse;
-}
-
-.shortcuts-table td {
- padding: 8px 0;
- border-bottom: 1px solid var(--panel-border);
-}
-
-.shortcuts-table tr:last-child td {
- border-bottom: none;
-}
-
-.shortcuts-table td:first-child {
- color: var(--text-muted);
- width: 80px;
}
-/* =============================================================================
- Settings Modal - Footer
- ============================================================================= */
-
-.modal-footer {
- margin-top: 24px;
- padding-top: 16px;
- border-top: 1px solid var(--panel-border);
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 11px;
+.capsule-volume .mono-label {
color: var(--text-muted);
}
/* =============================================================================
- Input List (legacy - keeping for compatibility)
- ============================================================================= */
-
-#input-list {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.input-item {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px;
- background-color: var(--hover-color);
- border-radius: 8px;
-}
-
-.input-item.disabled {
- opacity: 0.5;
-}
-
-.input-item .input-number {
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--accent-color);
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.input-name-wrapper {
- flex: 1;
- min-width: 0;
-}
-
-.input-name-field {
- width: 100%;
- padding: 6px 10px;
- background-color: rgba(255, 255, 255, 0.1);
- border: 1px solid transparent;
- border-radius: 6px;
- color: var(--text-color);
- font-size: 13px;
- font-family: inherit;
- outline: none;
- transition: border-color 0.2s ease, background-color 0.2s ease;
-}
-
-.input-name-field:hover {
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-.input-name-field:focus {
- border-color: var(--accent-color);
- background-color: rgba(255, 255, 255, 0.2);
-}
-
-.input-item .input-actions {
- display: flex;
- gap: 8px;
- flex-shrink: 0;
-}
-
-.input-item button {
- padding: 6px 12px;
- background-color: transparent;
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- color: var(--text-color);
- font-size: 12px;
- cursor: pointer;
- transition: background-color 0.2s ease;
-}
-
-.input-item button:hover:not(:disabled) {
- background-color: var(--hover-color);
-}
-
-.input-item button:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-.input-item button.active {
- background-color: var(--accent-color);
- border-color: var(--accent-color);
-}
-
-/* =============================================================================
- Settings Controls
+ Shortcut hints (#258)
============================================================================= */
-.panel-section {
- margin-top: 20px;
- padding-top: 16px;
- border-top: 1px solid var(--panel-border);
-}
-
-.setting-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.setting-row label {
- flex: 1;
- font-size: 14px;
-}
-
-.setting-row input[type="range"] {
- width: 100px;
- accent-color: var(--accent-color);
-}
-
-.setting-row span {
- width: 50px;
- font-size: 12px;
- color: var(--text-muted);
- text-align: right;
-}
-
-/* Layout Buttons */
-.layout-buttons {
- display: flex;
- gap: 6px;
-}
-
-.layout-btn {
- width: 32px;
- height: 32px;
- display: flex;
+/* Sized off the 12px floor, not shrunk until it stops competing: 10px at 0.55
+ opacity read fine on a laptop and was invisible on the wall. */
+.shortcut-hint {
+ display: inline-flex;
align-items: center;
- justify-content: center;
- background-color: transparent;
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- color: var(--text-color);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- transition: background-color 0.2s ease, border-color 0.2s ease;
-}
-
-.layout-btn:hover {
- background-color: var(--hover-color);
-}
-
-.layout-btn.active {
- background-color: var(--accent-color);
- border-color: var(--accent-color);
-}
-
-/* =============================================================================
- Toggle Switch
- ============================================================================= */
-
-.toggle-switch {
- position: relative;
- width: 44px;
- height: 24px;
- background-color: rgba(255, 255, 255, 0.2);
- border-radius: 12px;
- cursor: pointer;
- transition: background-color 0.2s ease;
+ gap: 4px;
flex-shrink: 0;
+ opacity: 0.85;
}
-.toggle-switch.active {
- background-color: var(--accent-color);
-}
-
-.toggle-switch::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 20px;
- height: 20px;
- background-color: white;
- border-radius: 50%;
- transition: transform 0.2s ease;
+.shortcut-hint kbd {
+ font-size: 12px;
}
-.toggle-switch.active::after {
- transform: translateX(20px);
+.view-mode-btn.active .shortcut-hint kbd {
+ background-color: rgba(255, 255, 255, 0.24);
}
/* =============================================================================
- Settings Form Elements
+ Shortcut legend (dropup)
============================================================================= */
-.settings-description {
- font-size: 13px;
- color: var(--text-muted);
- margin: 0 0 16px;
- line-height: 1.5;
-}
-
-.toggle-row {
+/* The mirror of the dropdown above: same hover-to-reveal, same slide, same
+ touch-open class, anchored to the bottom edge instead of the top. Rows come from
+ shortcuts.js, so the legend cannot disagree with what the keys do (#258).
+
+ Wider than the dropdown on purpose. The dropdown is a picker capped at 400px;
+ this is a reference table, and the wall is 5:1 -- a grid that uses the width
+ turns twelve rows into two or three short columns instead of one long list that
+ runs off the bottom of a window. */
+#legend-trigger {
+ position: fixed;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 60px;
+ height: 30px;
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 12px;
+ justify-content: center;
+ background-color: rgba(2, 12, 23, 0.6);
+ /* Mirrored corners: rounded on the side away from the edge it hangs off. */
+ border-radius: 12px 12px 0 0;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--motion);
+ z-index: 100;
+ -webkit-app-region: no-drag;
}
-.toggle-row label {
- font-size: 14px;
- color: var(--text-color);
+body.cursor-visible #legend-trigger {
+ opacity: 0.7;
}
-.remote-keyboard-fields,
-.artnet-fields {
- margin-top: 16px;
- padding: 16px;
- background: rgba(0, 0, 0, 0.15);
- border-radius: 8px;
+#legend-trigger:hover,
+#legend-trigger.touch-open {
+ opacity: 1;
}
-.remote-keyboard-fields.hidden,
-.artnet-fields.hidden {
- display: none;
+.legend-arrow {
+ font-size: 12px;
+ color: var(--text-color);
}
-.input-row {
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-bottom: 12px;
+/* Floats just above the bottom edge, in the same surface as the capsule and
+ Settings. It overlaps the tab by a few pixels on purpose: hover moves from the
+ tab into the panel without crossing a gap, which would close it.
+
+ Fades only, like everything else in this visual language. visibility is
+ delayed on the way out so the fade plays before it stops taking the pointer. */
+#legend-panel {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: var(--surface);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-container);
+ z-index: 99;
+ /* An explicit width, not just a max.
+
+ A fixed-position element is shrink-to-fit, so `max-width` caps it but never
+ expands it -- and `grid-template-columns: repeat(auto-fit, ...)` needs a
+ DEFINITE width to work out how many columns fit. With only a max the panel
+ sized to its content's minimum: measured 467px wide and one column at a
+ 1500px viewport, which is the long-list-running-off-the-bottom this grid
+ exists to avoid. */
+ width: min(1100px, calc(100vw - 32px));
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity var(--motion), visibility 0s linear var(--motion-ms);
+ -webkit-app-region: no-drag;
}
-.input-row:last-child {
- margin-bottom: 0;
+#legend-trigger:hover + #legend-panel,
+#legend-panel:hover,
+#legend-trigger.touch-open + #legend-panel,
+#legend-panel.touch-open {
+ opacity: 1;
+ visibility: visible;
+ transition: opacity var(--motion), visibility 0s;
}
-.input-row label {
- font-size: 12px;
- color: var(--text-muted);
- font-weight: 500;
+/* Keep the cursor up while the legend is open, same as the dropdown does. */
+body:has(#legend-trigger:hover),
+body:has(#legend-panel:hover) {
+ cursor: default !important;
}
-.input-row input[type="text"],
-.input-row input[type="password"],
-.input-row select {
- padding: 10px 12px;
- background: rgba(0, 0, 0, 0.25);
- border: 1px solid rgba(255, 255, 255, 0.14);
- border-radius: 6px;
- color: var(--text-color);
- font-size: 14px;
- outline: none;
- transition: border-color 0.15s, box-shadow 0.15s;
+body:has(#legend-trigger:hover) *,
+body:has(#legend-panel:hover) * {
+ cursor: default;
+}
+
+@media (pointer: coarse) {
+ #legend-trigger {
+ width: 80px;
+ height: 44px;
+ }
+
+ /* Still overlapping the taller tab. */
+ #legend-panel {
+ bottom: 38px;
+ }
}
-.input-row input[type="text"]:focus,
-.input-row input[type="password"]:focus,
-.input-row select:focus {
- border-color: var(--accent-color);
- box-shadow: 0 0 0 3px rgba(130, 180, 255, 0.2);
+.legend-content {
+ padding: 20px 24px 22px;
}
-.input-row input[type="text"]::placeholder,
-.input-row input[type="password"]::placeholder {
- color: var(--text-muted);
- opacity: 0.6;
+.legend-title {
+ margin-bottom: 14px;
+ font: 400 12px/1.2 var(--font-mono);
+ color: var(--turquoise);
+ letter-spacing: 0.02em;
}
-.artnet-saver-list {
- max-height: 260px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding-right: 4px;
+/* auto-fit rather than a fixed column count: one rule covers the 6000px wall and
+ a small dev window, wrapping to however many columns the width allows. */
+.legend-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 10px 28px;
}
-.artnet-saver-row {
+.legend-row {
display: flex;
- align-items: center;
+ /* Top-aligned, not centred: a label that wraps to two lines should keep its key
+ chip beside the FIRST line rather than floating against the middle. */
+ align-items: flex-start;
gap: 12px;
+ /* min-width:0 so a long label wraps inside its cell rather than widening the
+ track -- the same grid trap that overflowed the old dropdown columns. */
+ min-width: 0;
}
-.artnet-saver-row .artnet-saver-name {
- flex: 1;
- min-width: 0;
- font-size: 13px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+/* Fixed width so the labels line up in a column instead of ragging against
+ whatever the key chips happen to measure. */
+.legend-keys {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ flex-shrink: 0;
+ width: 96px;
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
}
-.artnet-saver-row select {
- flex: 0 0 190px;
- padding: 6px 8px;
- background: rgba(0, 0, 0, 0.25);
- border: 1px solid rgba(255, 255, 255, 0.14);
- border-radius: 6px;
+/* Wraps rather than truncates. Measured at 1100px wide: three of the twelve
+ labels ellipsised, including both "Presentation: ... (if the remote keyboard is
+ enabled)" rows -- so the caveat, which is the only reason those rows are
+ confusing without it, was the part being cut. A legend that hides what a key
+ does defeats itself; uneven row heights are the cheaper cost. */
+.legend-label {
+ min-width: 0;
+ padding-top: 3px;
+ font: 700 13px/1.45 var(--font-sans);
+ letter-spacing: -0.02em;
color: var(--text-color);
- font-size: 12px;
- outline: none;
}
-.artnet-saver-row select:focus {
- border-color: var(--accent-color);
+.legend-note {
+ font-weight: 400;
+ color: var(--text-muted);
}
/* =============================================================================
@@ -1915,320 +1274,635 @@ kbd {
text-overflow: ellipsis;
}
-#presenter-debug-log .debug-line.error {
- color: #ff6b6b;
+#presenter-debug-log .debug-line.error {
+ color: #ff6b6b;
+}
+
+#presenter-debug-log .debug-line.ok {
+ color: #6bff95;
+}
+
+/* =============================================================================
+ Settings modal
+ ============================================================================= */
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: rgba(2, 12, 23, 0.72);
+ -webkit-app-region: no-drag;
+}
+
+.modal,
+.modal * {
+ cursor: default;
+}
+
+/* 920x680, shrunk to fit a small laptop window. */
+.settings-modal-content {
+ width: min(920px, calc(100vw - 32px));
+ height: min(680px, calc(100vh - 32px));
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ background-color: var(--surface);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-container);
+ overflow: hidden;
+ animation: modalIn var(--motion-ms) var(--ease);
+}
+
+@keyframes modalIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .settings-modal-content { animation: none; }
+}
+
+.settings-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 24px 12px 16px;
+ background-color: var(--well);
+ border-right: 1px solid var(--hairline);
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.settings-nav h2 {
+ padding: 0 12px 16px;
+ font: 800 22px/1.2 var(--font-sans);
+ letter-spacing: -0.03em;
+}
+
+.settings-nav-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 12px 0 14px;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--text-muted);
+ font: 700 14px/1.2 var(--font-sans);
+ letter-spacing: -0.02em;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color var(--motion), color var(--motion);
+}
+
+.settings-nav-item:hover {
+ color: var(--text-color);
+}
+
+.settings-nav-item.active {
+ background-color: var(--raised);
+ color: var(--text-color);
+}
+
+.nav-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background-color: rgba(255, 255, 255, 0.3);
+}
+
+.status-dot.ok { background-color: var(--signal-green); }
+.status-dot.warn { background-color: var(--signal-orange); }
+
+.nav-count {
+ min-width: 22px;
+ padding: 3px 7px;
+ border-radius: var(--radius-pill);
+ background-color: var(--signal-orange);
+ color: var(--well);
+ font-weight: 700;
+ text-align: center;
+}
+
+.modal-footer {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: auto;
+ padding: 16px 12px 0;
+ font: 400 12px/1.4 var(--font-mono);
+ color: var(--text-muted);
+}
+
+.settings-body {
+ position: relative;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 28px 32px 32px;
+}
+
+.settings-body > .close-btn {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ z-index: 1;
+}
+
+.settings-section h3 {
+ margin: 4px 56px 20px 0;
+ font: 800 22px/1.2 var(--font-sans);
+ letter-spacing: -0.03em;
+}
+
+.settings-description {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.field-hint {
+ margin-left: 6px;
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
+}
+
+/* Toggle switch: a button with role="switch". The track is drawn by ::before and
+ the knob by ::after so the button itself can stay a full 44px hit target. */
+.toggle-switch {
+ position: relative;
+ flex-shrink: 0;
+ width: 56px;
+ height: 44px;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ -webkit-app-region: no-drag;
+}
+
+.toggle-switch::before {
+ content: '';
+ position: absolute;
+ left: 4px;
+ top: 8px;
+ width: 48px;
+ height: 28px;
+ border-radius: var(--radius-pill);
+ background-color: rgba(255, 255, 255, 0.18);
+ transition: background-color var(--motion);
}
-#presenter-debug-log .debug-line.ok {
- color: #6bff95;
+.toggle-switch::after {
+ content: '';
+ position: absolute;
+ left: 8px;
+ top: 12px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background-color: var(--text-color);
+ transition: transform var(--motion);
}
-/* =============================================================================
- Setup Wizard Modal
- ============================================================================= */
-
-.modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.9);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- -webkit-app-region: no-drag;
+.toggle-switch.active::before {
+ background-color: var(--sbp-blue);
}
-.modal.hidden {
- display: none;
+.toggle-switch.active::after {
+ transform: translateX(20px);
}
-.modal-content {
- background-color: var(--panel-bg);
- border-radius: 12px;
- padding: 40px;
- max-width: 700px;
- width: 90%;
- max-height: 90vh;
- overflow-y: auto;
+@media (prefers-reduced-motion: reduce) {
+ .toggle-switch::after { transition: none; }
}
-.modal-content h2 {
- margin-bottom: 16px;
- font-size: 28px;
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 44px;
+ margin-bottom: 12px;
}
-.wizard-instructions {
- color: var(--text-muted);
- margin-bottom: 30px;
- line-height: 1.6;
+.toggle-row label {
+ font: 700 14px/1.3 var(--font-sans);
+ letter-spacing: -0.02em;
}
-.wizard-steps {
- margin-bottom: 30px;
+.toggle-row-described {
+ align-items: flex-start;
+ padding: 14px 16px;
+ border-radius: var(--radius-container);
+ background-color: var(--raised);
}
-.wizard-step {
- display: flex;
- gap: 20px;
- margin-bottom: 30px;
+.toggle-row-described .settings-description {
+ margin: 4px 0 0;
}
-.step-number {
- flex-shrink: 0;
- width: 40px;
- height: 40px;
- border-radius: 50%;
- background-color: var(--accent-color);
+.input-row {
display: flex;
- align-items: center;
- justify-content: center;
- font-weight: bold;
- font-size: 18px;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 14px;
}
-.step-content {
- flex: 1;
+.input-row > label,
+.slider-row > label {
+ font: 700 13px/1.3 var(--font-sans);
+ letter-spacing: -0.02em;
}
-.step-content h3 {
- margin-bottom: 8px;
- font-size: 18px;
+.settings-body input[type="text"],
+.settings-body input[type="password"],
+.settings-body select {
+ width: 100%;
+ min-height: 44px;
+ padding: 0 14px;
+ border: 1px solid var(--hairline);
+ border-radius: 10px;
+ background-color: var(--well);
+ color: var(--text-color);
+ font: 400 14px/1.2 var(--font-sans);
+ -webkit-app-region: no-drag;
}
-.step-content p {
- color: var(--text-muted);
- line-height: 1.5;
+.settings-body input::placeholder {
+ color: rgba(255, 255, 255, 0.4);
}
-.wizard-input-select {
- display: flex;
- gap: 20px;
- margin-top: 12px;
+.settings-body input:focus,
+.settings-body select:focus {
+ outline: none;
+ border-color: var(--sbp-blue);
}
-.wizard-input-select label {
+.input-with-button {
display: flex;
- align-items: center;
gap: 8px;
- cursor: pointer;
- padding: 10px 16px;
- background-color: rgba(255, 255, 255, 0.05);
- border-radius: 6px;
- transition: background-color 0.2s;
}
-.wizard-input-select label:hover {
- background-color: rgba(255, 255, 255, 0.1);
+.slider-row {
+ display: grid;
+ grid-template-columns: 180px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
}
-.wizard-input-select input[type="radio"] {
- cursor: pointer;
+.status-line {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 24px;
+ margin: -4px 0 14px;
+ font: 400 12px/1.4 var(--font-mono);
+ color: var(--text-muted);
}
-.wizard-preview {
- margin-top: 12px;
- border: 2px solid var(--panel-border);
- border-radius: 8px;
- overflow: hidden;
- background-color: #000;
+.status-line:empty {
+ display: none;
}
-#wizard-preview-canvas {
- display: block;
- width: 100%;
- height: auto;
- max-height: 300px;
- object-fit: contain;
+.remote-keyboard-fields,
+.artnet-fields {
+ padding-top: 4px;
}
-.preview-status {
- padding: 12px;
- text-align: center;
- background-color: rgba(0, 0, 0, 0.5);
- font-size: 14px;
- color: var(--text-muted);
-}
+/* =============================================================================
+ Settings: Inputs table
+ ============================================================================= */
-.wizard-actions {
+.input-name-list {
display: flex;
- gap: 12px;
- margin-bottom: 20px;
+ flex-direction: column;
+ gap: 2px;
+ margin-top: 16px;
}
-.btn-primary, .btn-secondary {
- flex: 1;
- padding: 14px 24px;
- border: none;
- border-radius: 8px;
- font-size: 16px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s;
+/* Key, On, Name, Startup, No-signal. */
+.input-name-row,
+.input-table-head {
+ display: grid;
+ grid-template-columns: 40px 56px minmax(0, 1fr) 96px 132px;
+ align-items: center;
+ gap: 10px;
}
-.btn-primary {
- background-color: var(--accent-color);
- color: white;
+.input-table-head {
+ padding: 0 8px 6px;
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
}
-.btn-primary:hover {
- background-color: #0052cc;
- transform: translateY(-1px);
+.input-name-row {
+ padding: 4px 8px;
+ border-radius: 10px;
}
-.btn-primary:active {
- transform: translateY(0);
+.input-name-row:hover {
+ background-color: rgba(255, 255, 255, 0.03);
}
-.btn-secondary {
- background-color: rgba(255, 255, 255, 0.1);
+.input-key {
+ font: 400 14px/1 var(--font-mono);
+ text-align: center;
color: var(--text-color);
}
-.btn-secondary:hover {
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-.wizard-footer {
- text-align: center;
+.input-name-row.disabled .input-key,
+.input-name-row.disabled .input-name-field {
color: var(--text-muted);
- font-size: 13px;
}
-.setup-btn {
+.startup-pill {
width: 100%;
- padding: 12px;
- background-color: rgba(0, 102, 255, 0.2);
- border: 1px solid var(--accent-color);
- border-radius: 6px;
- color: var(--text-color);
- font-size: 14px;
- cursor: pointer;
- transition: all 0.2s;
}
-.setup-btn:hover {
- background-color: rgba(0, 102, 255, 0.3);
+.startup-pill[aria-checked="true"] {
+ background-color: var(--sbp-blue);
+ border-color: var(--sbp-blue);
}
-/* =============================================================================
- No-signal reference manager (#161, #160)
-
- A device with no reference is silently inert -- detection returns "has
- signal" unconditionally and logs nothing -- so the row badge exists to make
- that state visible rather than leaving it to a console command.
- ============================================================================= */
-
-.input-name-row .ref-toggle-btn {
- padding: 8px 12px;
- background: transparent;
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- color: var(--text-muted);
- font-size: 11px;
- cursor: pointer;
- transition: all 0.15s;
- white-space: nowrap;
+.ref-toggle-btn {
+ width: 100%;
+ font-family: var(--font-mono);
+ font-weight: 400;
+ font-size: 12px;
+ letter-spacing: 0;
}
-.input-name-row .ref-toggle-btn:hover {
- background: var(--hover-color);
+.ref-toggle-btn.warn {
+ border-color: var(--signal-orange);
+ color: var(--signal-orange);
}
-/* Amber rather than red: an input with no reference is unconfigured, not
- broken, and several inputs legitimately never need one. */
-.input-name-row .ref-toggle-btn.warn {
- border-color: rgba(245, 158, 11, 0.5);
- color: rgb(245, 158, 11);
+.input-row-note {
+ grid-column: 3 / -1;
+ font: 400 12px/1.3 var(--font-mono);
+ color: var(--signal-orange);
}
.ref-panel {
- padding: 10px 12px 14px;
- margin: -4px 0 8px;
- border-left: 2px solid var(--panel-border);
-}
-
-.ref-panel.hidden {
- display: none;
+ margin: 4px 8px 10px 114px;
+ padding: 14px 16px;
+ border-radius: var(--radius-container);
+ background-color: var(--well);
+ border: 1px solid var(--hairline);
}
.ref-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
+ margin-bottom: 10px;
}
.ref-item {
position: relative;
}
-/* Thumbnails are the stored ImageData drawn directly, so they are already at
- the detect resolution -- no second copy is kept for the UI. */
.ref-thumb {
- width: 128px;
- height: auto;
display: block;
- border: 1px solid var(--panel-border);
- border-radius: 4px;
+ width: 128px;
+ height: 72px;
+ border-radius: 6px;
+ object-fit: cover;
+ background-color: #000;
}
.ref-delete {
position: absolute;
- top: -6px;
- right: -6px;
- width: 20px;
- height: 20px;
- padding: 0;
- line-height: 18px;
- text-align: center;
- border: 1px solid var(--panel-border);
+ top: -10px;
+ right: -10px;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--hairline);
border-radius: 50%;
- background: var(--panel-bg, #1a1a1a);
- color: var(--text-muted);
- font-size: 14px;
+ background-color: var(--raised);
+ color: var(--text-color);
+ font-size: 16px;
+ line-height: 1;
cursor: pointer;
}
-.ref-delete:hover {
- border-color: rgba(239, 68, 68, 0.6);
- color: rgb(239, 68, 68);
-}
-
.ref-empty,
.ref-note {
- margin: 6px 0 0;
- font-size: 11px;
- color: var(--text-muted);
+ font-size: 13px;
line-height: 1.5;
+ color: var(--text-muted);
+ margin-bottom: 12px;
}
-.ref-orphans {
+.ref-capture-row {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 12px;
- padding: 10px 12px;
- margin-bottom: 10px;
- border: 1px solid rgba(245, 158, 11, 0.4);
- border-radius: 6px;
- font-size: 11px;
+}
+
+.ref-capture-msg {
+ font: 400 12px/1.4 var(--font-mono);
color: var(--text-muted);
+}
+
+.ref-capture-msg.ok { color: var(--signal-green); }
+.ref-capture-msg.error { color: var(--signal-orange); }
+
+.ref-orphans {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 12px 16px;
+ margin-bottom: 12px;
+ border: 1px solid var(--signal-orange);
+ border-radius: var(--radius-container);
+ font-size: 13px;
line-height: 1.5;
}
-.ref-prune-btn {
- padding: 6px 10px;
- background: transparent;
- border: 1px solid var(--panel-border);
- border-radius: 6px;
+.ref-orphans-text {
+ flex: 1;
+}
+
+.ref-orphans-text span {
+ display: block;
color: var(--text-muted);
- font-size: 11px;
- cursor: pointer;
+}
+
+/* =============================================================================
+ Settings: Layout diagram
+ ============================================================================= */
+
+/* The 5:1 wall drawn flat and to scale: side borders, the two halves and the
+ centre gap, with what is on each half. Widths are flex-grow ratios set from JS
+ in screen pixels, so the drawing is proportional whatever the window size. */
+.layout-diagram {
+ display: flex;
+ aspect-ratio: 5 / 1;
+ width: 100%;
+ margin-bottom: 20px;
+ padding: 6px;
+ border-radius: var(--radius-container);
+ background-color: var(--well);
+ border: 1px solid var(--hairline);
+}
+
+.layout-diagram > div {
+ min-width: 0;
+}
+
+.layout-half {
+ display: flex;
+ align-items: flex-end;
+ padding: 8px 10px;
+ border-radius: 4px;
+ background-color: var(--sbp-blue);
+ font: 700 12px/1.2 var(--font-sans);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.layout-gap,
+.layout-border {
+ background: repeating-linear-gradient(135deg,
+ rgba(255, 255, 255, 0.10) 0 2px, transparent 2px 6px);
+}
+
+/* =============================================================================
+ Settings: Art-Net per-screensaver grid
+ ============================================================================= */
+
+.artnet-saver-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px 16px;
+}
+
+.artnet-saver-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
+ align-items: center;
+ gap: 8px;
+}
+
+.artnet-saver-row .artnet-saver-name {
+ font-size: 13px;
white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.artnet-saver-row.customised .artnet-saver-name {
+ color: var(--turquoise);
+}
+
+.settings-body .artnet-saver-row select {
+ min-height: 40px;
+ font-size: 13px;
+ padding: 0 10px;
+}
+
+/* A narrow window: stack the table cells that would otherwise squeeze the name. */
+@media (max-width: 760px) {
+ .settings-modal-content {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .settings-nav {
+ flex-direction: row;
+ flex-wrap: wrap;
+ border-right: 0;
+ border-bottom: 1px solid var(--hairline);
+ }
+
+ .settings-nav .modal-footer { display: none; }
+ .artnet-saver-list { grid-template-columns: 1fr; }
+ .ref-panel { margin-left: 8px; }
+}
+
+/* =============================================================================
+ Volume icon
+ ============================================================================= */
+
+/* A speaker beside each volume slider that gains a wave per third of volume
+ (data-level 0-3, set by paintRangeFill). A wave arriving draws itself in from
+ the speaker outwards; one leaving fades. Muted swaps the waves for a cross. */
+.vol-icon {
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ color: var(--text-color);
+ overflow: visible;
+}
+
+.vol-icon .vol-speaker {
+ fill: currentColor;
+ stroke-width: 1.2;
+}
+
+.vol-icon .vol-wave {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ opacity: 0;
+ transition:
+ stroke-dashoffset 240ms var(--ease),
+ opacity 160ms var(--ease);
}
-.ref-prune-btn:hover {
- background: var(--hover-color);
+.vol-icon[data-level="1"] .w1,
+.vol-icon[data-level="2"] .w1,
+.vol-icon[data-level="2"] .w2,
+.vol-icon[data-level="3"] .vol-wave {
+ stroke-dashoffset: 0;
+ opacity: 1;
+}
+
+/* Outer waves follow the inner ones, so a jump from silent to loud ripples out
+ rather than appearing all at once. */
+.vol-icon .w2 { transition-delay: 40ms; }
+.vol-icon .w3 { transition-delay: 80ms; }
+
+.vol-icon .vol-mute {
+ opacity: 0;
+ stroke: var(--signal-orange);
+ transition: opacity 160ms var(--ease);
+}
+
+.vol-icon[data-level="0"] .vol-mute {
+ opacity: 1;
+}
+
+.vol-icon[data-level="0"] .vol-speaker {
+ opacity: 0.5;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .vol-icon .vol-wave,
+ .vol-icon .vol-mute {
+ transition: none;
+ }
}
diff --git a/input_viewer_electron/test/input-thumbnails.test.js b/input_viewer_electron/test/input-thumbnails.test.js
index 380e8e6..3058004 100644
--- a/input_viewer_electron/test/input-thumbnails.test.js
+++ b/input_viewer_electron/test/input-thumbnails.test.js
@@ -306,11 +306,12 @@ describe('the tile is sized, not stretched', () => {
expect(body).not.toMatch(/width:\s*100%/)
})
- it('lays each list out for its own width', () => {
- // ~149px dual columns have no room for a name beside the tile; ~322px single
- // rows do. One rule for both would be wrong in one of them.
+ it('stacks the name under the tile, in a strip that scrolls sideways', () => {
+ // Dropdown 2b: each half gets one horizontal strip of tiles. Five or more
+ // inputs scroll rather than wrapping into a second row that would push the
+ // volume slider off the half.
expect(ruleBody('.input-option')).toMatch(/flex-direction:\s*column/)
- expect(ruleBody('.single-input-option')).toMatch(/flex-direction:\s*row/)
+ expect(ruleBody('.picker-strip')).toMatch(/overflow-x:\s*auto/)
})
it('never lets the tile absorb the row slack', () => {
diff --git a/input_viewer_electron/test/settings-panel.test.js b/input_viewer_electron/test/settings-panel.test.js
new file mode 100644
index 0000000..63c1011
--- /dev/null
+++ b/input_viewer_electron/test/settings-panel.test.js
@@ -0,0 +1,455 @@
+// @vitest-environment jsdom
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2025-2026 Schuberg Philis / Lab271
+/**
+ * The rebuilt Settings panel, the dropdown's open/close, Multi-view and the key
+ * handling fixes that came with them.
+ *
+ * Unlike the other renderer tests this loads the REAL index.html body rather
+ * than the id-derived fixture: the panel's behaviour lives in its markup (nav
+ * items, sections, switch roles), and a fixture would only test itself.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { device, projectRoot, requiredElementIds } from './helpers/renderer-dom.js'
+
+const INDEX_HTML = readFileSync(path.resolve(projectRoot, 'src/renderer/index.html'), 'utf8')
+const body = INDEX_HTML.slice(INDEX_HTML.indexOf('') + 6, INDEX_HTML.indexOf(''))
+ .replace(/