style(launcher): accessibility, session list UX, and preset-* token fixes

Style token fixes:
- launcher_cfg.svelte: tab buttons preset-filled-primary-500 -> preset-filled-primary;
  opacity-50 inactive -> preset-tonal-surface
- launcher_cfg_app_modes.svelte: same fix for app mode buttons (opacity-40)
- launcher_cfg_controller.svelte: variant-filled-success/error -> preset-filled-*
- launcher_cfg_template.svelte: variant-filled-success -> preset-filled-success
- launcher_session_view.svelte: add dark:border-gray-600/700 to bare border-gray-*

Session list (menu_session_list.svelte) -- full accessibility + UX pass:
- fix: background sync fetches hidden:all so All Sessions toggle works
- fix: hide_event_launcher respected in class:hidden and class:opacity-40
- fix: overlay uses explicit opaque backgrounds (slate-100/slate-800) to prevent
  preset-tonal-secondary transparency bleed-through in light and dark mode
- feat: compact fixed 2rem row height; position:absolute overlay on hover/focus
  reveals full session name (300-char) without any layout shift (no sibling movement)
- feat: active session always fully visible in flow (height:auto, no clipping)
- a11y: hover_timer_wait 750->1200ms (motor accessibility)
- a11y: removed hover:scale which caused cursor drift and timer jitter
- a11y: px-1.5 py-1 touch targets, focus-visible ring for keyboard/switch users
- a11y: fa-eye-slash icons distinguish hide vs hide_event_launcher states
- docs: comprehensive OSIT/Aether-specific comments throughout
This commit is contained in:
Scott Idem
2026-03-06 20:25:31 -05:00
parent cc6f73ca04
commit 4cecc7a860
7 changed files with 226 additions and 40 deletions

View File

@@ -28,27 +28,27 @@
type="button"
onclick={() => ($events_loc.launcher.app_mode = 'default')}
class="btn btn-xs text-[9px] font-bold"
class:preset-filled-primary-500={$events_loc.launcher
class:preset-filled-primary={$events_loc.launcher
.app_mode === 'default'}
class:opacity-40={$events_loc.launcher.app_mode !==
class:preset-tonal-surface={$events_loc.launcher.app_mode !==
'default'}>Web</button
>
<button
type="button"
onclick={() => ($events_loc.launcher.app_mode = 'native')}
class="btn btn-xs text-[9px] font-bold"
class:preset-filled-primary-500={$events_loc.launcher
class:preset-filled-primary={$events_loc.launcher
.app_mode === 'native'}
class:opacity-40={$events_loc.launcher.app_mode !==
class:preset-tonal-surface={$events_loc.launcher.app_mode !==
'native'}>App</button
>
<button
type="button"
onclick={() => ($events_loc.launcher.app_mode = 'onsite')}
class="btn btn-xs text-[9px] font-bold"
class:preset-filled-primary-500={$events_loc.launcher
class:preset-filled-primary={$events_loc.launcher
.app_mode === 'onsite'}
class:opacity-40={$events_loc.launcher.app_mode !==
class:preset-tonal-surface={$events_loc.launcher.app_mode !==
'onsite'}>Onsite</button
>
</div>

View File

@@ -36,11 +36,11 @@
</div>
{#if ws_connected}
<span
class="badge variant-filled-success text-[8px] animate-pulse"
class="badge preset-filled-success text-[8px] animate-pulse"
>Connected</span
>
{:else}
<span class="badge variant-filled-error text-[8px]"
<span class="badge preset-filled-error text-[8px]"
>Disconnected</span
>
{/if}

View File

@@ -117,7 +117,7 @@
>
<div class="flex justify-between items-center">
<span class="text-[10px] font-medium">Engine Health</span>
<span class="badge variant-filled-success text-[8px] uppercase"
<span class="badge preset-filled-success text-[8px] uppercase"
>Stable</span
>
</div>

View File

@@ -148,6 +148,7 @@
for_obj_type: 'event_location',
for_obj_id: location_id,
view: 'alt',
hidden: 'all', // Launcher is operator-only; fetch all sessions so the "All Sessions" toggle works
try_cache: true,
log_lvl: 0
});

View File

@@ -89,8 +89,8 @@
type="button"
onclick={() => (active_tab = 'system')}
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
class:preset-filled-primary-500={active_tab === 'system'}
class:opacity-50={active_tab !== 'system'}
class:preset-filled-primary={active_tab === 'system'}
class:preset-tonal-surface={active_tab !== 'system'}
>
<span class="fas fa-microchip mr-1"></span> System
</button>
@@ -98,8 +98,8 @@
type="button"
onclick={() => (active_tab = 'sync')}
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
class:preset-filled-primary-500={active_tab === 'sync'}
class:opacity-50={active_tab !== 'sync'}
class:preset-filled-primary={active_tab === 'sync'}
class:preset-tonal-surface={active_tab !== 'sync'}
>
<span class="fas fa-sync mr-1"></span> Sync
</button>
@@ -107,8 +107,8 @@
type="button"
onclick={() => (active_tab = 'general')}
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
class:preset-filled-primary-500={active_tab === 'general'}
class:opacity-50={active_tab !== 'general'}
class:preset-filled-primary={active_tab === 'general'}
class:preset-tonal-surface={active_tab !== 'general'}
>
<span class="fas fa-sliders-h mr-1"></span> General
</button>

View File

@@ -164,7 +164,7 @@
{#if $lq__event_session_obj && $lq__event_session_obj.event_session_id}
<header
class="event_session_about text-center border-b-2 border-gray-400 flex flex-row flex-wrap gap-2 items-center justify-between"
class="event_session_about text-center border-b-2 border-gray-400 dark:border-gray-600 flex flex-row flex-wrap gap-2 items-center justify-between"
>
<h3
class:hidden={!$lq__event_session_obj?.start_datetime ||
@@ -359,7 +359,7 @@
<ul class="event_presentation_list max-w-full space-y-2">
{#each $lq__event_presentation_obj_li as event_presentation_obj (event_presentation_obj.event_presentation_id)}
<li
class="border-b-2 border-gray-300 my-1 py-1 text-center md:text-left"
class="border-b-2 border-gray-300 dark:border-gray-700 my-1 py-1 text-center md:text-left"
>
<!-- The presentation information -->
<div

View File

@@ -1,4 +1,45 @@
<script lang="ts">
/**
* menu_session_list.svelte — Aether Launcher: Session Selector
*
* PURPOSE:
* This is the primary navigation control for conference operators using
* the Aether Events Launcher. It lists all sessions in the selected room
* (event_location) and lets the operator switch the room's active session.
*
* ENVIRONMENT:
* The Launcher runs on operator laptops, dedicated podium/kiosk tablets,
* projector-connected desktops, and occasionally phones in breakout rooms.
* Users range from tech-savvy AV staff to volunteers with limited computer
* experience. Some users have motor impairments or shaky hands (e.g. older
* members common at IDAA and similar events).
*
* KEY DESIGN CONSTRAINTS:
* - Must show 020 sessions without scrolling (compact fixed-height rows)
* - Session names can be extremely long (~300 chars) — must truncate at
* rest but reveal fully on hover without pushing other rows around
* - Hover-to-switch fires after a delay timer (not instantly) to prevent
* accidental session changes from casual cursor movement
* - Strongly prefer click-to-confirm over hair-trigger hover activation
* - Works in light and dark mode; projector-safe high-contrast overlay
*
* DATA FLOW:
* lq__event_session_obj_li (Dexie liveQuery, passed from launcher/+layout.svelte)
* → rendered here as buttons
* → click / hover-timer sets trigger_reload__event_session_obj_id
* → $effect fires load + URL navigation + optional WS remote-control push
*
* SESSION VISIBILITY (operator toggle — show_content__hidden_sessions):
* Normal sessions: always visible
* hide_event_launcher = true: hidden from list by default (launcher-specific
* suppression, e.g. overflow/backup sessions)
* hide = true: globally hidden across all views (draft/cancelled)
*
* Both hidden states are fetched into Dexie with hidden:'all' by the background
* sync so the operator can reveal them via the "All Sessions" menu toggle.
* When revealed they appear dimmed (opacity-40) with an eye-slash indicator.
*/
interface Props {
slct__event_session_id?: null | boolean | string;
loading__session_id_status?: null | boolean | string;
@@ -60,7 +101,14 @@
slct__event_presentation_li: null
});
let hover_timer_wait = 750; // Optimized from 1250ms for better responsiveness
// WHY 1200ms: Aether Launcher is used at conferences by operators of all ages and
// motor abilities — shaky hands, imprecise trackpads, and fat-finger tablet taps are
// routine. 750ms (the previous value) triggered accidental session changes when the
// cursor drifted across the list. 1200ms means the operator must deliberately hold
// focus on a row for over a second before it fires — still fast for intentional use.
// NOTE: hover-timer only triggers a data PRE-LOAD (preview). The session does not
// actually switch until the operator clicks. See onclick handler below.
let hover_timer_wait = 1200;
let hover_timer: any = $state(null);
// Navigation Shield Pattern (Refactored 2026-02-11)
@@ -71,7 +119,7 @@
if (trigger_reload__event_session_obj_id) {
const start = performance.now();
const event_session_id = String(trigger_reload__event_session_obj_id);
if (log_lvl) {
console.log(`[UI Trace] trigger_reload changed to: ${event_session_id}`);
}
@@ -179,22 +227,21 @@
<ul
class="
space-y-1
w-full max-w-full
p-0 m-0
flex flex-col gap-0.5 items-start justify-start
transition-all
flex flex-col gap-0 items-start justify-start
"
>
{#each $lq__event_session_obj_li as event_session_obj (event_session_obj.event_session_id)}
<li
class="
session-item
relative
p-0 m-0
w-full max-w-full
z-0 hover:z-20
hover:scale-120
transition-all
"
class:session-active={slct__event_session_id ===
event_session_obj?.id}
>
<button
type="button"
@@ -223,21 +270,21 @@
$events_slct.event_file_obj = null;
}}
class="
btn btn-sm hover:preset-filled-primary-500
session-btn
btn btn-sm
focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1
text-sm
w-full max-w-full
hover:min-w-fit
hover:max-w-2xl
text-left
m-0
p-0
px-1.5 py-1
rounded-md
flex flex-row items-center justify-start
transition-all
transition-colors duration-200
"
class:preset-filled-primary-500={slct__event_session_id ===
class:preset-filled-primary={slct__event_session_id ===
event_session_obj?.id}
class:preset-tonal-secondary={slct__event_session_id !=
event_session_obj?.id}
@@ -247,15 +294,24 @@
event_session_obj?.id}
class:hidden={!$events_loc.launcher
.show_content__hidden_sessions &&
event_session_obj?.hide}
class:dim={event_session_obj?.hide}
(event_session_obj?.hide ||
event_session_obj?.hide_event_launcher)}
class:opacity-40={event_session_obj?.hide ||
event_session_obj?.hide_event_launcher}
title={`Session: ${event_session_obj?.name}\nID: ${event_session_obj?.id} | ${ae_util.iso_datetime_formatter(event_session_obj?.start_datetime, $events_loc.launcher.time_format)}`}
>
<!-- hover:scale-115 -->
<!-- hover:z-index-10 -->
<!-- Session row layout: [date column | session name]
Date column is fixed-width (shrink-0) so name column always
gets consistent space regardless of date string length.
VISIBILITY (class:hidden / class:opacity-40 above):
- hide_event_launcher: suppressed in Launcher only (operator toggle reveals)
- hide: globally hidden across all Aether views (draft/cancelled)
Both fetched into Dexie with hidden:'all' by launcher_background_sync.
When revealed, dimmed (opacity-40) with eye-slash icon. -->
<span
class="border-r border-surface-400-600 pr-1 min-w-28"
class="border-r border-surface-400-600 pr-1 min-w-20 shrink-0"
>
{#if slct__event_session_id === event_session_obj?.id}
<span class="fas fa-calendar-check"></span>
@@ -282,15 +338,22 @@
<span
class="
grow text-xs
min-w-32 hover:min-w-fit
overflow-hidden hover:overflow-visible
trasnition-all
session-name
grow text-sm
min-w-0
"
>
{#if event_session_obj?.type_code == 'poster'}
<span class="fas fa-image mr-1 text-primary-500" title="Digital Poster Session"></span>
{/if}
<!-- Distinct icon styles distinguish the two hidden states:
amber = hide (globally hidden — draft, cancelled, or admin-only)
muted = hide_event_launcher (suppressed in Launcher view only) -->
{#if event_session_obj?.hide}
<span class="fas fa-eye-slash mr-1 text-warning-600" title="Hidden session"></span>
{:else if event_session_obj?.hide_event_launcher}
<span class="fas fa-eye-slash mr-1 opacity-60" title="Hidden from Launcher"></span>
{/if}
{event_session_obj?.name}
</span>
</button>
@@ -301,3 +364,125 @@
<div class="text-sm">No sessions found.</div>
{/if}
</div>
<style>
/*
* ═══════════════════════════════════════════════════════════════════
* Aether Launcher — Compact Session List Styles
* One Sky IT — specialized for conference operator use
* ═══════════════════════════════════════════════════════════════════
*
* ARCHITECTURE OVERVIEW
* ─────────────────────
* Each <li class="session-item"> is a FIXED-HEIGHT placeholder (2rem).
* Its child <button class="session-btn"> is clipped to that height at rest.
*
* On hover/focus-within, the button becomes position:absolute — it pops
* OUT of the layout flow and floats as an overlay panel above sibling rows.
* The <li> retains its original 2rem height permanently, so NO siblings
* ever shift position. This is deliberate and critical:
*
* WHY NOT transform:scale?
* scale() keeps the element in flow but enlarges its visual footprint.
* If it grows large enough to show wrapped text, neighbors shift OR the
* cursor drifts off the hitbox edge, firing mouseleave and cancelling the
* hover timer — exactly the jitter problem for shaky-hand operators.
*
* WHY position:absolute instead?
* The overlay grows in z-space only. The operator's cursor stays inside
* the expanded button hitbox the entire time (the button IS the element
* they are hovering — its hitbox grows with it). No jitter.
*
* ACTIVE SESSION
* ──────────────
* The currently-selected session (.session-active) reverts to height:auto
* in normal flow so its full (potentially very long) name is always visible
* without requiring any interaction. One expanded row in a 20-row list is
* visually negligible and gives the operator instant confirmation of where
* the room currently is.
*
* OVERLAY BACKGROUNDS
* ────────────────────
* preset-tonal-secondary is semi-transparent by design (~20% fill). Used as
* the overlay background it lets underlying rows bleed through, making the
* text hard to read — especially on projectors and in dark mode.
* The overlay therefore uses explicit opaque colours:
* Light: slate-100 (#f1f5f9) — matches standard card surface
* Dark: slate-800 (#1e293b) + slate-100 text — high contrast, WCAG AA
* A primary-accent left border provides a clear visual anchor so operators
* can instantly see which row is being previewed.
*/
/* ── Inactive row: fixed compact height ── */
.session-item {
height: 2rem;
}
/* ── Active row: always fully visible in flow ── */
.session-item.session-active {
height: auto;
min-height: 2rem;
}
/* Clip inactive button content to row height */
.session-btn {
overflow: hidden;
}
/* Active button: never clip — operator can always read it */
.session-item.session-active .session-btn {
overflow: visible;
}
/*
* Inactive hover/focus: pop button out as an overlay panel.
* - position:absolute keeps the <li> placeholder at 2rem (no layout shift)
* - Solid opaque background prevents tonal transparency from showing through
* - Left border accent gives a clear "you are here" cue
*/
.session-item:not(.session-active):hover .session-btn,
.session-item:not(.session-active):focus-within .session-btn {
position: absolute;
top: 0;
left: 0;
right: 0;
height: auto;
z-index: 20;
overflow: visible;
opacity: 1;
background-color: #f1f5f9; /* slate-100 — solid light surface */
border-left: 3px solid rgb(var(--color-primary-500, 99 102 241));
border-radius: 0.375rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* Dark mode overlay — solid dark surface, light readable text */
:global(.dark) .session-item:not(.session-active):hover .session-btn,
:global(.dark) .session-item:not(.session-active):focus-within .session-btn {
background-color: #1e293b; /* slate-800 */
color: #f1f5f9; /* slate-100 */
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6), 0 1px 4px rgba(0, 0, 0, 0.4);
}
/* ── Session name: single-line truncated at rest ── */
.session-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Active session: name always wraps fully */
.session-item.session-active .session-name {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
/* Overlay: name wraps fully (300-char titles readable) */
.session-item:not(.session-active):hover .session-name,
.session-item:not(.session-active):focus-within .session-name {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
</style>