diff --git a/archive/build.sh b/archive/build.sh index 6c7df42..b272996 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -20,6 +20,7 @@ trap cleanup EXIT # CSS files to concatenate in order concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/base.css" \ "css/layout.css" \ "css/components.css" \ @@ -38,6 +39,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/hash.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/preview-lib.js" \ "js/init.js" \ "js/parser.js" \ diff --git a/browse/build.sh b/browse/build.sh index 03e7f20..c9ec9b0 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -20,6 +20,7 @@ trap cleanup EXIT # CSS files: shared base first, then browse-specific. concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/base.css" \ "css/tree.css" \ > "$css_temp" @@ -34,6 +35,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/help.js" \ "../shared/preview-lib.js" \ "js/init.js" \ diff --git a/classifier/build.sh b/classifier/build.sh index 6bd5d95..c96ad81 100644 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -20,6 +20,7 @@ trap cleanup EXIT # CSS files to concatenate in order concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/base.css" \ "css/layout.css" \ "css/spreadsheet.css" \ @@ -37,6 +38,7 @@ concat_files \ "../shared/hash.js" \ "../shared/zddc-source.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ diff --git a/classifier/css/base.css b/classifier/css/base.css index 3274e62..ca3d4eb 100644 --- a/classifier/css/base.css +++ b/classifier/css/base.css @@ -28,38 +28,5 @@ cursor: pointer; } -/* ── Toast notifications (classifier-only) ───────────────────────────────── */ -/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */ -.toast { - position: fixed; - bottom: 2rem; - right: 2rem; - background: var(--bg); - color: var(--text); - padding: 0.875rem 1.25rem; - border-radius: var(--radius); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; - font-size: 0.875rem; - animation: zddc-toast-in 0.3s ease-out; -} - -.toast-success { border-left: 4px solid var(--success); } -.toast-error { border-left: 4px solid var(--danger); } -.toast-info { border-left: 4px solid var(--info); } -.toast-warning { border-left: 4px solid var(--warning); } - -.toast-fade { - animation: zddc-toast-out 0.3s ease-out forwards; -} - -@keyframes zddc-toast-in { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } -} - -@keyframes zddc-toast-out { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } -} +/* Toast notifications come from shared/toast.css (.zddc-toast); the + classifier-local .toast block was promoted there. */ diff --git a/classifier/js/excel.js b/classifier/js/excel.js index 97a48f7..916fc78 100644 --- a/classifier/js/excel.js +++ b/classifier/js/excel.js @@ -6,26 +6,19 @@ 'use strict'; /** - * Show toast notification + * Thin wrapper over the shared toast helper. Keeps the + * window.app.modules.excel.showToast call sites in classifier + * unchanged while delegating to the canonical implementation in + * shared/toast.js (window.zddc.toast). */ function showToast(message, type = 'info') { - // Remove existing toast - const existing = document.querySelector('.toast'); - if (existing) { - existing.remove(); + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(message, type); + } else { + // shared/toast.js missing from the build — log so the + // problem is visible without crashing the caller. + console.warn('[classifier] window.zddc.toast unavailable;', type, message); } - - // Create toast - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - document.body.appendChild(toast); - - // Auto-remove after 5 seconds - setTimeout(() => { - toast.classList.add('toast-fade'); - setTimeout(() => toast.remove(), 300); - }, 5000); } /** diff --git a/form/build.sh b/form/build.sh index 49cf4aa..8462443 100755 --- a/form/build.sh +++ b/form/build.sh @@ -19,11 +19,13 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/form.css" \ > "$css_temp" concat_files \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/help.js" \ "js/app.js" \ "js/context.js" \ diff --git a/landing/build.sh b/landing/build.sh index dfe43c0..0abf041 100755 --- a/landing/build.sh +++ b/landing/build.sh @@ -19,6 +19,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/landing.css" \ > "$css_temp" @@ -26,6 +27,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/help.js" \ "js/landing.js" \ > "$js_raw" diff --git a/mdedit/build.sh b/mdedit/build.sh index 19528c9..ac4c75c 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -30,6 +30,7 @@ trap cleanup EXIT concat_files \ "css/tailwind-utils.css" \ "../shared/base.css" \ + "../shared/toast.css" \ "css/base.css" \ "css/editor.css" \ "css/toc.css" \ @@ -41,6 +42,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/zddc-source.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ diff --git a/playwright.config.js b/playwright.config.js index ff8bb61..65e1818 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -59,6 +59,10 @@ export default defineConfig({ name: 'zddc-source', testMatch: 'zddc-source.spec.js', }, + { + name: 'toast', + testMatch: 'toast.spec.js', + }, { name: 'zddc', testMatch: 'zddc.spec.js', diff --git a/shared/toast.css b/shared/toast.css new file mode 100644 index 0000000..449adc9 --- /dev/null +++ b/shared/toast.css @@ -0,0 +1,40 @@ +/* shared/toast.css — single-toast notification styles paired with + shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions + with tool-local .toast classes; the old classifier rules can stay + alongside until this file is concatenated above them in the build. */ + +.zddc-toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg); + color: var(--text); + padding: 0.875rem 1.25rem; + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 9000; + max-width: 400px; + font-size: 0.875rem; + cursor: pointer; + animation: zddc-toast-in 0.3s ease-out; +} + +.zddc-toast--success { border-left: 4px solid var(--success); } +.zddc-toast--error { border-left: 4px solid var(--danger); } +.zddc-toast--info { border-left: 4px solid var(--info); } +.zddc-toast--warning { border-left: 4px solid var(--warning); } + +.zddc-toast--fade { + animation: zddc-toast-out 0.3s ease-out forwards; +} + +@keyframes zddc-toast-in { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes zddc-toast-out { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} diff --git a/shared/toast.js b/shared/toast.js new file mode 100644 index 0000000..6f2a7d6 --- /dev/null +++ b/shared/toast.js @@ -0,0 +1,63 @@ +// shared/toast.js — non-blocking notification helper available to every +// tool via window.zddc.toast(msg, level, opts). Originated as classifier's +// local showToast (classifier/js/excel.js); promoted here so tools that +// today use alert() or silent console.error can switch to a uniform +// non-blocking surface. +// +// Usage: +// window.zddc.toast('Saved.', 'success'); +// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// +// Levels: 'info' (default) | 'success' | 'warning' | 'error'. +// Each tool may also expose app.notify(msg, level) as a thin wrapper — +// see ARCHITECTURE.md for the convention. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + // Don't overwrite if a tool defined its own first. + if (typeof window.zddc.toast === 'function') return; + + var DEFAULT_DURATION_MS = 5000; + var FADE_MS = 300; + + function toast(message, level, opts) { + opts = opts || {}; + var lvl = (level === 'success' || level === 'error' || + level === 'warning') ? level : 'info'; + + // Single-toast policy: dismiss any existing toast immediately + // so the new one is always the most recent. Matches the + // classifier's prior behavior and avoids stack-of-toasts UX. + var existing = document.querySelector('.zddc-toast'); + if (existing) existing.remove(); + + var el = document.createElement('div'); + el.className = 'zddc-toast zddc-toast--' + lvl; + // ARIA: errors get assertive (interrupts SR queue), others polite. + el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); + el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); + el.textContent = message == null ? '' : String(message); + document.body.appendChild(el); + + var dur = typeof opts.durationMs === 'number' ? + opts.durationMs : DEFAULT_DURATION_MS; + var timer = setTimeout(function () { + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + }, FADE_MS); + }, dur); + + // Click-to-dismiss. Useful for sticky errors the user wants gone. + el.addEventListener('click', function () { + clearTimeout(timer); + if (el.parentNode) el.parentNode.removeChild(el); + }); + + return el; + } + + window.zddc.toast = toast; +})(); diff --git a/tables/build.sh b/tables/build.sh index 3ce5432..1aa90ea 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -19,6 +19,7 @@ trap cleanup EXIT concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/table.css" \ "../form/css/form.css" \ > "$css_temp" @@ -33,6 +34,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/zddc-source.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/help.js" \ "js/mode.js" \ "js/app.js" \ diff --git a/tests/toast.spec.js b/tests/toast.spec.js new file mode 100644 index 0000000..752be7a --- /dev/null +++ b/tests/toast.spec.js @@ -0,0 +1,67 @@ +// Tests for shared/toast.js — the cross-tool notification helper +// available as window.zddc.toast(msg, level, opts). Loaded into every +// tool's bundle by build.sh. +// +// Strategy: load any tool's dist HTML over file:// (browse is the +// smallest), trigger the helper, and assert DOM + ARIA shape. + +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const HTML_PATH = path.resolve('browse/dist/browse.html'); + +test.describe('shared/toast.js', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); + }); + + test('exposes window.zddc.toast(msg, level)', async ({ page }) => { + const exposed = await page.evaluate( + () => typeof window.zddc?.toast === 'function' + ); + expect(exposed).toBe(true); + }); + + test('renders a single toast with the level class and ARIA role', async ({ page }) => { + const after = await page.evaluate(() => { + window.zddc.toast('Saved.', 'success'); + const el = document.querySelector('.zddc-toast'); + return el && { + text: el.textContent, + level: [...el.classList].find(c => c.startsWith('zddc-toast--')), + role: el.getAttribute('role'), + live: el.getAttribute('aria-live'), + }; + }); + expect(after).toEqual({ + text: 'Saved.', + level: 'zddc-toast--success', + role: 'status', + live: 'polite', + }); + }); + + test('error level uses role=alert + aria-live=assertive', async ({ page }) => { + const probe = await page.evaluate(() => { + window.zddc.toast('Boom', 'error'); + const el = document.querySelector('.zddc-toast'); + return { role: el.getAttribute('role'), live: el.getAttribute('aria-live') }; + }); + expect(probe).toEqual({ role: 'alert', live: 'assertive' }); + }); + + test('a second toast replaces the first (single-toast policy)', async ({ page }) => { + const count = await page.evaluate(() => { + window.zddc.toast('one', 'info'); + window.zddc.toast('two', 'info'); + return document.querySelectorAll('.zddc-toast').length; + }); + expect(count).toBe(1); + }); + + test('clicking dismisses immediately', async ({ page }) => { + await page.evaluate(() => window.zddc.toast('click me', 'info')); + await page.locator('.zddc-toast').click(); + await expect(page.locator('.zddc-toast')).toHaveCount(0); + }); +}); diff --git a/transmittal/build.sh b/transmittal/build.sh index 4ed9acb..ebf75de 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -23,6 +23,7 @@ trap cleanup EXIT # CSS files to concatenate in order concat_files \ "../shared/base.css" \ + "../shared/toast.css" \ "css/base.css" \ "css/layout.css" \ "css/forms.css" \ @@ -47,6 +48,7 @@ concat_files \ "../shared/hash.js" \ "../shared/zddc-source.js" \ "../shared/theme.js" \ + "../shared/toast.js" \ "../shared/preview-lib.js" \ "js/app.js" \ "js/reactive.js" \ diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 1e4dc80..a8c17bb 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -335,6 +335,11 @@ a:hover { font-size: 1rem; } +/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */ +#refreshHeaderBtn { + font-size: 1.1rem; +} + /* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */ /* ── Theme and help icon buttons ─────────────────────────────────────────── */ @@ -525,6 +530,47 @@ body.help-open .app-header { color: var(--text-muted); } +/* shared/toast.css — single-toast notification styles paired with + shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions + with tool-local .toast classes; the old classifier rules can stay + alongside until this file is concatenated above them in the build. */ + +.zddc-toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg); + color: var(--text); + padding: 0.875rem 1.25rem; + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 9000; + max-width: 400px; + font-size: 0.875rem; + cursor: pointer; + animation: zddc-toast-in 0.3s ease-out; +} + +.zddc-toast--success { border-left: 4px solid var(--success); } +.zddc-toast--error { border-left: 4px solid var(--danger); } +.zddc-toast--info { border-left: 4px solid var(--info); } +.zddc-toast--warning { border-left: 4px solid var(--warning); } + +.zddc-toast--fade { + animation: zddc-toast-out 0.3s ease-out forwards; +} + +@keyframes zddc-toast-in { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes zddc-toast-out { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + /* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */ .table-main { @@ -967,7 +1013,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-09 23:35:43 · 3a4a1c7-dirty + v0.0.17-alpha · 2026-05-10 00:02:54 · 538167b-dirty
@@ -1965,6 +2011,70 @@ body.help-open .app-header { } }()); +// shared/toast.js — non-blocking notification helper available to every +// tool via window.zddc.toast(msg, level, opts). Originated as classifier's +// local showToast (classifier/js/excel.js); promoted here so tools that +// today use alert() or silent console.error can switch to a uniform +// non-blocking surface. +// +// Usage: +// window.zddc.toast('Saved.', 'success'); +// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// +// Levels: 'info' (default) | 'success' | 'warning' | 'error'. +// Each tool may also expose app.notify(msg, level) as a thin wrapper — +// see ARCHITECTURE.md for the convention. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + // Don't overwrite if a tool defined its own first. + if (typeof window.zddc.toast === 'function') return; + + var DEFAULT_DURATION_MS = 5000; + var FADE_MS = 300; + + function toast(message, level, opts) { + opts = opts || {}; + var lvl = (level === 'success' || level === 'error' || + level === 'warning') ? level : 'info'; + + // Single-toast policy: dismiss any existing toast immediately + // so the new one is always the most recent. Matches the + // classifier's prior behavior and avoids stack-of-toasts UX. + var existing = document.querySelector('.zddc-toast'); + if (existing) existing.remove(); + + var el = document.createElement('div'); + el.className = 'zddc-toast zddc-toast--' + lvl; + // ARIA: errors get assertive (interrupts SR queue), others polite. + el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); + el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); + el.textContent = message == null ? '' : String(message); + document.body.appendChild(el); + + var dur = typeof opts.durationMs === 'number' ? + opts.durationMs : DEFAULT_DURATION_MS; + var timer = setTimeout(function () { + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + }, FADE_MS); + }, dur); + + // Click-to-dismiss. Useful for sticky errors the user wants gone. + el.addEventListener('click', function () { + clearTimeout(timer); + if (el.parentNode) el.parentNode.removeChild(el); + }); + + return el; + } + + window.zddc.toast = toast; +})(); + /** * ZDDC shared help panel — open/close logic. * Works with all four tools regardless of their module pattern.