Modals, toasts and notices

Overlays and feedback, the modal and confirm dialog, the toast stack, the three-level notice pattern, popover menus, tooltips, and inline impact signals.

Saga has one way to interrupt (the modal), one way to inform without interrupting (the toast), and one shared vocabulary for telling an admin that an edit reached past the screen (notice, warning, confirmation). This chapter is the look and the behaviour contract for each; the vanilla primitives in Saga Connect (modal.ts, toast.ts, popover-menu.ts) and the dashboard's core-ui components implement the same contract.

A modal is a white card on a navy scrim, mounted when open and removed when closed. The host decides when; the modal only reports a close request.

Part Treatment
Scrim --modal-scrim (navy at 40%), full viewport, --z-overlay; fades in over --motion-fade
Card .saga-modal: white, --radius-lg (8px), --shadow-modal, --z-modal; enters with .overlay-in (98% scale and an 8px rise over --motion-base), fade only under reduced motion
Sizes default 640px; .saga-modal--confirm 440px; .saga-modal--large 960px; .saga-modal--fullscreen fills the viewport with no radius. All cap at 100% width and 92vh height
Position vertically and horizontally centred in the scrim (margin: auto); the body scrolls inside the card so a modal that grows past 92vh does not jump
Header .saga-modal__header: .saga-modal__title in --text-lg --weight-semibold --color-text, a quiet ✕ icon button (.saga-modal__close) at the right, --space-md --space-lg padding, 1px --color-border rule below
Body .saga-modal__body: --space-lg padding, scrolls on its own when tall
Footer .saga-modal__footer: --space-md --space-lg padding, --color-bg-secondary fill with a hairline above; buttons --space-sm apart, right-aligned inside .saga-modal__footer-end (see buttons)

Behaviour, all of it required:

Multi-step modals (dashboard_configure_period.png) put the step strip (.saga-steps > .saga-step) directly under the header, full width: each step is a numbered --space-lg circle (.saga-step__num, counted by CSS when left empty) and a label. The current step (aria-current="step") has a --color-primary filled circle with a white digit and a 2px blue underline across its whole segment; completed steps (data-complete) show a check in a --color-success circle; future steps are grey on a --color-bg-secondary band (DS-109). The title row reads "Period 2 | Configure Period" with the subject in blue (<em> inside .saga-modal__title) and the task in --color-text. The footer holds Cancel and Continue; the last step's primary says what it does.

Prefer a native <dialog class="saga-modal">: showModal() gives the scrim through ::backdrop, focus trapping and Esc for free. The same classes work on a div inside .saga-scrim when the dialog must stay in the page's stacking context (a framed shell, a prototype scene); then .saga-scrim--local covers the nearest positioned ancestor instead of the viewport.

<dialog class="saga-modal" id="edit-session" aria-labelledby="m1">
  <form method="dialog" style="display: contents">
    <div class="saga-modal__header">
      <h2 class="saga-modal__title" id="m1">Edit Session</h2>
      <button class="saga-btn saga-btn--quiet saga-btn--icon saga-modal__close" aria-label="Close" value="cancel">
        <iconify-icon icon="material-symbols-light:close-rounded"></iconify-icon>
      </button>
    </div>
    <div class="saga-modal__body">...</div>
    <div class="saga-modal__footer">
      <div class="saga-modal__footer-end">
        <button class="saga-btn saga-btn--secondary" value="cancel">Cancel</button>
        <button class="saga-btn saga-btn--primary" value="save">Save</button>
      </div>
    </div>
  </form>
</dialog>

Confirm dialog#

A confirm is a small modal (.saga-modal.saga-modal--confirm, 440px) with a question for a title, one short paragraph in --color-text-secondary, and two buttons. It uses role="alertdialog". The safe button (Cancel, Never mind) is focused on open, not the confirming one.

Variant Confirm button Extra
Danger .saga-btn--danger, labelled with the verb and noun: "Delete session", "Yes, cancel this session" default
Neutral .saga-btn--primary for a non-destructive but weighty step
Caution .saga-btn--caution: --color-warning-strong amber with white text (--color-warning itself is 3.2:1 under white, DS-112) a --space-xs --color-warning-border top edge on the card; "allowed, but this has consequences"

When the confirm is about losing things, the body is a loss ledger: a bulleted list naming each thing that will be removed ("Pods in Group 202: 3"), so the admin reads exactly what goes. A confirm mounted inside another modal scrims only that modal's card, not the whole page: a .saga-scrim.saga-scrim--local.saga-scrim--strong inside the positioned modal card.

<div class="saga-modal saga-modal--confirm" role="alertdialog" aria-modal="true" aria-labelledby="c1" aria-describedby="c1-body">
  <div class="saga-modal__header"><h2 class="saga-modal__title" id="c1">Delete this session?</h2></div>
  <div class="saga-modal__body"><p class="text-secondary" id="c1-body">Students will lose their place in Pod 6. This cannot be undone.</p></div>
  <div class="saga-modal__footer">
    <div class="saga-modal__footer-end">
      <button class="saga-btn saga-btn--secondary" autofocus>Cancel</button>
      <button class="saga-btn saga-btn--danger">Delete session</button>
    </div>
  </div>
</div>

Toasts#

A toast is a short, non-blocking message in a stack at the bottom-right of the window (.saga-toast-stack, --space-lg from both edges; DS-115). It is for things that happened elsewhere or already happened: "Roster uploaded", "2 students are waiting in Pod 6". It is never a form error and never a question that needs an answer.

Look:

Behaviour:

<div class="saga-toast-stack" role="status" aria-live="polite">
  <div class="saga-toast saga-toast--success">
    <span class="saga-toast__icon"><iconify-icon icon="material-symbols-light:check-circle-outline-rounded"></iconify-icon></span>
    <span class="saga-toast__msg">Roster uploaded. 55 students added.</span>
    <button class="saga-toast__action">View roster</button>
    <button class="saga-toast__close" aria-label="Dismiss"><iconify-icon icon="material-symbols-light:close-rounded"></iconify-icon></button>
  </div>
</div>

Inside a framed shell or a prototype scene the stack is .saga-toast-stack--local, pinned to the frame's corner instead of the window's.

Notices, warnings and confirmations#

When an action touches something the admin cannot see (a student already in another pod this period, a tutor in a sibling group, a rotation change that would wipe another group's pods), the response is one of three levels. The situation picks the level, not the kind of collision.

Level Ask Looks like Interrupts a11y
Notice allowed, usually worth avoiding bare .saga-notice (--notice is an accepted alias): --color-notice-bg fill, 1px --color-notice-border, text --color-text, blue family passive, dismissible role="status", polite
Warning a real collision they probably did not intend .saga-notice--warning: --color-warning-bg, --color-warning-border, amber family prominent, non-blocking, offers a fix role="status", polite
Confirmation destructive or irreversible the danger confirm dialog above, in --color-danger blocks until answered role="alertdialog", focus trapped

Ask in order: destructive? then confirmation. A collision they did not mean? then warning. Allowed but avoidable? then notice. When unsure pick the lower level; escalate only for destruction.

The levels are the modifier: bare (notice), --warning, --danger, --success, plus --attention for a standing problem (below). Inline notices are anchored next to the thing that was just touched, not in a global corner: --radius-md, --space-sm --space-md padding, --text-sm, flat (no shadow; the tint and border are the surface), up to 560px wide, with a ✕ (.saga-notice__dismiss) at the right. They carry their response as inline links (.saga-notice__action) in --color-primary semibold underline:

Dismissing a notice hides it; it does not resolve the condition, and the same placement does not re-prompt. Two or more notices about the same subject collapse into one summary line with a "Review" expander.

<div class="saga-notice saga-notice--warning" role="status">
  <span class="saga-notice__icon"><iconify-icon icon="material-symbols-light:warning-outline-rounded"></iconify-icon></span>
  <div class="saga-notice__body">Maya Ellis is also in another pod this period. <button class="saga-notice__action">Remove from Rotation 2?</button></div>
  <button class="saga-btn saga-btn--quiet saga-btn--icon saga-btn--sm saga-notice__dismiss" aria-label="Dismiss"><iconify-icon icon="material-symbols-light:close-rounded"></iconify-icon></button>
</div>

A standing problem the admin did not just cause (a pod with no tutor after a roster sync) is not a toast. It is counted in the notification bell and shown quietly in its own context when the admin arrives, with an amber glow on the affected rows.

Popover menus#

A kebab or caret opens an anchored menu (.saga-popover): white, 1px --color-border, --radius-md, --shadow-popover, --z-dropdown, 12 to 20rem wide, --space-xs vertical padding. Items (.saga-popover__item) are 2.25rem rows with an optional 1.2em icon, --text-sm, hover --color-bg-tertiary; a danger item (data-danger) is --color-danger text with a faint danger wash on hover; a divider (.saga-popover__divider) is a 1px --color-border rule. The menu prefers bottom-end placement and flips when it would overflow.

Keyboard: focus lands on the menu container (nothing pre-selected), arrows move, Enter or Space picks, Esc and outside click close, Tab wraps. A disabled item that the user may still want to understand stays clickable with aria-disabled and explains itself on click.

Tooltips#

Tooltips are painted DOM; title= is never the only mechanism (DS-123). A native tooltip is composited outside the tab and vanishes in a tab share, has no keyboard path, and its timing cannot be controlled, so title may ride along as a harmless extra but carries the description on its own only in a prototype or where no tooltip component exists.

Inline impact signals#

The non-blocking rungs (notice and warning) rendered inline are impact signals. They are the same element as the notice above with one more rule: the consumer supplies the message and marks its remediation buttons so they share the link style. A signal that stages a change (the student is removed when you save, not now) says so and offers Undo in the same line. Save is held behind a "Review overlaps 2" button until the admin has looked, and the held button stays focusable so it can explain why.

Do and don't#

Do

Don't