From ec8661b317f7ea541fd994ace5f42fc324fe58b9 Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Sun, 4 Oct 2026 19:24:03 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(client):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E4=B8=BA=E4=B8=89=E7=BB=84=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E9=94=AE=E5=80=BC=E7=8A=B6=E6=80=81=EF=BC=8C=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=B8=A6=E7=8A=B6=E6=80=81=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client.js | 243 +++++++++++++++++++++------------ src/client/i18n/en.js | 6 +- src/client/i18n/zh.js | 6 +- src/client/ui/ConfigSection.js | 142 ++++++++++++------- src/client/ui/styles.js | 48 +++++-- 5 files changed, 288 insertions(+), 157 deletions(-) diff --git a/client.js b/client.js index 1ff47e7..6e2ca94 100644 --- a/client.js +++ b/client.js @@ -104,6 +104,7 @@ var zh = { "config.testAny": "测试提醒", "config.testSystem": "测试系统通知", "config.diag.window": "窗口状态", + "config.diag.watch": "会话监听", "config.diag.inFront": "在前台(会走轻弹窗)", "config.diag.away": "不在前台(会走系统通知)", "config.diag.last": "最近一次投递", @@ -114,8 +115,9 @@ var zh = { "config.diag.replayDelivery": "应用内轻弹窗(回到窗口时补发)", "config.diag.popup": "应用内轻弹窗", "config.diag.refused": "系统通知被拒绝,改用了轻弹窗", - "config.diag.replay": "等你回到窗口时补发:{count} 条", - "config.diag.replayNone": "没有待补发的提醒" + "config.diag.replay": "待补发提醒", + "config.diag.replayValue": "{count} 条", + "config.diag.replayNone": "没有" }; // src/client/i18n/en.js @@ -163,6 +165,7 @@ var en = { "config.testAny": "Test alert", "config.testSystem": "Test system notification", "config.diag.window": "Window", + "config.diag.watch": "Session watch", "config.diag.inFront": "in front (popup is used)", "config.diag.away": "not in front (system notification is used)", "config.diag.last": "Last delivery", @@ -173,8 +176,9 @@ var en = { "config.diag.replayDelivery": "in-app popup (replayed when you came back)", "config.diag.popup": "in-app popup", "config.diag.refused": "system channel refused, popup was used instead", - "config.diag.replay": "waiting for you to come back: {count}", - "config.diag.replayNone": "nothing waiting to be replayed" + "config.diag.replay": "Replay", + "config.diag.replayValue": "{count} waiting", + "config.diag.replayNone": "None" }; // src/client/i18n/index.js @@ -331,6 +335,9 @@ function createCopy(t) { if (candidate.kind === "completion") { return { kind: "completion", title: t("notification.completion"), body: t("body.completion", { title }) }; } + if (candidate.pendingKind === "plan-review") { + return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) }; + } if (candidate.kind === "question") { const question = candidate.detail; return { @@ -339,9 +346,6 @@ function createCopy(t) { body: question === "" ? t("body.question", { title }) : `${title} · ${question}` }; } - if (candidate.pendingKind === "plan-review") { - return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) }; - } const tool = candidate.detail; return { kind: "approval", @@ -349,13 +353,13 @@ function createCopy(t) { body: tool === "" ? t("body.approvalPlain", { title }) : t("body.approval", { title, tool }) }; }; - const describeDelivery = (last, tr) => { - if (last === null || last === void 0) return tr("config.diag.none"); + const deliveryFacts = (last, tr) => { + if (last === null || last === void 0) return null; const outcome = last.outcome.startsWith("system-refused") ? tr("config.diag.refused") : last.outcome === "system" ? last.shown === true ? tr("config.diag.systemShown") : last.shown === false ? tr("config.diag.systemFailed") : tr("config.diag.systemUnconfirmed") : last.outcome === "replay" ? tr("config.diag.replayDelivery") : tr("config.diag.popup"); - return `${tr("config.diag.last")}: ${last.at} · ${outcome} · ${last.title}`; + return { at: last.at, outcome, title: last.title ?? "" }; }; - const describeReplay = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replay", { count }); - return { copyFor, describeDelivery, describeReplay }; + const replayValue = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replayValue", { count }); + return { copyFor, deliveryFacts, replayValue }; } // src/client/core/toasts.js @@ -548,13 +552,18 @@ function isOnScreen(sessionId, list) { } return false; } -function stillWorth(candidate, live) { +function stillOwed(candidate, live) { + if (candidate.test === true) return true; + if (candidate.pendingKind !== "") { + const sessionStatus = live.status?.get?.(candidate.sessionId); + if (sessionStatus === void 0) return true; + return servedPendingKind(sessionStatus.pendingInteraction) === candidate.pendingKind; + } const list = live.list; - if (candidate.test === true || list === void 0) return true; + if (list === void 0) return true; const summary = list.byId?.[candidate.sessionId]; if (summary === void 0) return true; - const status = live.status; - const running = status?.get?.(candidate.sessionId)?.running ?? summary.running; + const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running; return running !== true; } @@ -582,28 +591,32 @@ function createDelivery({ store, copy, live, toasts, system, replay }) { const deliver = (candidate, ignoreThrottle) => { if (ignoreThrottle !== true) { const now = Date.now(); - if (now - lastDelivery < THROTTLE_MS) return; + if (now - lastDelivery < THROTTLE_MS) return false; lastDelivery = now; } if (!windowIsAway()) { - if (isOnScreen(candidate.sessionId, live.list)) return; + if (isOnScreen(candidate.sessionId, live.list)) return true; toasts.showToast(candidate); recordDelivery("popup", candidate); - return; + return true; } const result = system.notifySystem(candidate, candidate.test === true); if (result.outcome === "raised") { recordDelivery("system", candidate, result); if (candidate.test !== true) replay.holdForReplay(candidate, result); - return; + return true; } toasts.showToast(candidate); recordDelivery(`system-refused-${result.outcome}`, candidate); + return true; }; const flushSettle = () => { settleTimer = 0; const candidate = pending.shift(); - if (candidate !== void 0 && stillWorth(candidate, live)) deliver(candidate, false); + if (candidate !== void 0 && stillOwed(candidate, live)) { + const delivered = deliver(candidate, false); + if (delivered === false && candidate.pendingKind !== "") pending.push(candidate); + } if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS); }; const queue = (candidate) => { @@ -894,17 +907,29 @@ var CSS = ` .dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer} .dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} .dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} -.dsn-sections{display:flex;flex-direction:column;gap:24px} -.dsn-section{display:flex;flex-direction:column;gap:10px} -.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px} -.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} -.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)} -.dsn-row:last-child{border-bottom:0} +.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px} +.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px} +.dsn-group{display:flex;flex-direction:column;gap:2px} +.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px} +.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-list{display:flex;flex-direction:column} +.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0} +.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)} +.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0} .dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0} .dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} -.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} -.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px} -.dsn-status-error{color:var(--dsw-alias-state-error-primary)} +.dsn-row-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-state{display:inline-flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} +.dsn-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)} +.dsn-dot.is-warn{background:var(--dsw-alias-state-warn-label)} +.dsn-dot.is-error{background:var(--dsw-alias-state-error-primary)} +.dsn-dot.is-muted{background:var(--dsw-alias-border-l3)} +.dsn-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 14px;margin:0;min-width:0} +.dsn-fact-key{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap} +.dsn-fact{display:flex;flex-direction:column;gap:2px;margin:0;min-width:0} +.dsn-fact-value{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere} +.dsn-fact-value.is-error{color:var(--dsw-alias-state-error-primary)} +.dsn-fact-title{margin:0;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere} .dsn-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3);cursor:pointer} .dsn-switch[aria-checked='true']{background:var(--dsw-alias-brand-primary)} .dsn-switch:disabled{cursor:default;opacity:.5} @@ -912,10 +937,12 @@ var CSS = ` .dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease} .dsn-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)} .dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)} -.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none} +.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:transparent;flex:none} .dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)} .dsn-button:disabled{cursor:not-allowed;opacity:.4} .dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)} +.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88} `; // src/client/ui/ToastLayer.js @@ -1022,85 +1049,125 @@ function ConfigSection(props) { const supported = permission !== "unsupported"; const failed = permission === "denied" || snapshot.promptError !== ""; const statusKey = permission === "granted" ? "settings.permission.granted" : permission === "denied" ? "settings.permission.denied" : supported ? "settings.permission.default" : "settings.permission.unsupported"; - const permissionText = snapshot.promptError === "" ? `${tr(statusKey)}${permission === "denied" || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ""}` : snapshot.promptError; + const promptFailed = snapshot.promptError !== ""; + const permissionText = promptFailed ? snapshot.promptError : tr(statusKey); + const dotClass = permission === "granted" ? "" : failed ? " is-error" : supported ? " is-warn" : " is-muted"; + const permissionNote = promptFailed ? "" : permission === "denied" || !supported ? tr(`settings.permission.hint.${props.platform}`) : permission === "granted" ? tr("config.testHint") : tr("settings.permission.hint.plain"); const health = snapshot.health; const healthText = health.state === "watching" ? tr("settings.health.watching") : health.state === "noHooks" ? tr("settings.health.noHooks") : health.state === "error" ? tr("settings.health.error", { message: health.message }) : tr("settings.health.starting"); const healthFailed = health.state !== "watching" && health.state !== "starting"; + const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr); return h4( React5.Fragment, null, h4("style", null, CSS), h4( "div", - { className: "dsn-sections" }, - // The page header already shows this bundle's title and description, so - // this section opens with what the header cannot say: how alerts are - // carried, and what this form changes. + { className: "dsn-page" }, + // The page header already shows this bundle's title and description, so this + // lede says what the header cannot: how an alert travels, and that the + // channel is chosen at delivery time. + h4("p", { className: "dsn-lede" }, tr("config.intro")), h4( "section", - { className: "dsn-section" }, - h4("p", { className: "dsn-section-desc" }, tr("config.intro")) - ), - h4( - "section", - { className: "dsn-section" }, - h4("h3", { className: "dsn-section-title" }, tr("config.section.kinds")), - KINDS.map((kind) => h4(KindRow, { - key: kind, - label: tr(`settings.kind.${kind}`), - checked: snapshot.kinds[kind] === true, - disabled: false, - onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true) - })), - h4("p", { className: "dsn-section-desc" }, tr("config.saveNote")) - ), - h4( - "section", - { className: "dsn-section" }, - h4("h3", { className: "dsn-section-title" }, tr("config.section.permission")), + { className: "dsn-group" }, + h4("h3", { className: "dsn-group-title" }, tr("config.section.kinds")), h4( "div", - { className: "dsn-row" }, - h4( - "div", - { className: "dsn-row-text" }, - h4("p", { - className: `dsn-status${failed ? " dsn-status-error" : ""}`, - role: failed ? "alert" : "status" - }, permissionText) - ), - h4("button", { - type: "button", - className: "dsn-button", - disabled: !supported, - onClick: permission === "granted" ? props.testSystem : props.ask - }, tr(!supported ? "settings.action.unsupported" : permission === "granted" ? "settings.action.testSystem" : "settings.action.allow")) + { className: "dsn-list" }, + KINDS.map((kind) => h4(KindRow, { + key: kind, + label: tr(`settings.kind.${kind}`), + checked: snapshot.kinds[kind] === true, + disabled: false, + onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true) + })) ), - h4("p", { className: "dsn-section-desc" }, tr("config.testHint")) + h4("p", { className: "dsn-group-note" }, tr("config.saveNote")) ), h4( "section", - { className: "dsn-section" }, - h4("h3", { className: "dsn-section-title" }, tr("config.section.status")), + { className: "dsn-group" }, + h4("h3", { className: "dsn-group-title" }, tr("config.section.permission")), h4( "div", - { className: "dsn-row" }, + { className: "dsn-list" }, h4( "div", - { className: "dsn-row-text" }, - h4("p", { className: "dsn-status" }, `${tr("config.diag.window")}: ${tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")}`), - h4("p", { - className: `dsn-status${healthFailed ? " dsn-status-error" : ""}`, - role: healthFailed ? "alert" : "status" - }, healthText), - h4("p", { className: "dsn-status" }, props.copy.describeDelivery(snapshot.lastDelivery, tr)), - h4("p", { className: "dsn-status" }, props.copy.describeReplay(props.replayCount(), tr)) + { className: "dsn-row" }, + h4( + "div", + { className: "dsn-row-text" }, + h4( + "span", + { + className: "dsn-state", + role: failed ? "alert" : "status" + }, + h4("span", { className: `dsn-dot${dotClass}` }), + permissionText + ), + h4("p", { className: "dsn-row-sub" }, permissionNote) + ), + h4("button", { + type: "button", + className: `dsn-button${supported && permission !== "granted" ? " is-primary" : ""}`, + disabled: !supported, + onClick: permission === "granted" ? props.testSystem : props.ask + }, tr(!supported ? "settings.action.unsupported" : permission === "granted" ? "settings.action.testSystem" : "settings.action.allow")) + ) + ) + ), + h4( + "section", + { className: "dsn-group" }, + h4("h3", { className: "dsn-group-title" }, tr("config.section.status")), + h4( + "dl", + { className: "dsn-facts" }, + h4("dt", { className: "dsn-fact-key" }, tr("config.diag.window")), + h4( + "dd", + { className: "dsn-fact" }, + h4("p", { className: "dsn-fact-value" }, tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")) ), - h4("button", { - type: "button", - className: "dsn-button", - onClick: props.testAny - }, tr("settings.action.testAny")) + h4("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")), + h4( + "dd", + { className: "dsn-fact" }, + h4("p", { + className: `dsn-fact-value${healthFailed ? " is-error" : ""}`, + role: healthFailed ? "alert" : "status" + }, healthText) + ), + h4("dt", { className: "dsn-fact-key" }, tr("config.diag.last")), + h4( + "dd", + { className: "dsn-fact" }, + delivery === null ? h4("p", { className: "dsn-fact-value" }, tr("config.diag.none")) : [ + h4("p", { key: "line", className: "dsn-fact-value" }, `${delivery.at} · ${delivery.outcome}`), + delivery.title === "" ? null : h4("p", { key: "title", className: "dsn-fact-title" }, delivery.title) + ] + ), + h4("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")), + h4( + "dd", + { className: "dsn-fact" }, + h4("p", { className: "dsn-fact-value" }, props.copy.replayValue(props.replayCount(), tr)) + ) + ), + h4( + "div", + { className: "dsn-list" }, + h4( + "div", + { className: "dsn-row is-action" }, + h4("button", { + type: "button", + className: "dsn-button", + onClick: props.testAny + }, tr("settings.action.testAny")) + ) ) ) ) diff --git a/src/client/i18n/en.js b/src/client/i18n/en.js index b8b58c3..3506866 100644 --- a/src/client/i18n/en.js +++ b/src/client/i18n/en.js @@ -52,6 +52,7 @@ export const en = { 'config.testAny': 'Test alert', 'config.testSystem': 'Test system notification', 'config.diag.window': 'Window', + 'config.diag.watch': 'Session watch', 'config.diag.inFront': 'in front (popup is used)', 'config.diag.away': 'not in front (system notification is used)', 'config.diag.last': 'Last delivery', @@ -62,6 +63,7 @@ export const en = { 'config.diag.replayDelivery': 'in-app popup (replayed when you came back)', 'config.diag.popup': 'in-app popup', 'config.diag.refused': 'system channel refused, popup was used instead', - 'config.diag.replay': 'waiting for you to come back: {count}', - 'config.diag.replayNone': 'nothing waiting to be replayed', + 'config.diag.replay': 'Replay', + 'config.diag.replayValue': '{count} waiting', + 'config.diag.replayNone': 'None', } diff --git a/src/client/i18n/zh.js b/src/client/i18n/zh.js index f28690a..3af17b3 100644 --- a/src/client/i18n/zh.js +++ b/src/client/i18n/zh.js @@ -52,6 +52,7 @@ export const zh = { 'config.testAny': '测试提醒', 'config.testSystem': '测试系统通知', 'config.diag.window': '窗口状态', + 'config.diag.watch': '会话监听', 'config.diag.inFront': '在前台(会走轻弹窗)', 'config.diag.away': '不在前台(会走系统通知)', 'config.diag.last': '最近一次投递', @@ -62,6 +63,7 @@ export const zh = { 'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)', 'config.diag.popup': '应用内轻弹窗', 'config.diag.refused': '系统通知被拒绝,改用了轻弹窗', - 'config.diag.replay': '等你回到窗口时补发:{count} 条', - 'config.diag.replayNone': '没有待补发的提醒', + 'config.diag.replay': '待补发提醒', + 'config.diag.replayValue': '{count} 条', + 'config.diag.replayNone': '没有', } diff --git a/src/client/ui/ConfigSection.js b/src/client/ui/ConfigSection.js index 6966ba3..7bcd3a7 100644 --- a/src/client/ui/ConfigSection.js +++ b/src/client/ui/ConfigSection.js @@ -6,6 +6,12 @@ * row's description) and `page` (the whole form). Everything visible here * belongs to this package, including the platform-specific permission hint. * + * The page is laid out as three groups — what to notify about, the system + * channel's own state, and what the plugin is doing right now. Each group keeps + * its controls in one place: a list whose rows carry the subject on the left and + * the control on the right, and a fact list for the readings, so the status + * block is scannable instead of being four sentences in one cell. + * * @module @dsh-plugin/session-notify/client/ui/ConfigSection */ @@ -22,6 +28,7 @@ export function ConfigSection(props) { const snapshot = useOwnStore(props.store) const tr = seatTranslator(props, props.fallbackT) if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description')) + const permission = snapshot.permission const supported = permission !== 'unsupported' const failed = permission === 'denied' || snapshot.promptError !== '' @@ -32,9 +39,19 @@ export function ConfigSection(props) { : supported ? 'settings.permission.default' : 'settings.permission.unsupported' - const permissionText = snapshot.promptError === '' - ? `${tr(statusKey)}${permission === 'denied' || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ''}` - : snapshot.promptError + const promptFailed = snapshot.promptError !== '' + const permissionText = promptFailed ? snapshot.promptError : tr(statusKey) + const dotClass = permission === 'granted' + ? '' + : failed ? ' is-error' : supported ? ' is-warn' : ' is-muted' + const permissionNote = promptFailed + ? '' + : permission === 'denied' || !supported + ? tr(`settings.permission.hint.${props.platform}`) + : permission === 'granted' + ? tr('config.testHint') + : tr('settings.permission.hint.plain') + const health = snapshot.health const healthText = health.state === 'watching' ? tr('settings.health.watching') @@ -44,55 +61,80 @@ export function ConfigSection(props) { ? tr('settings.health.error', { message: health.message }) : tr('settings.health.starting') const healthFailed = health.state !== 'watching' && health.state !== 'starting' + + const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr) + return h(React.Fragment, null, h('style', null, CSS), - h('div', { className: 'dsn-sections' }, - // The page header already shows this bundle's title and description, so - // this section opens with what the header cannot say: how alerts are - // carried, and what this form changes. - h('section', { className: 'dsn-section' }, - h('p', { className: 'dsn-section-desc' }, tr('config.intro'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')), - KINDS.map((kind) => h(KindRow, { - key: kind, - label: tr(`settings.kind.${kind}`), - checked: snapshot.kinds[kind] === true, - disabled: false, - onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true), - })), - h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')), - h('div', { className: 'dsn-row' }, - h('div', { className: 'dsn-row-text' }, + h('div', { className: 'dsn-page' }, + // The page header already shows this bundle's title and description, so this + // lede says what the header cannot: how an alert travels, and that the + // channel is chosen at delivery time. + h('p', { className: 'dsn-lede' }, tr('config.intro')), + + h('section', { className: 'dsn-group' }, + h('h3', { className: 'dsn-group-title' }, tr('config.section.kinds')), + h('div', { className: 'dsn-list' }, + KINDS.map((kind) => h(KindRow, { + key: kind, + label: tr(`settings.kind.${kind}`), + checked: snapshot.kinds[kind] === true, + disabled: false, + onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true), + }))), + h('p', { className: 'dsn-group-note' }, tr('config.saveNote'))), + + h('section', { className: 'dsn-group' }, + h('h3', { className: 'dsn-group-title' }, tr('config.section.permission')), + h('div', { className: 'dsn-list' }, + h('div', { className: 'dsn-row' }, + h('div', { className: 'dsn-row-text' }, + h('span', { + className: 'dsn-state', + role: failed ? 'alert' : 'status', + }, + h('span', { className: `dsn-dot${dotClass}` }), + permissionText), + h('p', { className: 'dsn-row-sub' }, permissionNote)), + h('button', { + type: 'button', + className: `dsn-button${supported && permission !== 'granted' ? ' is-primary' : ''}`, + disabled: !supported, + onClick: permission === 'granted' ? props.testSystem : props.ask, + }, tr(!supported + ? 'settings.action.unsupported' + : permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))))), + + h('section', { className: 'dsn-group' }, + h('h3', { className: 'dsn-group-title' }, tr('config.section.status')), + h('dl', { className: 'dsn-facts' }, + h('dt', { className: 'dsn-fact-key' }, tr('config.diag.window')), + h('dd', { className: 'dsn-fact' }, + h('p', { className: 'dsn-fact-value' }, tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront'))), + h('dt', { className: 'dsn-fact-key' }, tr('config.diag.watch')), + h('dd', { className: 'dsn-fact' }, h('p', { - className: `dsn-status${failed ? ' dsn-status-error' : ''}`, - role: failed ? 'alert' : 'status', - }, permissionText)), - h('button', { - type: 'button', - className: 'dsn-button', - disabled: !supported, - onClick: permission === 'granted' ? props.testSystem : props.ask, - }, tr(!supported - ? 'settings.action.unsupported' - : permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))), - h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.status')), - h('div', { className: 'dsn-row' }, - h('div', { className: 'dsn-row-text' }, - h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`), - h('p', { - className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`, + className: `dsn-fact-value${healthFailed ? ' is-error' : ''}`, role: healthFailed ? 'alert' : 'status', - }, healthText), - h('p', { className: 'dsn-status' }, props.copy.describeDelivery(snapshot.lastDelivery, tr)), - h('p', { className: 'dsn-status' }, props.copy.describeReplay(props.replayCount(), tr))), - h('button', { - type: 'button', - className: 'dsn-button', - onClick: props.testAny, - }, tr('settings.action.testAny')))))) + }, healthText)), + h('dt', { className: 'dsn-fact-key' }, tr('config.diag.last')), + h('dd', { className: 'dsn-fact' }, + delivery === null + ? h('p', { className: 'dsn-fact-value' }, tr('config.diag.none')) + : [ + h('p', { key: 'line', className: 'dsn-fact-value' }, `${delivery.at} · ${delivery.outcome}`), + delivery.title === '' + ? null + : h('p', { key: 'title', className: 'dsn-fact-title' }, delivery.title), + ]), + h('dt', { className: 'dsn-fact-key' }, tr('config.diag.replay')), + h('dd', { className: 'dsn-fact' }, + h('p', { className: 'dsn-fact-value' }, props.copy.replayValue(props.replayCount(), tr)))), + h('div', { className: 'dsn-list' }, + h('div', { className: 'dsn-row is-action' }, + h('button', { + type: 'button', + className: 'dsn-button', + onClick: props.testAny, + }, tr('settings.action.testAny'))))))) } diff --git a/src/client/ui/styles.js b/src/client/ui/styles.js index 378fcdd..67d9527 100644 --- a/src/client/ui/styles.js +++ b/src/client/ui/styles.js @@ -3,11 +3,15 @@ * * The popup copies the shipped toast surface (a fixed entry in the frame-wide * overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair, - * `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text; - * the switch copies `Switch.module.css` behavior and proportion; the - * configuration sections follow the Plugin Manager page's own section rhythm - * (a column, 32px apart). Class names are renamed under `dsn-`; only the popup - * glyphs carry their own artwork colors. + * `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text. + * + * The settings page is a reading order, not a form dump: a lede that says how + * alerts travel, then one group per concern. Inside a group a title introduces a + * list whose rows put the subject on the left and the single control on the + * right, with hairlines only between rows; the status group is a two-column fact + * list, because four sentences stacked in one cell are not scannable. Class + * names are renamed under `dsn-`; only the popup glyphs carry their own artwork + * colors. * * A plugin must not import a Harness Client package, so the declarations are * this package's own text and reference theme tokens only. @@ -34,17 +38,29 @@ export const CSS = ` .dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer} .dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} .dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} -.dsn-sections{display:flex;flex-direction:column;gap:24px} -.dsn-section{display:flex;flex-direction:column;gap:10px} -.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px} -.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} -.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)} -.dsn-row:last-child{border-bottom:0} +.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px} +.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px} +.dsn-group{display:flex;flex-direction:column;gap:2px} +.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px} +.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-list{display:flex;flex-direction:column} +.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0} +.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)} +.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0} .dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0} .dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} -.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} -.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px} -.dsn-status-error{color:var(--dsw-alias-state-error-primary)} +.dsn-row-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-state{display:inline-flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} +.dsn-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)} +.dsn-dot.is-warn{background:var(--dsw-alias-state-warn-label)} +.dsn-dot.is-error{background:var(--dsw-alias-state-error-primary)} +.dsn-dot.is-muted{background:var(--dsw-alias-border-l3)} +.dsn-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 14px;margin:0;min-width:0} +.dsn-fact-key{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap} +.dsn-fact{display:flex;flex-direction:column;gap:2px;margin:0;min-width:0} +.dsn-fact-value{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere} +.dsn-fact-value.is-error{color:var(--dsw-alias-state-error-primary)} +.dsn-fact-title{margin:0;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere} .dsn-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3);cursor:pointer} .dsn-switch[aria-checked='true']{background:var(--dsw-alias-brand-primary)} .dsn-switch:disabled{cursor:default;opacity:.5} @@ -52,8 +68,10 @@ export const CSS = ` .dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease} .dsn-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)} .dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)} -.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none} +.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:transparent;flex:none} .dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)} .dsn-button:disabled{cursor:not-allowed;opacity:.4} .dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)} +.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88} `