The contrast pass found two things larger than itself, both recorded as not-fixed. This is them. The semantic colours were 'fixed across themes', and one fixed colour cannot clear 4.5:1 against both a near-black and a near-white surface: --yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and success and warning failed on dark and light both. They are split by the question they answer. A *fill* is 'what colour is a danger button' -- red in every theme, unchanged -- and a *text* colour is 'what colour is the word failed on this background', which is now per ramp. Every fill also carries a computed foreground. White on the default accent is 1.43:1, and the accent is a colour picker, so no fixed answer survives it: --yj-accent-fg and the four semantic -fg values are derived (white if white clears, else black), which keeps a red danger button white and flips a green or amber one to black. Two accent buttons took their foreground from --yj-bg-base, which inverts with the ramp -- that is exactly the white-on-yellow 'Apply (A)' the light theme showed. Accent used as text gets the same treatment through accentTextOn(), which mixes along the hue until it clears the ramp's surface and stops. On both dark ramps it returns the accent unchanged, so the dark themes are visually untouched by that half. Measured across three ramps and twelve views: 2237 nodes, 0 failing, against 110 on dark and 50 on light before. Borders, outlines and shadows were explicitly kept on the fill token -- a border is not text, and the first pass of the rewrite moved 30 of them by accident.
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
/**
|
||
* Where the app speaks: the three levels that are not tied to a panel.
|
||
*
|
||
* Mounted once, in `index.html`, next to the first-run wizard — it has
|
||
* to outlive every view, since the failure it reports is usually the
|
||
* reason the user is about to navigate somewhere else.
|
||
*
|
||
* `inline` is the fourth level and is not here: it belongs to the
|
||
* region that failed (`<inline-notice region="…">`).
|
||
*/
|
||
import { LitElement, css, html, nothing } from 'lit';
|
||
import { customElement, state } from 'lit/decorators.js';
|
||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||
|
||
import { notificationStore } from '@store/notification-store';
|
||
import { designTokens } from '../../styles/tokens.css';
|
||
import { nameDialogsIn } from '../../utils/name-dialog';
|
||
|
||
import { noticeStyles, renderNotice } from './notice';
|
||
|
||
@customElement('notification-host')
|
||
export class NotificationHost extends LitElement {
|
||
@state() private version = 0;
|
||
|
||
private unsubscribe?: () => void;
|
||
|
||
static override styles = [
|
||
designTokens,
|
||
noticeStyles,
|
||
css`
|
||
:host {
|
||
display: contents;
|
||
}
|
||
|
||
/* Below the header, not above the player bar: the bottom
|
||
band belongs to the player, whose own inline notice
|
||
floats there and grows upward by however many lines it
|
||
needs — at 800×600 a two-line one reached straight into
|
||
a bottom-anchored stack. */
|
||
.stack {
|
||
position: fixed;
|
||
right: 16px;
|
||
top: calc(4em + 12px);
|
||
z-index: 60;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: min(30em, calc(100vw - 32px));
|
||
pointer-events: none;
|
||
}
|
||
|
||
.stack .notice {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
wa-dialog::part(dialog) {
|
||
background: var(--yj-bg-surface, #212529);
|
||
color: var(--yj-text-primary, #fff);
|
||
}
|
||
|
||
.blocking-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.blocking-actions button {
|
||
background: var(--yj-bg-elevated, #343a40);
|
||
border: 1px solid var(--yj-border, #495057);
|
||
border-radius: 4px;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
padding: 6px 14px;
|
||
}
|
||
|
||
.blocking-actions .primary {
|
||
background: var(--yj-accent, #ffd43b);
|
||
border-color: var(--yj-accent, #ffd43b);
|
||
color: var(--yj-accent-fg, #000);
|
||
}
|
||
|
||
.blocking-detail {
|
||
color: var(--yj-text-tertiary, #868e96);
|
||
font-size: var(--yj-text-sm, 0.8125rem);
|
||
margin-top: 12px;
|
||
overflow-wrap: anywhere;
|
||
user-select: text;
|
||
}
|
||
`,
|
||
];
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
// Not a cached view: this element is mounted once and never
|
||
// navigated away from, so connection really is its lifetime.
|
||
this.unsubscribe = notificationStore.subscribe(() => {
|
||
this.version += 1;
|
||
});
|
||
}
|
||
|
||
override disconnectedCallback(): void {
|
||
super.disconnectedCallback();
|
||
this.unsubscribe?.();
|
||
}
|
||
|
||
private handlers = {
|
||
dismiss: (id: number) => notificationStore.dismiss(id),
|
||
act: (id: number) => notificationStore.runAction(id),
|
||
};
|
||
|
||
/** Blocking is one modal at a time, and it must be acknowledged. */
|
||
private renderBlocking() {
|
||
const notification = notificationStore.currentBlocking();
|
||
|
||
if (!notification) return nothing;
|
||
|
||
return html`
|
||
<wa-dialog
|
||
open
|
||
label=${notification.title ?? 'Something needs your attention'}
|
||
data-testid="notification-blocking"
|
||
@wa-hide=${() => notificationStore.dismiss(notification.id)}
|
||
>
|
||
<p>${notification.text}</p>
|
||
${notification.detail
|
||
? html`<p class="blocking-detail">${notification.detail}</p>`
|
||
: nothing}
|
||
<div class="blocking-actions" slot="footer">
|
||
${notification.action
|
||
? html`
|
||
<button
|
||
type="button"
|
||
data-testid="notification-action"
|
||
@click=${() =>
|
||
notificationStore.runAction(notification.id)}
|
||
>
|
||
${notification.action.label}
|
||
</button>
|
||
`
|
||
: nothing}
|
||
<button
|
||
type="button"
|
||
class="primary"
|
||
@click=${() => notificationStore.dismiss(notification.id)}
|
||
>
|
||
OK
|
||
</button>
|
||
</div>
|
||
</wa-dialog>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Web Awesome renders `label` into a heading it never points the
|
||
* `<dialog>` at, so the dialog has no accessible name until
|
||
* something sets one. See `utils/name-dialog.ts`.
|
||
*/
|
||
override updated() {
|
||
nameDialogsIn(this.shadowRoot);
|
||
}
|
||
|
||
override render() {
|
||
const stacked = [
|
||
...notificationStore.byLevel('persistent'),
|
||
...notificationStore.byLevel('transient'),
|
||
];
|
||
|
||
return html`
|
||
${this.renderBlocking()}
|
||
${stacked.length === 0
|
||
? nothing
|
||
: html`
|
||
<div
|
||
class="stack"
|
||
role="status"
|
||
aria-live="polite"
|
||
data-testid="notification-stack"
|
||
>
|
||
${stacked.map((n) => renderNotice(n, this.handlers))}
|
||
</div>
|
||
`}
|
||
`;
|
||
}
|
||
}
|