Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 46 additions & 21 deletions apps/frontend/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,27 @@ Use the standard dialog family for focused tasks:
- Use `FormDialog` when a dialog collects input and submits one form.
- Use `Dialog` for information, custom content, or two or more action paths.

All three components use the same structure. A `surface` tray frames one
inset `background` work plane. The header and footer stay visible. Only the
body scrolls when the content is long. Do not add a `Panel`, another work
plane, or a footer divider inside this structure.
All three components own responsive presentation. Below `md` (768 px), task
dialogs become full-width bottom sheets with a drag handle, `rounded-lg` top
corners, and safe-area spacing. Both layouts keep a `surface` frame around one
inset `background` work plane. `sheet-frame` owns the same 16 px side and bottom
surround for task dialogs and context-menu sheets, including safe-area spacing.
Do not add another outer inset in either component. Task work planes use 12 px
inner padding; desktop retains its existing frame. Feature components must not
select a mobile layout or render separate mobile content.

The header and actions stay visible while long body content scrolls. When a
short viewport or enlarged text leaves too little space, the sheet content
can scroll to keep every control reachable. Sheets follow the visual viewport
when a keyboard opens. Only the handle captures drag gestures. The body keeps
native scrolling. Resizing keeps the dialog, body, and action controls mounted.
Do not add a `Panel`, another work plane, or a footer divider inside this structure.

Dialog trays and floating menu shells use `floating-frame` for a faint top
highlight and lower shading across their padded frame. Content surfaces keep
their solid `background` fill and use `floating-inset` for a soft inner shadow
that makes them look recessed. Menu sections receive this shadow only inside
a floating menu shell. The finish adds no backdrop blur or animation and retains
that makes them look recessed. Menu sections receive this shadow inside their
shared frame, including bottom sheets. The finish adds no backdrop blur or animation and retains
the existing outer shadow. Full-screen mobile media dialogs have no exposed
frame and do not use this finish. Standard panels share the frame highlight
and recessed content treatment. Panel title bands stay transparent so the
Expand All @@ -218,24 +229,36 @@ frame gradient continues around the content without a seam. The shared
dark mode retains the stronger depth treatment. Form input fields
keep their existing finish.

Pass footer buttons directly to the `Dialog` footer snippet. `Dialog` owns the
horizontal, end-aligned layout. The actions always stay in one row. A button
label can expand the dialog beyond its baseline size when the viewport has
room. The label truncates only when the viewport cannot show the full action
row. Put Cancel first. Use `secondary` for Cancel, `action` for the recommended
path, and a semantic tone such as `danger` only when the action has that
meaning. Use an action-specific icon on the committing action. Do not add an
icon to Cancel by default.
Declare task buttons in `Dialog` snippets: `primaryAction` for the main action,
`secondaryActions` for alternatives, and `dismissAction` for Cancel or Close.
Each snippet contains ordinary buttons; callers do not set responsive classes.
`FormDialog` supplies submit and Cancel. `ConfirmDialog` inherits this behavior.

Mobile actions fill the sheet width, have a minimum 48 px height, and wrap
complete labels, including loading labels. The order is primary, alternatives,
then dismissal. Desktop actions form an end-aligned row in the order dismissal,
alternatives, then primary. DOM and keyboard order follow the visible order.
Desktop labels can expand the dialog beyond its baseline width and truncate
only at the viewport limit. Keep alternative actions in their intended order.

Use `secondary` for Cancel, `action` for the recommended path, and a semantic
tone such as `danger` only when the action has that meaning. Use an
action-specific icon on the committing action. Do not add an icon to Cancel
by default. The primary slot does not enable Enter activation: use a native
submit button or `Button`'s `defaultAction` where appropriate. Reserve `footer`
and `footerDetails` for specialized viewer controls. The prop types prevent
combining a custom footer with semantic actions. `footerDetails` requires `footer`.

Use the `sm` baseline size for short confirmations, `md` for ordinary custom
dialogs, and `lg` for dense content such as screen selection. Footer actions
can make each size wider. Keep the standard viewport gutter. Do not add a
can make each size wider on desktop. Keep the desktop viewport gutter. Do not add a
feature-specific width to a task dialog.

`ImageModal`, fullscreen video, `QuickSwitcher`, popovers, and `BottomSheet`
are specialized overlays. They can use their own geometry because they do not
represent a standard task dialog. Keep each exception local and do not copy
its layout into a task dialog.
`ImageModal`, fullscreen video, `QuickSwitcher`, and popovers remain specialized
overlays. Media viewers keep their full-screen mobile presentation. Context
menus retain input-capability selection between floating menus and sheets.
`Dialog` and `BottomSheet` share the internal `ModalSurface` for native modal
lifecycle, focus restoration, backdrop handling, animation, and handle gestures.

## Standard Pane Pages

Expand Down Expand Up @@ -409,6 +432,7 @@ instead of adding local gradients or arbitrary inset shadows.
| `shell-lighting` | The same quiet finish over server gutter artwork; does not change the image or intercept clicks. |
| `floating-frame` | Lit panel, dialog, and menu frames. |
| `floating-inset` | Recessed content inside those frames. |
| `sheet-frame` | Touch dialog and menu frames; uses shared depth, shell radius, and safe-area spacing. |
| `app-frame-shell` / `app-frame-inset` | Desktop app frame with a flat fill and no outer border or highlight in any depth mode. Light mode raises the content area with a light upper/left edge, dark lower/right edge, and soft outer shadow. Dark mode keeps the recessed content edge and inset shadow. No full-window gradient. The inset overlay passes pointer input through to the panes. Mobile stays edge-to-edge. |
| `accent-swatch` | Palette samples with their own colour gradient and shared lit edges. |

Expand Down Expand Up @@ -601,8 +625,9 @@ row actions, and icon feedback share this timing. Do not add local numeric
durations for these interactions. Keep disabled/pending opacity at 150 ms;
pane movement and toolbar entrance animations have separate timing.

Modals and floating context menus fade in and zoom from 95% to full size.
Context menus close immediately; modals keep their existing 100 ms exit.
Centred dialogs and floating context menus fade in and zoom from 95% to full size.
Floating context menus close immediately; centred dialogs use a 100 ms exit.
Sheets slide in and out with `--motion-duration-pane`.
Reduced motion skips surface animations. Floating placement uses the full
layout size so the entrance zoom cannot move a menu beyond the viewport.
Touch context menus keep their bottom-sheet presentation.
Expand Down
16 changes: 13 additions & 3 deletions apps/frontend/scripts/check-design-system.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const styleBlockAllowlist = new Set([
'src/lib/components/chat/VideoPlayer.svelte',
'src/lib/components/composer/TipTapEditor.svelte',
'src/lib/ui/AppHeader.svelte',
'src/lib/ui/BottomSheet.svelte',
'src/lib/ui/ModalSurface.svelte',
'src/lib/ui/Dialog.svelte',
'src/lib/ui/toast/ToastContainer.svelte'
]);
Expand All @@ -21,8 +21,7 @@ const styleBlockAllowlist = new Set([
// dialogs must use Dialog, FormDialog, or ConfirmDialog instead.
const nativeDialogAllowlist = new Set([
'src/lib/components/QuickSwitcher.svelte',
'src/lib/ui/BottomSheet.svelte',
'src/lib/ui/Dialog.svelte',
'src/lib/ui/ModalSurface.svelte',
'src/lib/ui/ImageModal.svelte',
'src/routes/chat/ModalContainerConfirmDialogMock.svelte',
'src/routes/chat/ModalContainerDialogMock.svelte',
Expand Down Expand Up @@ -159,6 +158,17 @@ for (const file of await svelteFiles(sourceRoot)) {
}
}

for (const dialog of utilitySource.matchAll(/<Dialog\b[\s\S]*?<\/Dialog>/g)) {
if (
/\{#snippet footer\s*\(|\{footer\}/.test(dialog[0]) &&
!/\bmediaViewer\b|\bfooterDetails\b/.test(dialog[0])
) {
failures.push(
`${path}: task dialogs must use primaryAction, secondaryActions, and dismissAction; custom footers are for viewer controls`
);
}
}

for (const contextMenu of utilitySource.matchAll(/<ContextMenu\b[\s\S]*?<\/ContextMenu>/g)) {
const actionSeparator = contextMenu[0].match(
/(?:class\s*=\s*["'][^"']*\bborder-t\b[^"']*["']|role\s*=\s*["']separator["'])[\s\S]*?<(?:a|button)\b/
Expand Down
45 changes: 42 additions & 3 deletions apps/frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,8 @@
@apply max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
}

/* Dialog actions form one stable row. Their natural width can expand the
* dialog, while the viewport limit makes buttons surrender label width on a
* narrow screen. Actions never wrap or force a second row. */
/* Desktop actions form one row and can expand the dialog up to its viewport
* limit. Task sheets add dialog-actions-sheet for complete, stacked labels. */
@utility dialog-actions {
width: max-content;
max-width: 100%;
Expand All @@ -538,6 +537,32 @@
}
}

/* Task sheets own button geometry, including the Button label and loading wrapper. */
@utility dialog-actions-sheet {
@apply w-full flex-col items-stretch;
align-self: stretch;

& > :is(button, a) {
width: 100%;
min-height: 3rem;
height: auto;
flex-shrink: 0;
padding-block: 0.75rem;
white-space: normal;
overflow: visible;
overflow-wrap: anywhere;
}

& .button-content,
& .button-content > span {
white-space: normal;
overflow: visible;
text-overflow: clip;
min-width: 0;
overflow-wrap: anywhere;
}
}

/* One shell with flat separators or softly rounded, individually lit segments.
* Geometry and highlights follow the shared animated depth preference. */
@utility pill-button-group {
Expand Down Expand Up @@ -1296,6 +1321,20 @@
--lowered-edge: 0 0 0 1px var(--frame-inset-edge);
}

/* Touch overlays retain the same frame and recessed work planes as dialogs
* and menus. Only the outer edge meets the viewport; depth follows preferences. */
@utility sheet-frame {
@apply floating-frame rounded-t-lg border border-border border-b-0 px-4 pt-1 pb-4;
max-height: var(--modal-available-height);
padding-inline-start: max(1rem, env(safe-area-inset-left));
padding-inline-end: max(1rem, env(safe-area-inset-right));
padding-bottom: max(1rem, env(safe-area-inset-bottom));

& :is(.menu-section, .menu-section-sm) {
@apply floating-inset;
}
}

/* The outer app frame meets the window edge without a border or highlight. */
@utility app-frame-shell {
@apply bg-surface;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
</div>
</div>

{#snippet footer()}
{#snippet primaryAction()}
<Button defaultAction onclick={close}>{m('common.got_it')}</Button>
{/snippet}
</Dialog>
Original file line number Diff line number Diff line change
Expand Up @@ -397,13 +397,17 @@
>
<p class="text-muted">{m('composer.recent_thread_confirm_body')}</p>

{#snippet footer()}
{#snippet dismissAction()}
<Button variant="secondary" onclick={() => composer.cancelThreadDestinationConfirmation()}>
{m('common.cancel')}
</Button>
{/snippet}
{#snippet secondaryActions()}
<Button variant="secondary" onclick={() => composer.postAsNewRoot()}>
{m('composer.post_as_new_message')}
</Button>
{/snippet}
{#snippet primaryAction()}
<Button defaultAction variant="action" onclick={() => composer.postInRecentThread()}>
<span class="iconify icon-[uil--comment-alt-lines]"></span>
{m('composer.continue_in_thread')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@

<Dialog visible title={m('voice.microphone_silent_hint')} size="sm" {onclose}>
<p>{m('voice.microphone_silence_explanation')}</p>
{#snippet footer()}
{#snippet dismissAction()}
<Button variant="secondary" onclick={onclose}>{m('ui.close')}</Button>
{/snippet}
{#snippet primaryAction()}
<Button onclick={openPreferences}>{m('voice.preferences.title')}</Button>
{/snippet}
</Dialog>
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ URL when its source changes or leaves the DOM.
}
</script>

{#snippet footer()}
{#snippet dismissAction()}
<Button variant="secondary" onclick={close}>{m('common.cancel')}</Button>
{/snippet}

<Dialog bind:visible title={m('voice.share_screen')} size="lg" onclose={handleClose} {footer}>
<Dialog bind:visible title={m('voice.share_screen')} size="lg" onclose={handleClose} {dismissAction}>
<div class="flex justify-center">
<SegmentedControl
label={m('voice.share_source')}
Expand Down
Loading
Loading