refactor(ui): unify notice + toast styling, polish inline notices

Notices used to be styled only inside the toast stack — every inline
.notice on the login page, auth pages and admin edit screens fell back
to browser defaults and looked unstyled. The Stripe-style toast redesign
(icon pill + neutral text + soft card surface) now lives on the base
.notice rule, and .app-toast-stack adds the slide-in animation, soft
shadow, dismiss button and progress bar on top.

Inline notices auto-render the variant icon via a ::before pseudo using
the Bootstrap Icons codepoints, so all 30+ existing call sites get the
new look without markup changes. Toasts opt out of ::before via
:has(> .notice-icon) and keep their explicit icon span.

The previous app-flash.phtml partial is folded into app-toast-stack.phtml
(both create the same .app-toast-stack container — having both mounted
made two stacks fight for the same fixed corner). default/login/page
templates now mount the unified partial; the architecture contract is
updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 19:32:09 +02:00
parent 2429588256
commit 4890a280fb
8 changed files with 197 additions and 132 deletions

View File

@@ -46,6 +46,13 @@ const defaultTimeouts = {
const MAX_VISIBLE_TOASTS = 5;
const VARIANT_ICONS = {
success: 'bi-check-circle-fill',
error: 'bi-x-octagon-fill',
warning: 'bi-exclamation-triangle-fill',
info: 'bi-info-circle-fill',
};
/**
* Resolve the server-rendered toast stack container.
* Returns null if the partial is missing — callers must handle that case.
@@ -111,14 +118,26 @@ export function showAsyncFlash(type, message, timeout) {
notice.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite');
notice.setAttribute('aria-atomic', 'true');
const iconSpan = document.createElement('span');
iconSpan.className = 'notice-icon';
iconSpan.setAttribute('aria-hidden', 'true');
const iconElement = document.createElement('i');
iconElement.className = `bi ${VARIANT_ICONS[type] || VARIANT_ICONS.info}`;
iconSpan.appendChild(iconElement);
notice.appendChild(iconSpan);
const messageSpan = document.createElement('span');
messageSpan.className = 'notice-content';
messageSpan.textContent = message;
notice.appendChild(messageSpan);
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'notice-dismiss';
closeButton.setAttribute('aria-label', 'Dismiss');
closeButton.innerHTML = '<i class="bi bi-x-lg"></i>';
const closeIcon = document.createElement('i');
closeIcon.className = 'bi bi-x-lg';
closeButton.appendChild(closeIcon);
closeButton.addEventListener('click', () => dismissToast(notice));
notice.appendChild(closeButton);