feat(shared): non-blocking toast helper available to every tool

Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.

API:
  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. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.

Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.

This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.

Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 19:04:41 -05:00
parent 538167b5c8
commit 8ba029612e
15 changed files with 313 additions and 53 deletions

View file

@ -20,6 +20,7 @@ trap cleanup EXIT
# CSS files to concatenate in order # CSS files to concatenate in order
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
"css/components.css" \ "css/components.css" \
@ -38,6 +39,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/init.js" \ "js/init.js" \
"js/parser.js" \ "js/parser.js" \

View file

@ -20,6 +20,7 @@ trap cleanup EXIT
# CSS files: shared base first, then browse-specific. # CSS files: shared base first, then browse-specific.
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/base.css" \ "css/base.css" \
"css/tree.css" \ "css/tree.css" \
> "$css_temp" > "$css_temp"
@ -34,6 +35,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/init.js" \ "js/init.js" \

View file

@ -20,6 +20,7 @@ trap cleanup EXIT
# CSS files to concatenate in order # CSS files to concatenate in order
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
"css/spreadsheet.css" \ "css/spreadsheet.css" \
@ -37,6 +38,7 @@ concat_files \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \
"js/utils.js" \ "js/utils.js" \

View file

@ -28,38 +28,5 @@
cursor: pointer; cursor: pointer;
} }
/* ── Toast notifications (classifier-only) ───────────────────────────────── */ /* Toast notifications come from shared/toast.css (.zddc-toast); the
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */ classifier-local .toast block was promoted there. */
.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; }
}

View file

@ -6,26 +6,19 @@
'use strict'; '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') { function showToast(message, type = 'info') {
// Remove existing toast if (window.zddc && typeof window.zddc.toast === 'function') {
const existing = document.querySelector('.toast'); window.zddc.toast(message, type);
if (existing) { } else {
existing.remove(); // 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);
} }
/** /**

View file

@ -19,11 +19,13 @@ trap cleanup EXIT
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/form.css" \ "css/form.css" \
> "$css_temp" > "$css_temp"
concat_files \ concat_files \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/help.js" \ "../shared/help.js" \
"js/app.js" \ "js/app.js" \
"js/context.js" \ "js/context.js" \

View file

@ -19,6 +19,7 @@ trap cleanup EXIT
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/landing.css" \ "css/landing.css" \
> "$css_temp" > "$css_temp"
@ -26,6 +27,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/help.js" \ "../shared/help.js" \
"js/landing.js" \ "js/landing.js" \
> "$js_raw" > "$js_raw"

View file

@ -30,6 +30,7 @@ trap cleanup EXIT
concat_files \ concat_files \
"css/tailwind-utils.css" \ "css/tailwind-utils.css" \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/base.css" \ "css/base.css" \
"css/editor.css" \ "css/editor.css" \
"css/toc.css" \ "css/toc.css" \
@ -41,6 +42,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \
"js/utils.js" \ "js/utils.js" \

View file

@ -59,6 +59,10 @@ export default defineConfig({
name: 'zddc-source', name: 'zddc-source',
testMatch: 'zddc-source.spec.js', testMatch: 'zddc-source.spec.js',
}, },
{
name: 'toast',
testMatch: 'toast.spec.js',
},
{ {
name: 'zddc', name: 'zddc',
testMatch: 'zddc.spec.js', testMatch: 'zddc.spec.js',

40
shared/toast.css Normal file
View file

@ -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; }
}

63
shared/toast.js Normal file
View file

@ -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;
})();

View file

@ -19,6 +19,7 @@ trap cleanup EXIT
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/table.css" \ "css/table.css" \
"../form/css/form.css" \ "../form/css/form.css" \
> "$css_temp" > "$css_temp"
@ -33,6 +34,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/help.js" \ "../shared/help.js" \
"js/mode.js" \ "js/mode.js" \
"js/app.js" \ "js/app.js" \

67
tests/toast.spec.js Normal file
View file

@ -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);
});
});

View file

@ -23,6 +23,7 @@ trap cleanup EXIT
# CSS files to concatenate in order # CSS files to concatenate in order
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
"css/forms.css" \ "css/forms.css" \
@ -47,6 +48,7 @@ concat_files \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \
"js/reactive.js" \ "js/reactive.js" \

View file

@ -335,6 +335,11 @@ a:hover {
font-size: 1rem; 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. */ /* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */ /* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -525,6 +530,47 @@ body.help-open .app-header {
color: var(--text-muted); 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. */ /* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main { .table-main {
@ -967,7 +1013,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 23:35:43 · 3a4a1c7-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-10 00:02:54 · 538167b-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -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. * ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern. * Works with all four tools regardless of their module pattern.