diff --git a/src/app.css b/src/app.css index 1adb651..ada33c0 100644 --- a/src/app.css +++ b/src/app.css @@ -32,10 +32,75 @@ body { background: var(--bg-space); } +/* + * Focus indicators. These used to live in SkillTree.svelte's *scoped* style + * block, so Svelte's scoping meant they never reached NodeModal or StatsPanel + * children — those fell back to the UA default outline, which is low-contrast + * on this background. Global is the only place this rule works. + */ +:focus-visible { + outline: 2px solid var(--color-gold); + outline-offset: 2px; + border-radius: 2px; +} + +/* + * Reduced motion. The JS side (see config.prefersReducedMotion) skips the + * entrance and never creates the ambient GSAP loops; this covers the CSS + * animations and transitions those can't reach. + */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* * Visually-hidden but available to screen readers and crawlers. Used for the * text version of the portfolio (the interactive content is canvas-rendered). */ +/* + * ...but focusable content inside it must become visible when focused, or a + * sighted keyboard user tabs through ~20 invisible stops with no idea where + * focus is (WCAG 2.4.7). Standard sr-only-focusable treatment. + */ +.sr-only:focus-within { + position: fixed; + top: 0; + left: 0; + z-index: 2000; + width: auto; + height: auto; + max-width: min(90vw, 60rem); + max-height: 90vh; + margin: 0; + overflow: auto; + clip: auto; + white-space: normal; + padding: 1.5rem; + background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%); + border: 1px solid var(--color-gold); + border-radius: 0 0 8px 0; + color: #e8e8f0; + line-height: 1.6; +} + +.sr-only:focus-within a { + color: var(--color-gold); +} + +.sr-only:focus-within h1, +.sr-only:focus-within h2, +.sr-only:focus-within h3 { + color: var(--color-gold); + margin: 1rem 0 0.5rem; +} + .sr-only { position: absolute; width: 1px; diff --git a/src/lib/components/NodeModal.svelte b/src/lib/components/NodeModal.svelte index 6777124..5e8aeb4 100644 --- a/src/lib/components/NodeModal.svelte +++ b/src/lib/components/NodeModal.svelte @@ -2,28 +2,84 @@ import type { TreeNode } from '$lib/data'; import { categoryColorHex } from '$lib/theme'; - const { node, visible, onClose }: { node: TreeNode; visible: boolean; onClose: () => void } = - $props(); + const { + node, + visible, + onClose, + onDismiss + }: { + node: TreeNode; + visible: boolean; + /** Explicit close (✕ button) — always immediate. */ + onClose: () => void; + /** Outside/backdrop dismissal — subject to the mobile touch-bleed guard. */ + onDismiss: () => void; + } = $props(); + + let dialogEl = $state(null); + + const FOCUSABLE = + 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'; + + /** + * Move focus into the dialog on open and restore it on close. + * + * Without this, `aria-modal="true"` was lying: it tells assistive tech that + * everything outside the dialog does not exist, while focus stayed on the + * canvas behind it and Tab walked the whole hidden text version underneath. + */ + $effect(() => { + if (!visible || !dialogEl) return; + + const previouslyFocused = document.activeElement as HTMLElement | null; + const first = dialogEl.querySelector(FOCUSABLE); + (first ?? dialogEl).focus({ preventScroll: true }); + + return () => previouslyFocused?.focus?.({ preventScroll: true }); + }); + + /** Keep Tab inside the dialog while it's open. */ + function trapFocus(e: KeyboardEvent) { + if (e.key !== 'Tab' || !dialogEl) return; + + const items = [...dialogEl.querySelectorAll(FOCUSABLE)].filter( + (el) => el.offsetParent !== null || el === document.activeElement + ); + if (!items.length) return; + + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + + if (e.shiftKey && (active === first || active === dialogEl)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } -