From 592943f30642ea92a8f307c744b7dbeff324d1ef Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 17 Sep 2026 18:25:58 -0700 Subject: [PATCH] Use the dashboard's overlay dialog in the Teams tab, not UIAlert Adds UIDashboardDialog, a confirm / alert / prompt card built like the share and properties modals: mounted in the dashboard window, revealed after a frame, dismissed by Escape, the close button or a backdrop press-and-release, with a focus trap and focus restored on close. On phones it docks to the bottom as a sheet. The Teams tab's confirms, error alerts, seat-limit warning and rename/create prompts all go through it; UIAlert and UIPrompt are no longer imported there. --- src/gui/src/UI/Dashboard/TabTeams.js | 72 ++--- .../src/UI/Dashboard/TabTeams.render.test.js | 3 +- src/gui/src/UI/Dashboard/TabTeams.test.js | 6 +- src/gui/src/UI/Dashboard/UIDashboardDialog.js | 172 ++++++++++ .../UI/Dashboard/UIDashboardDialog.test.js | 116 +++++++ src/gui/src/css/dashboard.css | 297 ++++++++++++++++++ 6 files changed, 625 insertions(+), 41 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/UIDashboardDialog.js create mode 100644 src/gui/src/UI/Dashboard/UIDashboardDialog.test.js diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index fc64da024..e651b4a2d 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -17,9 +17,8 @@ * along with this program. If not, see . */ -import UIAlert from '../UIAlert.js'; -import UIPrompt from '../UIPrompt.js'; import teamActionButton from './teamActionIcons.js'; +import UIDashboardDialog from './UIDashboardDialog.js'; import { annotateMembers, auditActionKey, @@ -47,13 +46,8 @@ const AUDIT_ROWS_PER_PAGE = 10; /** In flight, so `init` and the initial-route `onActivate` don't both load. */ let loadPromise = null; -const modalOptions = ($el_window) => ({ - parent_uuid: $el_window.attr('data-element_uuid'), - backdrop: true, - close_on_backdrop_click: true, - parent_center: true, - stay_on_top: true, -}); +/** Every dialog here is the dashboard's overlay card, mounted in the window. */ +const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts }); const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? ''; @@ -524,38 +518,39 @@ const showSeatLimit = async ($el_window, e) => { const canUpgrade = typeof window.UIUpgradeAccount === 'function'; let message = `

${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}

`; message += `

${i18n('teams_seat_limit_upgrade')}

`; - const answer = await UIAlert({ - type: 'warning', + const answer = await dialog($el_window, { + title: i18n('teams_add_account', [], false), + tone: 'warning', message, buttons: canUpgrade ? [ - { label: i18n('teams_see_plans'), value: 'upgrade', type: 'primary' }, - { label: i18n('cancel'), value: 'no' }, + { label: i18n('cancel', [], false), value: 'no' }, + { label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' }, ] - : [{ label: i18n('ok'), value: 'no' }], - ...modalOptions($el_window), + : [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }], }); if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window(); }; const showError = ($el_window, e) => { if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e); - return UIAlert({ - type: 'error', - message: e?.message ?? i18n('error_unknown_cause'), - ...modalOptions($el_window), + return dialog($el_window, { + title: i18n('something_went_wrong', [], false), + tone: 'error', + message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)), }); }; +/** `label` names the action and titles the dialog; `kind` colours its button. */ const confirm = async ($el_window, message, label, kind = 'danger') => { - const answer = await UIAlert({ - type: 'confirm', + const answer = await dialog($el_window, { + title: label, + tone: kind === 'danger' ? 'danger' : undefined, message, buttons: [ + { label: i18n('cancel', [], false), value: 'no' }, { label, value: 'yes', type: kind }, - { label: i18n('cancel'), value: 'no' }, ], - ...modalOptions($el_window), }); return answer === 'yes'; }; @@ -618,7 +613,7 @@ const reissueCredential = async ($el_window, username) => { const ok = await confirm( $el_window, `

${i18n('teams_confirm_reissue', { username })}

`, - i18n('teams_reissue_credential'), + i18n('teams_reissue_credential', [], false), ); if ( ! ok ) return; try { @@ -635,7 +630,7 @@ const setMemberEnabled = async ($el_window, username, enabled) => { const ok = await confirm( $el_window, `

${i18n('teams_confirm_disable', { username })}

`, - i18n('teams_disable_account'), + i18n('teams_disable_account', [], false), ); if ( ! ok ) return; } @@ -650,11 +645,14 @@ const setMemberEnabled = async ($el_window, username, enabled) => { }; const renameTeam = async ($el_window) => { - const name = await UIPrompt({ + const name = await dialog($el_window, { + title: i18n('teams_rename', [], false), message: i18n('teams_rename_prompt'), - placeholder: i18n('teams_team_name'), - defaultValue: state.selected.name ?? '', - ...modalOptions($el_window), + input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' }, + buttons: [ + { label: i18n('cancel', [], false), value: false }, + { label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' }, + ], }); if ( name === false || name.trim() === '' ) return; try { @@ -669,7 +667,7 @@ const deleteMemberAccount = async ($el_window, username) => { const ok = await confirm( $el_window, i18n('teams_confirm_delete_account', { username }), - i18n('teams_delete_account'), + i18n('teams_delete_account', [], false), ); if ( ! ok ) return; try { @@ -687,7 +685,7 @@ const setDirectoryEnabled = async ($el_window, enabled) => { const ok = await confirm( $el_window, i18n('teams_directory_confirm'), - i18n('teams_directory_confirm_action'), + i18n('teams_directory_confirm_action', [], false), // Reversible, and the wording says so — red would overstate it. 'primary', ); @@ -704,10 +702,14 @@ const setDirectoryEnabled = async ($el_window, enabled) => { }; const createTeam = async ($el_window) => { - const name = await UIPrompt({ + const name = await dialog($el_window, { + title: i18n('teams_create_team', [], false), message: i18n('teams_create_team_prompt'), - placeholder: i18n('teams_team_name'), - ...modalOptions($el_window), + input: { placeholder: i18n('teams_team_name', [], false) }, + buttons: [ + { label: i18n('cancel', [], false), value: false }, + { label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' }, + ], }); if ( name === false || name.trim() === '' ) return; try { @@ -724,7 +726,7 @@ const deleteTeam = async ($el_window) => { $el_window, `

${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}

` + `

${i18n('teams_confirm_delete_billing')}

`, - i18n('teams_delete_team'), + i18n('teams_delete_team', [], false), ); if ( ! ok ) return; try { diff --git a/src/gui/src/UI/Dashboard/TabTeams.render.test.js b/src/gui/src/UI/Dashboard/TabTeams.render.test.js index 9cb96449f..6e30e5b6b 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.render.test.js +++ b/src/gui/src/UI/Dashboard/TabTeams.render.test.js @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; -vi.mock('../UIAlert.js', () => ({ default: vi.fn() })); -vi.mock('../UIPrompt.js', () => ({ default: vi.fn() })); +vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); globalThis.$ = jQuery; globalThis.jQuery = jQuery; diff --git a/src/gui/src/UI/Dashboard/TabTeams.test.js b/src/gui/src/UI/Dashboard/TabTeams.test.js index 58a670783..97ca802fa 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.test.js +++ b/src/gui/src/UI/Dashboard/TabTeams.test.js @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -// The tab pulls in the dialog primitives, which pull in the whole window stack. -// Only the tab-object contract is under test here. -vi.mock('../UIAlert.js', () => ({ default: vi.fn() })); -vi.mock('../UIPrompt.js', () => ({ default: vi.fn() })); +// The tab's dialogs mount into the window; only the tab-object contract is under test here. +vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); globalThis.i18n = (key) => key; globalThis.html_encode = (value) => String(value); diff --git a/src/gui/src/UI/Dashboard/UIDashboardDialog.js b/src/gui/src/UI/Dashboard/UIDashboardDialog.js new file mode 100644 index 000000000..60968ca59 --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIDashboardDialog.js @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// The dashboard's own confirm / alert / prompt: an overlay card like the +// share and properties modals, not a UIWindow. Resolves with the pressed +// button's value (or the input's text), and `false` when dismissed. + +const SVG = (body) => + ``; + +const ICONS = { + close: SVG(''), + danger: SVG(''), + warning: SVG(''), + error: SVG(''), +}; + +const TONES = new Set(['danger', 'warning', 'error']); +const BUTTON_TYPES = new Set(['primary', 'danger', 'quiet']); + +/** + * @param {object} opts + * @param {string} [opts.title] Plain text. + * @param {string} opts.message HTML the caller has already encoded. + * @param {'danger'|'warning'|'error'} [opts.tone] Adds an icon; the primary + * button of a `danger` dialog is red. + * @param {Array<{ label: string, value: unknown, type?: 'primary'|'danger'|'quiet' }>} [opts.buttons] + * Rendered in order, right-aligned. Defaults to a single OK. + * @param {{ label?: string, placeholder?: string, value?: string }} [opts.input] + * Turns the dialog into a prompt: Enter presses the primary button and the + * result is the trimmed text rather than the button's value. + * @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body. + * @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close. + * @returns {Promise} + */ +export default function UIDashboardDialog ({ title, message, tone, buttons, input, $container, returnFocusTo }) { + const { html_encode } = window; + const $root = $container && $container.length ? $container : $('body'); + const id = window.uuidv4?.() ?? String(Date.now()); + const toneClass = TONES.has(tone) ? ` dashboard-dialog-${tone}` : ''; + const actions = buttons?.length + ? buttons + : [{ label: i18n('ok'), value: true, type: 'primary' }]; + const primary = actions.find(b => b.type === 'primary' || b.type === 'danger') ?? actions[actions.length - 1]; + + let h = '
'; + h += `
`; + if ( title ) { + h += '
'; + h += `${html_encode(title)}`; + h += ``; + h += '
'; + } + h += '
'; + if ( toneClass ) h += `
${ICONS[tone]}
`; + h += '
'; + h += `
${message ?? ''}
`; + if ( input ) { + h += ''; + } + h += '
'; + h += '
'; + + const $overlay = $(h); + $root.append($overlay); + requestAnimationFrame(() => $overlay.addClass('dashboard-dialog-show')); + + const el_previous_focus = document.activeElement; + const $input = $overlay.find('.dashboard-dialog-input'); + // A prompt wants the text; anything else, the safest button. + const $first = $input.length + ? $input + : $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first(); + ($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true }); + if ( $input.length ) $input.get(0).select(); + + return new Promise((resolve) => { + let closed = false; + const close = (value) => { + if ( closed ) return; + closed = true; + $overlay.removeClass('dashboard-dialog-show'); + $(document).off(`keydown.dashboard-dialog-${id}`); + setTimeout(() => $overlay.remove(), 200); + const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el)); + try { + el_focus_target?.focus({ preventScroll: true }); + } catch { /* focus restoration is best-effort */ } + resolve(value); + }; + + const submit = (button) => { + if ( ! button ) return close(false); + if ( $input.length && button === primary ) return close(String($input.val() ?? '').trim()); + close(button.value); + }; + + $overlay.on('click', '.dashboard-dialog-btn', function () { + submit(actions[Number($(this).attr('data-index'))]); + }); + $overlay.on('click', '.dashboard-dialog-close', () => close(false)); + + // Backdrop close goes by where the press started, so a drag that + // starts in the card and ends outside does not dismiss it. + let backdrop_pressed = false; + $overlay.on('mousedown', function (e) { + backdrop_pressed = e.target === $overlay[0]; + // A press inside the overlay must not let initgui's global mousedown + // hand focus (and the z-index) back to the window underneath it. + window.mouseover_window = undefined; + }); + $overlay.on('click', function (e) { + if ( e.target === $overlay[0] && backdrop_pressed ) close(false); + }); + $(document).on(`keydown.dashboard-dialog-${id}`, function (e) { + if ( e.key === 'Escape' ) close(false); + }); + $input.on('keydown', function (e) { + if ( e.key === 'Enter' ) { + e.preventDefault(); + submit(primary); + } + }); + + // Keep Tab cycling inside the dialog while it's up. + $overlay.on('keydown', function (e) { + if ( e.key !== 'Tab' ) return; + const focusables = $overlay + .find('button, input, [tabindex]:not([tabindex="-1"])') + .filter(':visible:not(:disabled)'); + if ( ! focusables.length ) return; + const first = focusables.get(0); + const last = focusables.get(focusables.length - 1); + if ( focusables.index(document.activeElement) === -1 ) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if ( e.shiftKey && document.activeElement === first ) { + e.preventDefault(); + last.focus(); + } else if ( ! e.shiftKey && document.activeElement === last ) { + e.preventDefault(); + first.focus(); + } + }); + }); +} diff --git a/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js b/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js new file mode 100644 index 000000000..ba5b83a6b --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIDashboardDialog.test.js @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = (key) => key; +globalThis.html_encode = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +window.html_encode = globalThis.html_encode; +window.requestAnimationFrame = (fn) => setTimeout(fn, 0); + +const { default: UIDashboardDialog } = await import('./UIDashboardDialog.js'); + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +const overlay = () => $('.dashboard-dialog-overlay'); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('UIDashboardDialog', () => { + it('mounts an overlay card, not a window, and reveals it after a frame', async () => { + UIDashboardDialog({ title: 'T', message: 'hello' }); + expect(overlay()).toHaveLength(1); + expect($('.window')).toHaveLength(0); + expect(overlay().hasClass('dashboard-dialog-show')).toBe(false); + await tick(); + expect(overlay().hasClass('dashboard-dialog-show')).toBe(true); + expect($('.dashboard-dialog-title').text()).toBe('T'); + expect($('.dashboard-dialog-message').html()).toBe('hello'); + }); + + it('resolves with the pressed button value', async () => { + const p = UIDashboardDialog({ + message: 'm', + buttons: [{ label: 'No', value: 'no' }, { label: 'Yes', value: 'yes', type: 'danger' }], + }); + expect($('.dashboard-dialog-btn-danger').text()).toBe('Yes'); + $('.dashboard-dialog-btn-danger').trigger('click'); + await expect(p).resolves.toBe('yes'); + }); + + it('resolves false on Escape, on the close button, and on a backdrop press-and-release', async () => { + const a = UIDashboardDialog({ title: 'T', message: 'm' }); + $(document).trigger($.Event('keydown', { key: 'Escape' })); + await expect(a).resolves.toBe(false); + + const b = UIDashboardDialog({ title: 'T', message: 'm' }); + $('.dashboard-dialog-close').last().trigger('click'); + await expect(b).resolves.toBe(false); + + const c = UIDashboardDialog({ title: 'T', message: 'm' }); + const el = overlay().last().get(0); + $(el).trigger($.Event('mousedown', { target: el })); + $(el).trigger($.Event('click', { target: el })); + await expect(c).resolves.toBe(false); + }); + + it('does not dismiss when a press starts inside the card and ends on the backdrop', async () => { + const resolved = vi.fn(); + UIDashboardDialog({ title: 'T', message: 'm' }).then(resolved); + const el = overlay().get(0); + const card = $('.dashboard-dialog').get(0); + $(card).trigger($.Event('mousedown', { target: card })); + $(el).trigger($.Event('click', { target: el })); + await tick(); + expect(resolved).not.toHaveBeenCalled(); + }); + + it('as a prompt, returns the trimmed text from the primary button or Enter', async () => { + const a = UIDashboardDialog({ + message: 'm', + input: { value: 'old' }, + buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }], + }); + expect($('.dashboard-dialog-input').val()).toBe('old'); + $('.dashboard-dialog-input').val(' new name '); + $('.dashboard-dialog-btn-primary').trigger('click'); + await expect(a).resolves.toBe('new name'); + + const b = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Save', value: 'ok', type: 'primary' }] }); + $('.dashboard-dialog-input').last().val('typed').trigger($.Event('keydown', { key: 'Enter' })); + await expect(b).resolves.toBe('typed'); + + const c = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }] }); + $('.dashboard-dialog-btn-quiet').last().trigger('click'); + await expect(c).resolves.toBe(false); + }); + + it('escapes the title and button labels but trusts the message as HTML', () => { + UIDashboardDialog({ title: 'x', message: '

ok

', buttons: [{ label: 'y', value: 1 }] }); + expect($('.dashboard-dialog-title').html()).toBe('<b>x</b>'); + expect($('.dashboard-dialog-btn').html()).toBe('<i>y</i>'); + expect($('.dashboard-dialog-message p')).toHaveLength(1); + }); + + it('mounts into the given container and marks the tone', () => { + document.body.innerHTML = '
'; + UIDashboardDialog({ message: 'm', tone: 'danger', $container: $('.host') }); + expect($('.host .dashboard-dialog-overlay')).toHaveLength(1); + expect($('.dashboard-dialog').hasClass('dashboard-dialog-danger')).toBe(true); + expect($('.dashboard-dialog-icon')).toHaveLength(1); + }); + + it('removes itself from the DOM after the exit transition', async () => { + vi.useFakeTimers(); + const p = UIDashboardDialog({ message: 'm' }); + $('.dashboard-dialog-btn').trigger('click'); + await p; + expect(overlay()).toHaveLength(1); + vi.advanceTimersByTime(250); + expect(overlay()).toHaveLength(0); + }); +}); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index f793b63ba..060eb9b2d 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -8980,3 +8980,300 @@ body.dashboard-mode .notifications-close-all { flex: 1; } } + +/* ====================================== + Dashboard dialog (confirm / alert / prompt) + ====================================== */ + +.dashboard-dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + box-sizing: border-box; + background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5)); + opacity: 0; + transition: opacity 200ms ease; + z-index: 1000; +} + +.dashboard-dialog-overlay.dashboard-dialog-show { + opacity: 1; +} + +.dashboard-dialog { + display: flex; + flex-direction: column; + width: min(440px, 100%); + max-height: 100%; + background: var(--dashboard-card-background); + border: 1px solid var(--dashboard-border); + border-radius: 14px; + box-shadow: 0 12px 40px var(--dashboard-shadow-medium); + overflow: hidden; + transform: scale(0.96); + opacity: 0; + transition: transform 200ms ease, opacity 200ms ease; +} + +.dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog { + transform: scale(1); + opacity: 1; +} + +.dashboard-dialog:focus { + outline: none; +} + +.dashboard-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-dialog-title { + min-width: 0; + font-size: 15px; + font-weight: 600; + color: var(--dashboard-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-dialog-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dashboard-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.dashboard-dialog-close svg { + width: 18px; + height: 18px; +} + +@media (hover: hover) { + .dashboard-dialog-close:hover { + background: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} + +.dashboard-dialog-close:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +.dashboard-dialog-body { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 18px; + overflow-y: auto; +} + +.dashboard-dialog-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--select-tint); + color: var(--select-color); +} + +.dashboard-dialog-icon svg { + width: 20px; + height: 20px; +} + +.dashboard-dialog-danger .dashboard-dialog-icon, +.dashboard-dialog-error .dashboard-dialog-icon { + background: var(--dashboard-danger-background); + color: var(--dashboard-danger-text); +} + +.dashboard-dialog-warning .dashboard-dialog-icon { + background: var(--dashboard-warning-background); + color: var(--dashboard-warning-text); +} + +.dashboard-dialog-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.dashboard-dialog-message { + font-size: 14px; + line-height: 1.55; + color: var(--dashboard-text-primary); + overflow-wrap: anywhere; +} + +.dashboard-dialog-message p { + margin: 0 0 10px; +} + +.dashboard-dialog-message p:last-child { + margin-bottom: 0; +} + +.dashboard-dialog-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dashboard-dialog-field > span { + font-size: 12px; + font-weight: 600; + color: var(--dashboard-text-secondary); +} + +.dashboard-dialog-input { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--dashboard-border); + border-radius: 10px; + background: var(--dashboard-input-background); + color: var(--dashboard-text-primary); + font-family: inherit; + font-size: 14px; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; +} + +.dashboard-dialog-input:focus { + outline: none; + border-color: var(--select-color); + background: var(--dashboard-card-background); + box-shadow: 0 0 0 3px var(--select-ring); +} + +.dashboard-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-dialog-btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 88px; + height: 36px; + padding: 0 16px; + border: 1px solid var(--dashboard-border); + border-radius: 10px; + background: var(--dashboard-card-background); + color: var(--dashboard-text-primary); + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.dashboard-dialog-btn-primary { + border-color: var(--dashboard-link); + background: var(--dashboard-link); + color: #fff; +} + +.dashboard-dialog-btn-danger { + border-color: #f00808; + background: #f00808; + color: #fff; +} + +@media (hover: hover) { + .dashboard-dialog-btn-quiet:hover { + background: var(--dashboard-sidebar-background); + } + + .dashboard-dialog-btn-primary:hover { + background: var(--dashboard-link-hover); + border-color: var(--dashboard-link-hover); + } + + .dashboard-dialog-btn-danger:hover { + background: #d90707; + border-color: #d90707; + } +} + +.dashboard-dialog-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +@media (pointer: coarse) { + .dashboard-dialog-btn { + min-height: 44px; + height: auto; + padding: 10px 18px; + } +} + +/* Mobile: dock to the bottom as a slide-up sheet, like the other modals. */ +@media (max-width: 600px) { + .dashboard-dialog-overlay { + align-items: flex-end; + padding: 0; + } + + .dashboard-dialog { + width: 100%; + max-width: none; + max-height: 92vh; + border-radius: 16px 16px 0 0; + border-bottom: none; + transform: translateY(24px); + } + + .dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog { + transform: translateY(0); + } + + .dashboard-dialog-footer { + flex-direction: column-reverse; + padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + } + + .dashboard-dialog-btn { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-dialog-overlay, + .dashboard-dialog { + transition: none; + } +}