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; } 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} .w{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.4;color:#1e1e2e} .fab{width:52px;height: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;background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.18);overflow:hidden;transition:opacity .15s,transform .15s} .panel.hidden{display:none} .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; } 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'); // Inject styles const style = document.createElement('style'); style.textContent = CSS; shadow.appendChild(style); const wrapper = el('div', {}, 'w'); wrapper.style.setProperty('--c', color); // FAB const fab = el('button', { 'aria-label': 'Accessibility options', 'aria-expanded': 'false', 'aria-haspopup': 'true', }, 'fab'); fab.style.background = color; fab.innerHTML = ''; // Panel const panel = el('div', { role: 'dialog', 'aria-label': 'Accessibility toolbar' }, 'panel hidden'); panel.style.cssText = [ isTop ? 'top:60px' : 'bottom:60px', 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; // ── Build feature controls ───────────────────────────────── for (const id of config.features) { const label = FEATURE_LABELS[id]; if (!label) continue; 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-label': `${startOn ? 'Disable' : 'Enable'} ${label}`, 'aria-labelledby': lblId, }, 'tb'); btn.addEventListener('click', () => { state[id] = !state[id]; const isOn = state[id]; btn.setAttribute('aria-pressed', isOn ? 'true' : 'false'); btn.setAttribute('aria-label', `${isOn ? 'Disable' : 'Enable'} ${label}`); 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); }