` to share the same theme, font and * size on boot. * * Pre-canonical tools (those without a Bundle Builder–style shell or CSS * that doesn't read --app-font / --app-font-size) need to be REWRITTEN * to match the canon — see `~/Notes/+Inbox/GMIAU pre-canon tool rewrites.md`. * Don't retrofit a foreign Settings UI onto them; that was tried and * rolled back 2026-05-09. * * What this module does: * • Reads the canonical localStorage keys Bundle Builder's Settings tab writes: * ifyi_mode_pref 'system' | 'light' | 'dark' * ifyi_dark_family 'gmiau' | 'dracula-pro' | 'dracula-pro-buffy' * ifyi_app_font_family 'system' | 'helvetica' | 'times' | 'mono' * ifyi_app_font_size '12'..'18' (px, as string) * ifyi_hide_guide_tab '0' | '1' (STYLE-GUIDE §2C, 2026-05-13) * ifyi_hide_config_tab '0' | '1' (STYLE-GUIDE §2C, 2026-05-13) * • Resolves Mode × Family → effective theme id and calls * ifyiTheme.apply(). * • Sets `--app-font` / `--app-font-size` CSS vars on :root. Tools that * follow the canon read those vars in their own CSS. * • Adds / removes `ifyi-hide-guide-tab` and `ifyi-hide-config-tab` * classes on based on the two visibility keys; tools' own CSS * hides the matching tab buttons + panels. * • If the currently-active tab is being hidden, calls * window.showTab(window.IFYI_PRIMARY_TAB) to fall back to the * tool's primary tab — so the body never goes blank. * • Rewrites `` hrefs to the correct relative path * for the launcher (../index.html from offline/ or source/, index.html * in the share-bundle flat layout). * • Listens to the `storage` event for cross-tab live sync (same-origin * tabs only — file:// URLs each get a per-file origin). * • Listens to `prefers-color-scheme` so `mode='system'` tracks OS flips. * * Depends on `ifyiTheme.apply()` — sentinel order in must be * `@@IFYI_THEME@@` → `@@IFYI_SETTINGS@@`. */ (function(){ 'use strict'; const APP_FONT_STACKS = { system: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', helvetica: 'Helvetica, Arial, sans-serif', times: '"Times New Roman", Times, serif', mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', }; const KEYS = { mode: 'ifyi_mode_pref', family: 'ifyi_dark_family', appFont: 'ifyi_app_font_family', appSize: 'ifyi_app_font_size', theme: 'ifyi_theme', // resolved id; written by ifyiTheme.apply hideGuide: 'ifyi_hide_guide_tab', // '0' | '1' (STYLE-GUIDE §2C) hideConfig: 'ifyi_hide_config_tab', // '0' | '1' (STYLE-GUIDE §2C) }; const _ls = (k) => { try { return localStorage.getItem(k); } catch(e){ return null; } }; const _systemDarkMq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; function effectiveThemeId(){ const pref = _ls(KEYS.mode) || 'system'; const family = _ls(KEYS.family) || 'gmiau'; const mode = (pref === 'light' || pref === 'dark') ? pref : ((_systemDarkMq && _systemDarkMq.matches) ? 'dark' : 'light'); if (mode === 'light') return 'ifyi-light'; if (family === 'gmiau') return 'ifyi-dark'; return family; // 'dracula-pro' | 'dracula-pro-buffy' } function applyTheme(){ if (window.ifyiTheme && typeof window.ifyiTheme.apply === 'function'){ window.ifyiTheme.apply(effectiveThemeId(), false); } } function applyAppFont(){ const key = _ls(KEYS.appFont) || 'system'; const stack = APP_FONT_STACKS[key] || APP_FONT_STACKS.system; // Default 20 px matches every tool's per-tool Settings-UI default // (the `20` button is shown active in the App font size row when // no caseworker preference is saved). Previously 14, which clashed: // tools would draw the UI with 20 active but ifyi-settings.js // would overwrite `--app-font-size` to 14 at DOMContentLoaded, // forcing the user to click another size + back to 20 before the // page actually rendered at 20. Aligned 2026-05-18 on user report. const saved = _ls(KEYS.appSize); const n = Number(saved); // Responsive default when no preference saved: 16 px on mobile, 20 px on laptop. const dflt = (window.innerWidth && window.innerWidth <= 600) ? 16 : 20; const px = (saved && Number.isFinite(n) && n >= 10 && n <= 24) ? n : dflt; document.documentElement.style.setProperty('--app-font', stack); document.documentElement.style.setProperty('--app-font-size', px + 'px'); } // ── Visibility toggles (STYLE-GUIDE §2C, 2026-05-13) ──────────────────── function applyVisibility(){ const root = document.documentElement; const hideGuide = _ls(KEYS.hideGuide) === '1'; const hideConfig = _ls(KEYS.hideConfig) === '1'; root.classList.toggle('ifyi-hide-guide-tab', hideGuide); root.classList.toggle('ifyi-hide-config-tab', hideConfig); // If the currently-active tab is being hidden, fall back to the // tool's primary tab. Tools register their primary id by setting // window.IFYI_PRIMARY_TAB = '' before this module's apply runs. const fallback = window.IFYI_PRIMARY_TAB; if (!fallback || typeof window.showTab !== 'function') return; const activePanel = document.querySelector('.tab-panel.active'); if (!activePanel) return; const id = activePanel.id || ''; if ((hideGuide && id === 'tab-guide') || (hideConfig && id === 'tab-config')) { try { window.showTab(fallback); } catch (e) { /* tool not ready yet */ } } } // ── Index back-link href rewriter (STYLE-GUIDE §2A) ───────────────────── // points at the launcher (immigrationfyi-tools/index.html). // Tools live in three layouts: // - dev: immigrationfyi-tools/source/.html → ../index.html // - install: immigrationfyi-tools/offline/-offline.html → ../index.html // - share bundle: /-offline.html → index.html function indexHref(){ const path = (location && location.pathname) || ''; if (path.includes('/offline/') || path.includes('/source/')) return '../index.html'; // Deployed Pages layout: each tool is //index.html, launcher at site root → go up one. if (path.endsWith('/') || /\/index\.html$/.test(path)) return '../'; return 'index.html'; } function applyIndexLinks(){ const href = indexHref(); document.querySelectorAll('a[data-index-link]').forEach(a => { a.href = href; }); } // ── Sister-tool href rewriter (STYLE-GUIDE §2A, paired-tool canon) ────── // Paired-tool sister links use ``, // where the bare filename works in `source/` (the sister file is a sibling) // but 404s in `offline/` (where the sister is `-offline.html`) and // in the flat share / GitHub-Pages bundle (same -offline filename). // // Detect "offline layout" by the CURRENT FILE's name, not the URL path. // Earlier path-based version (`includes('/offline/') || !includes('/source/')`) // misfired for dev servers that serve source files at URL root // (immigrationfyi-tools.local/auditor.html — neither /source/ nor /offline/ in path): // that version returned true for source mode, breaking the sister-link. // // Index links share the .sister-tool class but already get rewritten via // data-index-link, so exclude them here. function inOfflineLayout(){ const path = (location && location.pathname) || ''; const filename = path.split('/').pop() || ''; return /-offline\.html$/i.test(filename); } function applySisterLinks(){ if (!inOfflineLayout()) return; document.querySelectorAll('a.sister-tool:not([data-index-link])').forEach(a => { const href = a.getAttribute('href') || ''; // Only rewrite plain ".html" → "-offline.html". Skip if // someone has already pointed at the offline file or used an absolute URL. if (/^[^/?#]+\.html$/i.test(href) && !/-offline\.html$/i.test(href)){ a.href = href.replace(/\.html$/i, '-offline.html'); } }); } // ── Cross-tool toggle-`.active` sync (added 2026-05-18) ──────────────── // Every GMIAU Shell tool's Settings tab uses the same six canonical // matter-toggles. When a setting changes in another tab, applyTheme / // applyAppFont / applyVisibility above re-apply the CSS variables and // theme class on THIS tab — but the matter-toggle button's `.active` // class doesn't update automatically. syncToggles() walks the // canonical toggle ids on this page and re-sets each button's `.active` // class to match the current localStorage value. Tools that don't // include a particular toggle id are silently skipped. const CANONICAL_TOGGLES = [ { id: 'app-font-family-toggle', key: KEYS.appFont, dflt: 'system' }, { id: 'app-font-size-toggle', key: KEYS.appSize, dflt: '20' }, { id: 'app-mode-toggle', key: KEYS.mode, dflt: 'system' }, { id: 'app-theme-family-toggle', key: KEYS.family, dflt: 'gmiau' }, { id: 'hide-guide-toggle', key: KEYS.hideGuide, dflt: '0' }, { id: 'hide-config-toggle', key: KEYS.hideConfig, dflt: '0' }, ]; function syncToggles(){ for (const t of CANONICAL_TOGGLES){ const root = document.getElementById(t.id); if (!root) continue; const val = _ls(t.key) || t.dflt; root.querySelectorAll('.matter-btn').forEach(b => { b.classList.toggle('active', String(b.dataset.val) === String(val)); }); } } // Expose so tools can call after their own non-localStorage changes // (rare — the per-tool wireMatterToggle already updates locally). window.gmiauSyncToggles = syncToggles; // ── Combined appearance toggle (single-axis, 2026-05-20) ──────────────── // One #app-theme-toggle replaces the old Mode + Theme-family pair: // ☀ Light · 🌙 Dark · 🧛 Ryan (free Dracula) · secret 🧛 Dracula PRO · 🦇 Buffy. // It writes the SAME ifyi_mode_pref + ifyi_dark_family keys, so // effectiveThemeId() above is unchanged. Tools just swap their Settings // markup; their old per-tool wireMatterToggle('app-mode-toggle'…) calls // no-op once the old toggle ids are gone. PRO/Buffy stay hidden unless the // user unlocks them by visiting with #ryan (persists; #noryan relocks). const THEME_CHOICE = { light: { mode:'light', family:'gmiau' }, dark: { mode:'dark', family:'gmiau' }, ryan: { mode:'dark', family:'dracula' }, pro: { mode:'dark', family:'dracula-pro' }, buffy: { mode:'dark', family:'dracula-pro-buffy' }, }; const PRO_KEY = 'ifyi_pro_unlocked'; function currentChoice(){ const pref = _ls(KEYS.mode) || 'system'; const mode = (pref === 'light' || pref === 'dark') ? pref : ((_systemDarkMq && _systemDarkMq.matches) ? 'dark' : 'light'); if (mode === 'light') return 'light'; const fam = _ls(KEYS.family) || 'gmiau'; if (fam === 'dracula') return 'ryan'; if (fam === 'dracula-pro') return 'pro'; if (fam === 'dracula-pro-buffy') return 'buffy'; return 'dark'; } function applyProUnlock(){ let on = _ls(PRO_KEY) === '1'; const h = (location.hash || '').toLowerCase(); if (h === '#ryan'){ on = true; try { localStorage.setItem(PRO_KEY, '1'); } catch(e){} try { history.replaceState(null, '', location.pathname + location.search); } catch(e){} } else if (h === '#noryan'){ on = false; try { localStorage.setItem(PRO_KEY, '0'); } catch(e){} try { history.replaceState(null, '', location.pathname + location.search); } catch(e){} } document.documentElement.classList.toggle('gmiau-pro-unlocked', on); if (!on){ const fam = _ls(KEYS.family); if (fam === 'dracula-pro' || fam === 'dracula-pro-buffy'){ try { localStorage.setItem(KEYS.family, 'dracula'); } catch(e){} } } } function injectSecretCss(){ if (document.getElementById('gmiau-secret-css')) return; const s = document.createElement('style'); s.id = 'gmiau-secret-css'; s.textContent = 'html:not(.gmiau-pro-unlocked) .gmiau-secret{display:none!important}'; (document.head || document.documentElement).appendChild(s); } function syncThemeToggle(){ const root = document.getElementById('app-theme-toggle'); if (!root) return; const c = currentChoice(); root.querySelectorAll('.matter-btn').forEach(b => b.classList.toggle('active', b.dataset.val === c)); } let _themeToggleWired = false; function wireThemeToggle(){ const root = document.getElementById('app-theme-toggle'); if (!root) return; if (!_themeToggleWired){ _themeToggleWired = true; root.addEventListener('click', (e) => { const btn = e.target.closest && e.target.closest('.matter-btn'); if (!btn || !root.contains(btn)) return; const c = THEME_CHOICE[btn.dataset.val] || THEME_CHOICE.light; try { localStorage.setItem(KEYS.mode, c.mode); localStorage.setItem(KEYS.family, c.family); } catch(err){} applyTheme(); syncThemeToggle(); }); } syncThemeToggle(); } function applyAll(){ injectSecretCss(); applyProUnlock(); applyTheme(); applyAppFont(); applyVisibility(); applyIndexLinks(); applySisterLinks(); syncToggles(); // initial pass so toggles match localStorage on load wireThemeToggle(); } // Cross-tab live sync — change a setting in any tool, every other open // tab on the same origin re-applies via the `storage` event AND // re-syncs its toggle .active highlights so the Settings UI stays // consistent with what's saved. window.addEventListener('storage', (e) => { if (!e.key) return; if (e.key === KEYS.mode || e.key === KEYS.family || e.key === KEYS.theme) applyTheme(); if (e.key === KEYS.appFont || e.key === KEYS.appSize) applyAppFont(); if (e.key === KEYS.hideGuide || e.key === KEYS.hideConfig) applyVisibility(); syncToggles(); }); // System dark-mode change (only matters when mode pref is 'system'). if (_systemDarkMq){ const onSysChange = () => { const pref = _ls(KEYS.mode); if (!pref || pref === 'system') applyTheme(); }; if (_systemDarkMq.addEventListener) _systemDarkMq.addEventListener('change', onSysChange); else if (_systemDarkMq.addListener) _systemDarkMq.addListener(onSysChange); } // Defer first apply to DOMContentLoaded — ifyiTheme.apply() writes // document.body.classList, which is null while we're still in . if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', applyAll, { once: true }); } else { applyAll(); } // For callers that mutated localStorage and want a manual re-apply. window.ifyiSettingsApply = applyAll; // ── Translate + Donate header buttons ────────────────────────────────────── // Injected into the tool
, governed by the launcher's shared keys // (ifyi_hide_translate / ifyi_donate). Reuses the existing .sister-tool style. const _DONATE = { gmiau: { label: 'Donate to GMIAU', href: 'https://gmiau.org/donate/' }, rtr: { label: 'Donate to Right to Remain', href: 'https://righttoremain.org.uk/donate/' } }; // Header layout (2026-05-22): the title + any sister-tool links + the ■ Index // back-link stay LEFT (Index is the last left item); 🌐 Translate + Donate are // the only right-aligned actions, styled as .hbtn pills to MATCH the launcher. function injectHeaderCss(){ if (document.getElementById('gmiau-header-css')) return; const s = document.createElement('style'); s.id = 'gmiau-header-css'; s.textContent = // Force the header into a flex row so the ■ Index back-link stays the // last LEFT item and the Translate/Donate actions push right via // margin-left:auto. Tools' own header CSS varied (some text-align:center, // some not flex at all) — this guarantees the layout suite-wide. 'header{display:flex;align-items:center;gap:12px;flex-wrap:wrap}' + 'header h1{margin-right:0!important}' + 'header .gmiau-hdr-actions{margin-left:auto;display:flex;gap:10px;align-items:center;flex-wrap:wrap}' + 'header .hbtn.gmiau-hdr-action{font-size:.8em;font-weight:700;letter-spacing:.02em;text-decoration:none;color:var(--accent);border:1px solid var(--accent);border-radius:5px;padding:5px 13px;white-space:nowrap;text-transform:none;transition:background .15s,color .15s}' + 'header .hbtn.gmiau-hdr-action:hover{background:var(--accent);color:var(--bg)}' + 'body.light header .hbtn.gmiau-hdr-action:hover,body.theme-ifyi-light header .hbtn.gmiau-hdr-action:hover{color:#fff}' + // Universal matter-toggle: every toggle fills its row with equal, centred // buttons (STYLE-GUIDE §8.1, generalised from Settings to all contexts, // 2026-05-22). !important so it wins over each tool's compact inline-flex base. '.matter-toggle{display:flex!important;width:100%!important;height:auto!important}' + '.matter-btn{flex:1 1 0!important;min-width:0!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;text-align:center!important;line-height:1.4!important;padding:0.5em 0.71em!important}'; (document.head || document.documentElement).appendChild(s); } function injectHeaderActions(){ const hdr = document.querySelector('header'); if (!hdr) return; injectHeaderCss(); // Remove any prior actions (wrapper + legacy unwrapped links). hdr.querySelectorAll('.gmiau-hdr-actions, .gmiau-hdr-action').forEach((n) => n.remove()); const wrap = document.createElement('div'); wrap.className = 'gmiau-hdr-actions'; const mk = (cls, href, txt) => { const a = document.createElement('a'); a.className = 'hbtn gmiau-hdr-action ' + cls; a.href = href; a.textContent = txt; a.target = '_blank'; a.rel = 'noopener'; return a; }; if ((_ls('ifyi_hide_translate') || '0') !== '1'){ const u = location.href.replace(/^https?:\/\//, ''); wrap.appendChild(mk('gmiau-translate', 'https://translate.kagi.com/' + u, '🌐 Translate')); } const don = _ls('ifyi_donate') || 'gmiau'; if (don !== 'off' && _DONATE[don]) wrap.appendChild(mk('gmiau-donate', _DONATE[don].href, _DONATE[don].label)); if (wrap.children.length) hdr.appendChild(wrap); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderActions, { once: true }); else injectHeaderActions(); window.addEventListener('storage', (e) => { if (e.key === 'ifyi_donate' || e.key === 'ifyi_hide_translate') injectHeaderActions(); }); })();

■ Database

■ Database ■ Self-Grant ■ Tools