import { load, save } from './storage'; import * as FontSize from './features/font-size'; import * as Contrast from './features/contrast'; import * as Dyslexia from './features/dyslexia-font'; import * as Grayscale from './features/grayscale'; import * as ReadGuide from './features/reading-guide'; import * as Animations from './features/animations'; import * as LineHeight from './features/line-height'; import * as Cursor from './features/cursor-enlarger'; import * as Links from './features/highlight-links'; import * as ScreenMask from './features/screen-mask'; export interface WidgetConfig { enabled?: boolean; color: string; position: string; features: string[]; hide_powered_by?: boolean; button_size?: 'small' | 'medium' | 'large'; offset_vertical?: number; offset_horizontal?: number; } type ToggleState = Record; const FEATURE_LABELS: Record = { font: 'Font Size', contrast: 'High Contrast', dyslexia: 'Dyslexia Font', grayscale: 'Grayscale', 'reading-guide':'Reading Guide', animations: 'Pause Animations', 'line-height': 'Line Height', cursor: 'Large Cursor', links: 'Highlight Links', 'screen-mask': 'Screen Mask', }; const FEATURE_ICONS: Record = { font: 'Aa', contrast: '◑', dyslexia: '\u{1D4BB}', // 𝒻 script f — indicates special font grayscale: '◌', 'reading-guide': '☰', animations: '⏸', 'line-height': '↕', cursor: '↖', links: '⬡', 'screen-mask': '▤', }; const CSS = ` *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} /* Explicit cursor:auto reset — cursor is inherited, and the "Large Cursor" feature (cursor-enlarger.ts) sets a page-wide !important custom cursor on the host page. That inherited value would otherwise bleed into any element here that doesn't declare its own cursor (e.g. the panel header), making the oversized arrow appear to float over the widget's own controls. */ .w{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.4;color:#1e1e2e;cursor:auto} .fab{width:var(--fab-size,52px);height:var(--fab-size,52px);border-radius:50%;border:2px solid rgba(255,255,255,.25);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:24px;color:#fff;box-shadow:0 2px 14px rgba(0,0,0,.28);transition:transform .15s} .fab:hover{transform:scale(1.06)} .fab:focus-visible{outline:3px solid #fff;outline-offset:4px;box-shadow:0 0 0 7px rgba(255,255,255,.28)} .panel{position:absolute;width:292px;max-width:calc(100vw - 16px);background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.18);overflow:hidden;transition:opacity .15s,transform .15s,visibility 0s} .panel.hidden{opacity:0;visibility:hidden;pointer-events:none;transform:translateY(8px);transition:opacity .15s,transform .15s,visibility 0s .15s} .ph{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 11px;border-bottom:1px solid #e5e7eb} .pt{font-size:14px;font-weight:700} .xb{background:none;border:none;cursor:pointer;font-size:17px;color:#6b7280;padding:4px 6px;border-radius:6px;line-height:1} .xb:hover{background:#f3f4f6} .xb:focus-visible{outline:2px solid var(--c);outline-offset:2px;background:#f3f4f6} .fs{padding:6px 8px 8px;max-height:380px;overflow-y:auto} .fr{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:8px;transition:background .12s,box-shadow .12s} .fr:hover{background:#f0f4ff;box-shadow:inset 0 0 0 2px var(--c)} .fr.fr-toggle{cursor:pointer} .fr.fr-toggle:focus-visible{outline:2px solid var(--c);outline-offset:2px;background:#f0f4ff} .fi{font-size:20px;width:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;line-height:1;opacity:.75} .fl{font-size:13px;font-weight:500;flex:1} .tb{width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;position:relative;background:#d1d5db;flex-shrink:0;transition:background .18s} .tb[aria-pressed=true]{background:var(--c)} .tb::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.2)} .tb[aria-pressed=true]::after{transform:translateX(20px)} .tb:focus-visible{outline:2px solid var(--c);outline-offset:3px} .sbs{display:flex;align-items:center;gap:4px} .sb{padding:2px 9px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;font-weight:700;color:#374151;line-height:1.8} .sb:hover{background:#f3f4f6} .sb:focus-visible{outline:2px solid var(--c);outline-offset:2px} .sv{font-size:11px;color:#6b7280;min-width:26px;text-align:center} .pf{padding:8px 14px;border-top:1px solid #e5e7eb;text-align:center;font-size:10px;color:#9ca3af;letter-spacing:.02em} .pf a{color:#9ca3af;text-decoration:none;font-weight:600} .pf a:hover{color:var(--c)} `; function el( tag: K, attrs: Record = {}, cls = '' ): HTMLElementTagNameMap[K] { const e = document.createElement(tag); if (cls) e.className = cls; for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); return e; } const FAB_SIZE_MAP: Record = { small: '40px', medium: '52px', large: '64px' }; export function mountToolbar(shadow: ShadowRoot, config: WidgetConfig): void { const color = config.color || '#7c6aff'; const isTop = config.position.startsWith('top'); const isLeft = config.position.endsWith('left'); const fabSize = FAB_SIZE_MAP[config.button_size ?? 'medium'] ?? '52px'; // Inject styles const style = document.createElement('style'); style.textContent = CSS; shadow.appendChild(style); const wrapper = el('div', {}, 'w'); wrapper.style.setProperty('--c', color); wrapper.style.setProperty('--fab-size', fabSize); // FAB const fab = el('button', { 'aria-label': 'Accessibility options', 'aria-expanded': 'false', 'aria-haspopup': 'true', }, 'fab'); fab.style.background = color; fab.innerHTML = ''; // Panel — offset by fab size + 8px gap so it clears the FAB const fabPx = parseInt(fabSize, 10); const panelOff = `${fabPx + 8}px`; const panel = el('div', { role: 'dialog', 'aria-label': 'Accessibility toolbar' }, 'panel hidden'); panel.style.cssText = [ isTop ? `top:${panelOff}` : `bottom:${panelOff}`, isLeft ? 'left:0' : 'right:0', ].join(';'); // Panel header const header = el('div', {}, 'ph'); const title = el('span', {}, 'pt'); title.textContent = 'Accessibility'; title.style.color = color; const closeBtn = el('button', { 'aria-label': 'Close accessibility panel' }, 'xb'); closeBtn.textContent = '✕'; header.appendChild(title); header.appendChild(closeBtn); panel.appendChild(header); // Feature list const featureList = el('div', {}, 'fs'); panel.appendChild(featureList); // Footer if (!config.hide_powered_by) { const footer = el('div', {}, 'pf'); footer.innerHTML = 'Powered by AccessMate'; panel.appendChild(footer); } wrapper.appendChild(fab); wrapper.appendChild(panel); shadow.appendChild(wrapper); // ── State ────────────────────────────────────────────────── const prefs = load(); const state: ToggleState = {}; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Large Cursor replaces the mouse pointer via CSS `cursor:` — a no-op on a // touch-only device (no pointer to replace), so skip it there rather than // show a toggle that visibly does nothing. const noFinePointer = window.matchMedia('(hover: none) and (pointer: coarse)').matches; // ── Build feature controls ───────────────────────────────── // Render in FEATURE_LABELS' fixed declaration order (filtered to enabled // features), not config.features' stored order — that array's order drifts // over time as features are toggled off/on in the admin UI (re-enabling one // appends it to the end), which would otherwise scramble the on-site order // out of sync with the Widget Configurator's preview, which already uses // this same fixed-order approach. for (const id of Object.keys(FEATURE_LABELS)) { if (!config.features.includes(id)) continue; if (id === 'cursor' && noFinePointer) continue; const label = FEATURE_LABELS[id]; const row = el('div', {}, 'fr'); const ico = el('span', { 'aria-hidden': 'true' }, 'fi'); ico.textContent = FEATURE_ICONS[id] ?? '●'; const lbl = el('span', {}, 'fl'); lbl.textContent = label; row.appendChild(ico); row.appendChild(lbl); if (id === 'font') { const saved = Number(prefs['font-step'] ?? 0); FontSize.setStep(saved); const btns = el('div', {}, 'sbs'); const decBtn = el('button', { 'aria-label': 'Decrease font size' }, 'sb'); decBtn.textContent = 'A−'; const incBtn = el('button', { 'aria-label': 'Increase font size' }, 'sb'); incBtn.textContent = 'A+'; const stepVal = el('span', {}, 'sv'); stepVal.textContent = saved !== 0 ? `${saved > 0 ? '+' : ''}${saved}` : '—'; decBtn.addEventListener('click', () => { FontSize.decrease(); const s = FontSize.getStep(); stepVal.textContent = s !== 0 ? `${s > 0 ? '+' : ''}${s}` : '—'; updatePrefs('font-step', s); }); incBtn.addEventListener('click', () => { FontSize.increase(); const s = FontSize.getStep(); stepVal.textContent = s !== 0 ? `${s > 0 ? '+' : ''}${s}` : '—'; updatePrefs('font-step', s); }); btns.appendChild(decBtn); btns.appendChild(stepVal); btns.appendChild(incBtn); row.appendChild(btns); } else if (id === 'line-height') { const savedIdx = Number(prefs['line-height-step'] ?? 0); LineHeight.setStep(savedIdx); const LH_LABELS = ['Off', '1.5×', '1.8×', '2.0×']; const cycleBtn = el('button', { 'aria-label': 'Cycle line height' }, 'sb'); cycleBtn.style.minWidth = '44px'; cycleBtn.textContent = LH_LABELS[savedIdx] ?? 'Off'; cycleBtn.addEventListener('click', () => { const next = LineHeight.cycle(); const idx = LineHeight.getStep(); cycleBtn.textContent = next === 0 ? 'Off' : LH_LABELS[idx] ?? 'Off'; updatePrefs('line-height-step', idx); }); row.appendChild(cycleBtn); } else { // Standard toggle const savedOn = Boolean(prefs[id]); // Respect prefers-reduced-motion for reading-guide const startOn = id === 'reading-guide' && reducedMotion ? false : savedOn; state[id] = startOn; applyFeature(id, startOn); // Give the label element a unique id so the toggle button can reference it const lblId = `acm-lbl-${id}`; lbl.id = lblId; const btn = el('button', { 'aria-pressed': startOn ? 'true' : 'false', 'aria-labelledby': lblId, }, 'tb'); btn.addEventListener('click', () => { state[id] = !state[id]; const isOn = state[id]; btn.setAttribute('aria-pressed', isOn ? 'true' : 'false'); applyFeature(id, isOn); updatePrefs(id, isOn); }); row.appendChild(btn); // Make the entire row clickable — clicking the icon or label triggers // the toggle button, but we skip the event when it already originates // from the button itself to avoid double-firing. row.classList.add('fr-toggle'); row.setAttribute('role', 'group'); row.setAttribute('aria-label', label); row.addEventListener('click', (e: MouseEvent) => { if (e.target === btn || btn.contains(e.target as Node)) return; btn.click(); }); // Also allow Space / Enter on the row itself for keyboard users who // tab to it before reaching the inner button. row.setAttribute('tabindex', '-1'); // not in tab order itself; btn already is } featureList.appendChild(row); } // ── Panel toggle ──────────────────────────────────────────── const openPanel = () => { panel.classList.remove('hidden'); fab.setAttribute('aria-expanded', 'true'); closeBtn.focus(); }; const closePanel = () => { panel.classList.add('hidden'); fab.setAttribute('aria-expanded', 'false'); fab.focus(); }; fab.addEventListener('click', () => { panel.classList.contains('hidden') ? openPanel() : closePanel(); }); closeBtn.addEventListener('click', closePanel); // Close on outside click (clicks outside the shadow host) document.addEventListener('click', (e: MouseEvent) => { if (panel.classList.contains('hidden')) return; if (!shadow.host.contains(e.target as Node)) closePanel(); }); // Close on Escape key panel.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') closePanel(); }); } // ── Helpers ────────────────────────────────────────────────── function applyFeature(id: string, on: boolean): void { switch (id) { case 'contrast': Contrast.toggle(on); break; case 'dyslexia': Dyslexia.toggle(on); break; case 'grayscale': Grayscale.toggle(on); break; case 'reading-guide': ReadGuide.toggle(on); break; case 'animations': Animations.toggle(on); break; case 'cursor': Cursor.toggle(on); break; case 'links': Links.toggle(on); break; case 'screen-mask': ScreenMask.toggle(on); break; } } function updatePrefs(key: string, val: unknown): void { const prefs = load(); prefs[key] = val; save(prefs); }