dsh-completion-reminder 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -64,25 +64,30 @@ window.__ModuleLoader__.load({
64
64
 
65
65
  // ── compiled client code ────────────────────────────────────────
66
66
  /**
67
- * DSH Completion Reminder — client half (v1.1).
67
+ * DSH Completion Reminder — client half (v1.2).
68
68
  *
69
- * Detects the agent-completion lifecycle by watching the DSH composer
70
- * primary button's `aria-label` (which flips between "Stop generating" /
71
- * "Stop 生成" / "Stop" while running and "Send message" / "发送消息" /
72
- * "Send" when idle) and the conversation root's `data-phase` (which
73
- * transitions through `active` / `settling` / `hero`).
69
+ * Strategy:
70
+ * 1. Detect agent-completion lifecycle by watching the DSH composer
71
+ * primary button's `aria-label` (which flips between "Stop
72
+ * generating" / "停止生成" and "Send message" / "发送消息") inside
73
+ * `[data-composer-card]`. The `data-phase` attribute on the
74
+ * conversation root helps disambiguate active / settling / hero.
74
75
  *
75
- * The plugin:
76
- * - exposes `window.DSHCompletionReminder.configure({...})` for API users
77
- * - injects a floating 🔔 button in the bottom-right corner that opens
78
- * a settings panel (provider picker + per-provider credentials + test
79
- * button). Configuration is persisted to localStorage.
80
- * - delivers notifications via 9 channels: browser, Telegram, Bark,
81
- * Pushover, Server酱, Discord, Slack, generic Webhook, custom.
76
+ * 2. Register a settings section in DSH's own settings dialog via
77
+ * `ctx.slots.inject('settings.section', ...)` so the user finds the
78
+ * configuration alongside General / Models / Plugins rather than as
79
+ * an extra floating widget.
80
+ *
81
+ * 3. Filter credential fields by the currently selected provider so the
82
+ * panel stays compact (Telegram shows 2 fields, Bark shows 1, etc.).
83
+ *
84
+ * 4. Persist configuration to `localStorage` and re-load on activation.
82
85
  *
83
86
  * The plugin is packaged as a DSH client plugin (`dsh.client` in
84
87
  * package.json) and loaded through `window.__ModuleLoader__`.
85
88
  */
89
+ var React = require("react");
90
+ var { forwardRef, useEffect, useImperativeHandle, useRef } = require("react");
86
91
 
87
92
  let config = { ...DEFAULT_OPTIONS };
88
93
  const state = {
@@ -95,49 +100,22 @@ window.__ModuleLoader__.load({
95
100
  isActive: false,
96
101
  permission: detectPermission(),
97
102
  unbinder: [],
98
- settingsPanelEl: null,
99
- settingsButtonEl: null,
100
- panelOpen: false,
103
+ panelHostEl: null,
104
+ panelRendered: false,
105
+ hintEl: null,
101
106
  };
102
107
  // ──── DOM signals we look at ───────────────────────────────────────────────
103
- /**
104
- * The composer card root in DSH: `<div data-composer-card="true">…</div>`.
105
- * It contains the textarea, the `data-input-scroll` scrollport, and the
106
- * primary send/stop button.
107
- */
108
108
  const COMPOSER_CARD_SELECTOR = '[data-composer-card]';
109
- /**
110
- * The composer root containing all sessions. Has `data-phase` attribute
111
- * ('hero' | 'active' | 'settling'). We use this to disambiguate "first
112
- * paint idle" from "completed and went idle".
113
- */
114
109
  const CONVERSATION_ROOT_SELECTOR = '[data-conversation-scroll], [data-composer-seat]';
115
- /**
116
- * The primary send/stop button. DSH flips its `aria-label` between
117
- * "Stop generating" / "停止生成" (while running) and "Send message" /
118
- * "发送消息" (when idle). In addition, DSH adds `disabled` to the
119
- * button when the draft is empty.
120
- *
121
- * We do NOT match by class name (CSS-modules hashes change between
122
- * builds) — only by stable attributes.
123
- */
124
- const PRIMARY_BUTTON_SELECTOR = 'button[type="button"]';
125
- /**
126
- * Tokens we look for in the primary button's `aria-label` to detect
127
- * a *running* agent.
128
- */
129
110
  const RUNNING_TOKENS = [
130
111
  'stop generating', '停止生成', '停止', 'stop',
131
112
  'abort', 'cancel generating', 'cancel',
132
113
  ];
133
- /** Tokens for an *idle* / send button. */
134
114
  const IDLE_TOKENS = [
135
115
  'send message', '发送消息', 'send', '发送',
136
116
  ];
137
117
  // ──── Public API ───────────────────────────────────────────────────────────
138
118
  function configure(opts) {
139
- // First merge in any persisted config so the panel + programmatic
140
- // configure() play nicely together.
141
119
  const persisted = loadPersisted();
142
120
  const merged = { ...persisted, ...(opts ?? {}) };
143
121
  if (!opts || opts.providers === undefined) {
@@ -149,7 +127,8 @@ window.__ModuleLoader__.load({
149
127
  providers: { ...DEFAULT_OPTIONS.providers, ...(merged.providers ?? {}) },
150
128
  };
151
129
  if (state.isActive) {
152
- refreshSettingsButtonVisibility();
130
+ // Re-render the open panel with new values.
131
+ rerenderPanel();
153
132
  }
154
133
  }
155
134
  function activate() {
@@ -163,9 +142,7 @@ window.__ModuleLoader__.load({
163
142
  }
164
143
  startObserver();
165
144
  bindVisibilityEvents();
166
- if (config.showSettingsPanel) {
167
- ensureSettingsButton();
168
- }
145
+ // Don't auto-show a hint — the user now finds us via DSH Settings.
169
146
  }
170
147
  function deactivate() {
171
148
  state.isActive = false;
@@ -176,9 +153,15 @@ window.__ModuleLoader__.load({
176
153
  }
177
154
  catch { /* noop */ }
178
155
  }
179
- removeSettingsUI();
156
+ removeHint();
180
157
  state.runStartedAt = null;
181
158
  state.inFlight = false;
159
+ // Clear the panel host so the next activation rebuilds it.
160
+ if (state.panelHostEl) {
161
+ state.panelHostEl.innerHTML = '';
162
+ state.panelHostEl = null;
163
+ }
164
+ state.panelRendered = false;
182
165
  }
183
166
  // ──── Persistence ──────────────────────────────────────────────────────────
184
167
  function loadPersisted() {
@@ -207,7 +190,6 @@ window.__ModuleLoader__.load({
207
190
  notifyOnError: config.notifyOnError,
208
191
  suppressWhenFocused: config.suppressWhenFocused,
209
192
  cooldownMs: config.cooldownMs,
210
- showSettingsPanel: config.showSettingsPanel,
211
193
  providers: config.providers,
212
194
  };
213
195
  localStorage.setItem(STORAGE_KEY, JSON.stringify(out));
@@ -245,9 +227,6 @@ window.__ModuleLoader__.load({
245
227
  if (state.observer)
246
228
  state.observer.disconnect();
247
229
  state.observer = new MutationObserver(handleMutations);
248
- // Watch the whole document — DSH may swap the conversation root on
249
- // session change. We only act on changes inside the composer card or
250
- // the conversation root, which is cheap to filter in the callback.
251
230
  state.observer.observe(document.body, {
252
231
  childList: true,
253
232
  subtree: true,
@@ -255,7 +234,6 @@ window.__ModuleLoader__.load({
255
234
  attributes: true,
256
235
  attributeFilter: ['aria-label', 'aria-disabled', 'data-phase', 'data-state', 'class', 'disabled'],
257
236
  });
258
- // Take an initial reading.
259
237
  evaluateNow();
260
238
  }
261
239
  function stopObserver() {
@@ -268,8 +246,6 @@ window.__ModuleLoader__.load({
268
246
  if (!state.isActive)
269
247
  return;
270
248
  for (const m of mutations) {
271
- // Attribute changes on the primary button or its `data-composer-card`
272
- // ancestor are the most precise signal.
273
249
  if (m.type === 'attributes') {
274
250
  const target = m.target;
275
251
  if (target && isInsideComposer(target)) {
@@ -277,7 +253,6 @@ window.__ModuleLoader__.load({
277
253
  continue;
278
254
  }
279
255
  }
280
- // Newly added composer cards / buttons.
281
256
  for (const node of m.addedNodes) {
282
257
  if (!(node instanceof HTMLElement))
283
258
  continue;
@@ -296,13 +271,11 @@ window.__ModuleLoader__.load({
296
271
  captureRunMetadata();
297
272
  const isRunning = !!primary && isRunningButton(primary);
298
273
  if (isRunning && !state.inFlight) {
299
- // idle → running
300
274
  state.inFlight = true;
301
275
  state.runStartedAt = Date.now();
302
276
  return;
303
277
  }
304
278
  if (!isRunning && state.inFlight) {
305
- // running → idle: completion event
306
279
  state.inFlight = false;
307
280
  const startedAt = state.runStartedAt ?? Date.now();
308
281
  const durationMs = Date.now() - startedAt;
@@ -310,12 +283,6 @@ window.__ModuleLoader__.load({
310
283
  void completeRun(determineStatus(), durationMs);
311
284
  }
312
285
  }
313
- /**
314
- * Find the primary send/stop button by walking up from the composer card.
315
- * The button is the only `<button type="button">` inside the composer that
316
- * is NOT the leading add-command button, so we look for buttons with an
317
- * aria-label that includes one of the well-known tokens.
318
- */
319
286
  function findPrimaryButton() {
320
287
  const card = document.querySelector(COMPOSER_CARD_SELECTOR);
321
288
  if (!card)
@@ -328,7 +295,6 @@ window.__ModuleLoader__.load({
328
295
  return b;
329
296
  }
330
297
  }
331
- // Last-resort: if the composer has exactly one button, use it.
332
298
  if (buttons.length === 1)
333
299
  return buttons[0];
334
300
  return null;
@@ -337,22 +303,11 @@ window.__ModuleLoader__.load({
337
303
  const aria = (btn.getAttribute('aria-label') || '').toLowerCase();
338
304
  return RUNNING_TOKENS.some((t) => aria.includes(t));
339
305
  }
340
- /**
341
- * Look at the conversation list to figure out success / error / stopped.
342
- * If we cannot decide, default to 'success' (the common case).
343
- */
344
306
  function determineStatus() {
345
- // Strongest signal: the conversation root's `data-phase` is `settling`
346
- // while a turn is being finalised. We treat the moment it leaves
347
- // `settling` as completion.
348
307
  const root = document.querySelector(CONVERSATION_ROOT_SELECTOR);
349
308
  const phase = root?.getAttribute('data-phase');
350
- if (phase === 'settling') {
309
+ if (phase === 'settling')
351
310
  return 'success';
352
- }
353
- // Next, look at the latest assistant turn. If its last child has
354
- // [data-state="interrupted"] / [data-state="error"] / class includes
355
- // "error" / "stop", we have a more specific status.
356
311
  const lastAssistant = findLastAssistantTurn();
357
312
  if (lastAssistant) {
358
313
  const cls = (lastAssistant.getAttribute('class') ?? '').toLowerCase();
@@ -491,7 +446,6 @@ window.__ModuleLoader__.load({
491
446
  }
492
447
  }
493
448
  }
494
- // ──── browser ──────────────────────────────────────────────────────────────
495
449
  async function deliverBrowser(payload) {
496
450
  if (state.permission === 'unsupported') {
497
451
  showInPageToast(payload);
@@ -585,27 +539,15 @@ window.__ModuleLoader__.load({
585
539
  style.id = TOAST_STYLE_ID;
586
540
  style.textContent = `
587
541
  .dsh-reminder-toast-root {
588
- position: fixed;
589
- top: 20px;
590
- right: 20px;
591
- z-index: 2147483647;
592
- display: flex;
593
- flex-direction: column;
594
- gap: 8px;
595
- pointer-events: none;
596
- max-width: 360px;
542
+ position: fixed; top: 20px; right: 20px; z-index: 2147483647;
543
+ display: flex; flex-direction: column; gap: 8px; pointer-events: none; max-width: 360px;
597
544
  }
598
545
  .dsh-reminder-toast {
599
546
  pointer-events: auto;
600
- background: ${DSH_CSS_VARS.bgModule};
601
- color: ${DSH_CSS_VARS.labelPrimary};
602
- border: 1px solid ${DSH_CSS_VARS.borderL3};
603
- border-radius: 10px;
547
+ background: ${DSH_CSS_VARS.bgModule}; color: ${DSH_CSS_VARS.labelPrimary};
548
+ border: 1px solid ${DSH_CSS_VARS.borderL3}; border-radius: 10px;
604
549
  box-shadow: ${DSH_CSS_VARS.shadowLv3};
605
- padding: 12px 14px;
606
- font-size: 13px;
607
- line-height: 1.4;
608
- cursor: pointer;
550
+ padding: 12px 14px; font-size: 13px; line-height: 1.4; cursor: pointer;
609
551
  transition: opacity .25s ease, transform .25s ease;
610
552
  }
611
553
  .dsh-reminder-toast-title { font-weight: 600; margin-bottom: 4px; color: ${DSH_CSS_VARS.labelPrimary}; }
@@ -615,7 +557,6 @@ window.__ModuleLoader__.load({
615
557
  `;
616
558
  document.head.appendChild(style);
617
559
  }
618
- // ──── Generic fetch helpers ────────────────────────────────────────────────
619
560
  async function postJson(url, body) {
620
561
  const res = await fetch(url, {
621
562
  method: 'POST',
@@ -651,7 +592,6 @@ window.__ModuleLoader__.load({
651
592
  return '';
652
593
  }
653
594
  }
654
- // ──── telegram ─────────────────────────────────────────────────────────────
655
595
  async function deliverTelegram(payload) {
656
596
  const cfg = config.providers;
657
597
  if (!cfg.telegramBotToken || !cfg.telegramChatId) {
@@ -669,7 +609,6 @@ window.__ModuleLoader__.load({
669
609
  function escapeMd(s) {
670
610
  return s.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (m) => `\\${m}`);
671
611
  }
672
- // ──── bark ─────────────────────────────────────────────────────────────────
673
612
  async function deliverBark(payload) {
674
613
  const cfg = config.providers;
675
614
  if (!cfg.barkKey)
@@ -682,7 +621,6 @@ window.__ModuleLoader__.load({
682
621
  throw new Error(`Bark ${res.status}: ${text || res.statusText}`);
683
622
  }
684
623
  }
685
- // ──── pushover ─────────────────────────────────────────────────────────────
686
624
  async function deliverPushover(payload) {
687
625
  const cfg = config.providers;
688
626
  if (!cfg.pushoverToken || !cfg.pushoverUserKey) {
@@ -700,7 +638,6 @@ window.__ModuleLoader__.load({
700
638
  fields.device = cfg.pushoverDevice;
701
639
  await postForm('https://api.pushover.net/1/messages.json', fields);
702
640
  }
703
- // ──── serverchan ───────────────────────────────────────────────────────────
704
641
  async function deliverServerChan(payload) {
705
642
  const cfg = config.providers;
706
643
  if (!cfg.serverchanSendKey)
@@ -710,7 +647,6 @@ window.__ModuleLoader__.load({
710
647
  desp: payload.body + (payload.url ? `\n\n[打开 DSH](${payload.url})` : ''),
711
648
  });
712
649
  }
713
- // ──── discord ──────────────────────────────────────────────────────────────
714
650
  async function deliverDiscord(payload) {
715
651
  const cfg = config.providers;
716
652
  if (!cfg.discordWebhookUrl)
@@ -720,7 +656,6 @@ window.__ModuleLoader__.load({
720
656
  username: 'DSH Reminder',
721
657
  });
722
658
  }
723
- // ──── slack ────────────────────────────────────────────────────────────────
724
659
  async function deliverSlack(payload) {
725
660
  const cfg = config.providers;
726
661
  if (!cfg.slackWebhookUrl)
@@ -729,196 +664,400 @@ window.__ModuleLoader__.load({
729
664
  text: `*${payload.title}*\n${payload.body}${payload.url ? `\n<${payload.url}|Open DSH>` : ''}`,
730
665
  });
731
666
  }
732
- // ──── generic webhook ──────────────────────────────────────────────────────
733
667
  async function deliverWebhook(payload) {
734
668
  const cfg = config.providers;
735
669
  if (!cfg.webhookUrl)
736
670
  throw new Error('Webhook provider requires webhookUrl');
737
- const body = cfg.webhookPayload
738
- ? cfg.webhookPayload(payload)
739
- : payload;
671
+ const body = cfg.webhookPayload ? cfg.webhookPayload(payload) : payload;
740
672
  await postJson(cfg.webhookUrl, body);
741
673
  }
742
- // ──── custom ───────────────────────────────────────────────────────────────
743
674
  async function deliverCustom(payload) {
744
675
  const fn = config.providers.customSend;
745
676
  if (!fn)
746
677
  throw new Error('Custom provider requires providers.customSend');
747
678
  await fn(payload);
748
679
  }
749
- // ──── Helpers ──────────────────────────────────────────────────────────────
750
680
  function toError(value) {
751
681
  if (value instanceof Error)
752
682
  return value;
753
683
  return new Error(typeof value === 'string' ? value : JSON.stringify(value));
754
684
  }
755
- // ──── Settings panel ───────────────────────────────────────────────────────
756
- const SETTINGS_STYLE_ID = 'dsh-completion-reminder-settings-style';
757
- function ensureSettingsButton() {
758
- if (state.settingsButtonEl && document.body.contains(state.settingsButtonEl))
759
- return;
760
- injectSettingsStyles();
761
- const btn = document.createElement('button');
762
- btn.type = 'button';
763
- btn.className = 'dsh-reminder-fab';
764
- btn.setAttribute('aria-label', 'Completion Reminder Settings');
765
- btn.title = 'Completion Reminder';
766
- btn.innerHTML = `
767
- <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
768
- <path fill="currentColor" d="M12 2a7 7 0 0 0-7 7v3.586l-1.707 1.707A1 1 0 0 0 4 15.5h16a1 1 0 0 0 .707-1.707L19 12.586V9a7 7 0 0 0-7-7zm0 19a3 3 0 0 0 3-3H9a3 3 0 0 0 3 3z"/>
769
- </svg>
770
- `;
771
- btn.addEventListener('click', () => toggleSettingsPanel());
772
- document.body.appendChild(btn);
773
- state.settingsButtonEl = btn;
685
+ // ──── Settings dialog integration ──────────────────────────────────────────
686
+ /**
687
+ * Map a provider id to the credential fields it needs. Used to filter
688
+ * the panel so users only see the fields relevant to their choice.
689
+ */
690
+ const PROVIDER_FIELDS = {
691
+ browser: [],
692
+ telegram: [
693
+ { key: 'telegramBotToken', label: 'Bot Token', placeholder: '123456:ABC…' },
694
+ { key: 'telegramChatId', label: 'Chat ID', placeholder: '123456789' },
695
+ ],
696
+ bark: [
697
+ { key: 'barkKey', label: 'Bark Key', placeholder: 'iPhone Bark 设备 Key' },
698
+ { key: 'barkServer', label: 'Bark Server(可选)', placeholder: 'https://api.day.app' },
699
+ ],
700
+ pushover: [
701
+ { key: 'pushoverToken', label: 'App Token', placeholder: 'a…' },
702
+ { key: 'pushoverUserKey', label: 'User Key', placeholder: 'u…' },
703
+ { key: 'pushoverDevice', label: 'Device(可选)', placeholder: '留空推送到所有设备' },
704
+ ],
705
+ serverchan: [
706
+ { key: 'serverchanSendKey', label: 'Server酱 SendKey', placeholder: 'SCT…' },
707
+ ],
708
+ discord: [
709
+ { key: 'discordWebhookUrl', label: 'Discord Webhook URL', placeholder: 'https://discord.com/api/webhooks/…' },
710
+ ],
711
+ slack: [
712
+ { key: 'slackWebhookUrl', label: 'Slack Webhook URL', placeholder: 'https://hooks.slack.com/services/…' },
713
+ ],
714
+ webhook: [
715
+ { key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://your-service.example/notify' },
716
+ ],
717
+ custom: [
718
+ { key: 'webhookUrl', label: 'Custom provider 占位字段', placeholder: '可填任意值' },
719
+ ],
720
+ };
721
+ const PROVIDER_LABELS = [
722
+ ['browser', '🌐 浏览器通知'],
723
+ ['telegram', '✈️ Telegram'],
724
+ ['bark', '🍎 Bark (iOS)'],
725
+ ['pushover', '📲 Pushover'],
726
+ ['serverchan', '🐦 Server酱'],
727
+ ['discord', '🎮 Discord'],
728
+ ['slack', '💼 Slack'],
729
+ ['webhook', '🔗 通用 Webhook'],
730
+ ['custom', '🛠 自定义'],
731
+ ];
732
+ const PANEL_STYLE_ID = 'dsh-completion-reminder-panel-style';
733
+ /**
734
+ * The Host React component. It renders a stable <div> that the
735
+ * vanilla JS portion of the plugin fills in. This way we get free
736
+ * DSH theming (the host div inherits the dialog's CSS variables) and
737
+ * we don't have to bundle React/JSX-runtime.
738
+ */
739
+ const Host = forwardRef((_props, ref) => {
740
+ const innerRef = useRef(null);
741
+ useImperativeHandle(ref, () => innerRef.current);
742
+ return React.createElement('div', {
743
+ ref: innerRef,
744
+ className: 'dsh-reminder-host',
745
+ 'data-reminder-host': '',
746
+ });
747
+ });
748
+ Host.displayName = 'DSHCompletionReminderHost';
749
+ /**
750
+ * Component registered with DSH's `settings.section` slot. Renders a
751
+ * ref-bound div into the dialog; the ref is captured by the parent
752
+ * closure and populated with the actual settings UI.
753
+ */
754
+ function makeSectionComponent(registerHost) {
755
+ return function ReminderSection() {
756
+ const localRef = useRef(null);
757
+ useEffect(() => {
758
+ if (localRef.current) {
759
+ registerHost(localRef.current);
760
+ }
761
+ return () => {
762
+ if (localRef.current) {
763
+ localRef.current.innerHTML = '';
764
+ }
765
+ };
766
+ }, []);
767
+ return React.createElement(Host, { ref: localRef });
768
+ };
774
769
  }
775
- function refreshSettingsButtonVisibility() {
776
- if (config.showSettingsPanel) {
777
- ensureSettingsButton();
770
+ /**
771
+ * Plugin entry point invoked by the DSH Cordis Loader.
772
+ *
773
+ * In DSH v1.x the host invokes the plugin's exported `apply(ctx, opts)`
774
+ * after the module system has been bootstrapped. We use the context
775
+ * to register a settings section.
776
+ */
777
+ function apply(ctx, opts) {
778
+ configure(opts);
779
+ activate();
780
+ // Register the settings entry point.
781
+ //
782
+ // The user finds this under "DSH 设置 → 插件 → 完成提醒" — a sibling
783
+ // tab to the existing "可配置" (configurable) tab. We register:
784
+ // 1. `settings.plugins.tab` — a new tab inside the Plugins section
785
+ // 2. `settings.section` — fallback top-level section in case
786
+ // the host is too old to expose the plugins tab slot
787
+ // The tab registration is the primary one and shows up in the right
788
+ // place; the section is registered too so the user can still find
789
+ // the configuration if the host has a non-standard Plugins UI.
790
+ try {
791
+ if (ctx && ctx.slots) {
792
+ const slots = ctx.slots;
793
+ const hostRef = { current: null };
794
+ // ──── primary: a tab inside the Plugins section ────────────────
795
+ // DSH v1.2+ uses `settings.plugins.tab` to render tab buttons in
796
+ // the Plugins section. The component factory is invoked with DI
797
+ // props; we accept them and ignore everything except the empty
798
+ // `renderSlot`, since our body is a vanilla-DOM host.
799
+ try {
800
+ slots.inject('settings.plugins.tab', () => {
801
+ return slots.register({
802
+ name: 'settings.plugins.tab',
803
+ id: 'reminder',
804
+ order: 100,
805
+ label: () => '🔔 完成提醒',
806
+ locale: '@dsh-completion-reminder',
807
+ inject: () => ({}),
808
+ }, function ReminderPluginsTab() {
809
+ const localRef = useRef(null);
810
+ useEffect(() => {
811
+ if (localRef.current) {
812
+ hostRef.current = localRef.current;
813
+ injectPanelInto(localRef.current);
814
+ }
815
+ return () => {
816
+ if (localRef.current)
817
+ localRef.current.innerHTML = '';
818
+ };
819
+ }, []);
820
+ return React.createElement(Host, { ref: localRef });
821
+ });
822
+ });
823
+ }
824
+ catch (err) {
825
+ try {
826
+ console.warn('[dsh-completion-reminder] settings.plugins.tab registration failed:', err);
827
+ }
828
+ catch { /* noop */ }
829
+ }
830
+ // ──── fallback: top-level section ───────────────────────────────
831
+ // Older hosts that don't know about the plugins-tab slot will
832
+ // accept a top-level section registration so the user can still
833
+ // find the configuration.
834
+ try {
835
+ slots.inject('settings.section', () => {
836
+ const sectionFactory = makeSectionComponent((el) => {
837
+ hostRef.current = el;
838
+ injectPanelInto(el);
839
+ });
840
+ return slots.register({
841
+ name: 'settings.section',
842
+ id: 'reminder',
843
+ order: 50,
844
+ label: () => '🔔 提醒',
845
+ locale: '@dsh-completion-reminder',
846
+ inject: () => ({}),
847
+ }, sectionFactory);
848
+ });
849
+ }
850
+ catch (err) {
851
+ try {
852
+ console.warn('[dsh-completion-reminder] settings.section registration failed:', err);
853
+ }
854
+ catch { /* noop */ }
855
+ }
856
+ // If neither slot was registered, the host is too old. The
857
+ // detection still works, but the user has nowhere to configure;
858
+ // surface a hint.
859
+ if (!hostRef.current) {
860
+ // Defer: hostRef gets populated on tab/section mount, but
861
+ // that happens after DSH opens the dialog. Show a hint now.
862
+ showFirstRunHint('DSH 主机不支持 settings.plugins.tab 槽位。');
863
+ }
864
+ }
865
+ else {
866
+ showFirstRunHint();
867
+ }
778
868
  }
779
- else if (state.settingsButtonEl) {
780
- state.settingsButtonEl.remove();
781
- state.settingsButtonEl = null;
782
- removeSettingsPanel();
869
+ catch (err) {
870
+ try {
871
+ console.warn('[dsh-completion-reminder] failed to register settings UI:', err);
872
+ }
873
+ catch { /* noop */ }
874
+ showFirstRunHint();
783
875
  }
784
876
  }
785
- function removeSettingsUI() {
786
- removeSettingsPanel();
787
- if (state.settingsButtonEl) {
788
- state.settingsButtonEl.remove();
789
- state.settingsButtonEl = null;
877
+ /**
878
+ * Show a single, dismissable hint that points the user to the DSH
879
+ * settings dialog. We only show it once and only when the section
880
+ * registration failed (older host, no slots service, etc.).
881
+ */
882
+ function showFirstRunHint(suffix) {
883
+ if (state.hintEl || hasPersistedConfig())
884
+ return;
885
+ injectHintStyles();
886
+ const el = document.createElement('div');
887
+ el.className = 'dsh-reminder-hint';
888
+ const msg = suffix || '已激活。请在 DSH 设置里配置通知渠道。';
889
+ el.innerHTML = `
890
+ <span>🔔 DSH Completion Reminder ${escapeHtml(msg)}</span>
891
+ <button type="button" data-reminder-hint-dismiss>知道了</button>
892
+ `;
893
+ el.querySelector('[data-reminder-hint-dismiss]')?.addEventListener('click', () => {
894
+ el.remove();
895
+ state.hintEl = null;
896
+ });
897
+ document.body.appendChild(el);
898
+ state.hintEl = el;
899
+ setTimeout(() => {
900
+ el.classList.add('dsh-reminder-hint-leave');
901
+ setTimeout(() => { el.remove(); state.hintEl = null; }, 600);
902
+ }, 12000);
903
+ }
904
+ function removeHint() {
905
+ if (state.hintEl) {
906
+ state.hintEl.remove();
907
+ state.hintEl = null;
790
908
  }
791
909
  }
792
- function toggleSettingsPanel() {
793
- if (state.panelOpen)
794
- removeSettingsPanel();
795
- else
796
- openSettingsPanel();
910
+ function hasPersistedConfig() {
911
+ if (typeof localStorage === 'undefined')
912
+ return false;
913
+ try {
914
+ return !!localStorage.getItem(STORAGE_KEY);
915
+ }
916
+ catch {
917
+ return false;
918
+ }
797
919
  }
798
- function openSettingsPanel() {
799
- if (state.settingsPanelEl)
920
+ const HINT_STYLE_ID = 'dsh-completion-reminder-hint-style';
921
+ function injectHintStyles() {
922
+ if (document.getElementById(HINT_STYLE_ID))
800
923
  return;
801
- injectSettingsStyles();
802
- const panel = document.createElement('div');
803
- panel.className = 'dsh-reminder-panel';
804
- panel.setAttribute('role', 'dialog');
805
- panel.setAttribute('aria-label', 'Completion Reminder Settings');
806
- panel.innerHTML = renderSettingsPanel();
807
- document.body.appendChild(panel);
808
- state.settingsPanelEl = panel;
809
- state.panelOpen = true;
810
- wireSettingsPanel(panel);
811
- }
812
- function removeSettingsPanel() {
813
- if (state.settingsPanelEl) {
814
- state.settingsPanelEl.remove();
815
- state.settingsPanelEl = null;
924
+ const style = document.createElement('style');
925
+ style.id = HINT_STYLE_ID;
926
+ style.textContent = `
927
+ .dsh-reminder-hint {
928
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
929
+ z-index: 2147483600;
930
+ display: flex; align-items: center; gap: 10px;
931
+ background: ${DSH_CSS_VARS.bgModule}; color: ${DSH_CSS_VARS.labelPrimary};
932
+ border: 1px solid ${DSH_CSS_VARS.borderL3}; border-radius: 999px;
933
+ box-shadow: ${DSH_CSS_VARS.shadowLv3};
934
+ padding: 8px 8px 8px 14px;
935
+ font-size: 13px; line-height: 1.2;
936
+ transition: opacity .4s, transform .4s;
937
+ }
938
+ .dsh-reminder-hint button {
939
+ background: ${DSH_CSS_VARS.buttonInfoFill}; color: #fff;
940
+ border: none; border-radius: 999px; padding: 4px 10px;
941
+ font-size: 12px; cursor: pointer; font-family: inherit;
942
+ }
943
+ .dsh-reminder-hint-leave { opacity: 0; transform: translate(-50%, 12px); }
944
+ `;
945
+ document.head.appendChild(style);
946
+ }
947
+ // ──── Panel rendering (vanilla DOM inside the DSH settings dialog) ─────────
948
+ /**
949
+ * Provider descriptions shown next to the picker.
950
+ */
951
+ const PROVIDER_DESCRIPTIONS = {
952
+ browser: '使用浏览器原生通知 API,首次使用需用户授权。',
953
+ telegram: '通过 Telegram Bot 推送到你的聊天 / 频道 / 群组。',
954
+ bark: '通过 Bark HTTP API 推送到 iPhone。',
955
+ pushover: '通过 Pushover 推送到 Android / iOS / 桌面。',
956
+ serverchan: '推送到微信(sct.ftqq.com,SendKey)。',
957
+ discord: '通过 Discord Webhook 推送到频道。',
958
+ slack: '通过 Slack Incoming Webhook 推送到频道。',
959
+ webhook: 'POST JSON 到你提供的 URL。',
960
+ custom: '通过 customSend(payload) 函数自己实现。',
961
+ };
962
+ function injectPanelInto(host) {
963
+ state.panelHostEl = host;
964
+ renderPanelInto(host);
965
+ }
966
+ function rerenderPanel() {
967
+ if (state.panelHostEl) {
968
+ renderPanelInto(state.panelHostEl);
816
969
  }
817
- state.panelOpen = false;
818
- }
819
- function renderSettingsPanel() {
820
- const providers = [
821
- ['browser', '🌐 浏览器通知'],
822
- ['telegram', '✈️ Telegram'],
823
- ['bark', '🍎 Bark (iOS)'],
824
- ['pushover', '📲 Pushover'],
825
- ['serverchan', '🐦 Server酱'],
826
- ['discord', '🎮 Discord'],
827
- ['slack', '💼 Slack'],
828
- ['webhook', '🔗 通用 Webhook'],
829
- ['custom', '🛠 自定义'],
830
- ];
831
- const providerOptions = providers
832
- .map(([id, name]) => `<option value="${id}" ${config.provider === id ? 'selected' : ''}>${name}</option>`)
970
+ }
971
+ function renderPanelInto(host) {
972
+ injectPanelStyles();
973
+ host.innerHTML = buildPanelHtml();
974
+ wirePanelEvents(host);
975
+ }
976
+ function buildPanelHtml() {
977
+ const providerOptions = PROVIDER_LABELS
978
+ .map(([id, label]) => `<option value="${id}" ${config.provider === id ? 'selected' : ''}>${escapeHtml(label)}</option>`)
833
979
  .join('');
834
980
  const p = config.providers;
835
- const fields = [
836
- ['telegramBotToken', 'Telegram Bot Token', 'bot123456:ABC…', p.telegramBotToken ?? ''],
837
- ['telegramChatId', 'Telegram Chat ID', '123456789', p.telegramChatId ?? ''],
838
- ['barkKey', 'Bark Key', '你的 iPhone Bark 设备 Key', p.barkKey ?? ''],
839
- ['barkServer', 'Bark Server(可选)', '默认 https://api.day.app', p.barkServer ?? ''],
840
- ['pushoverUserKey', 'Pushover User Key', 'u…', p.pushoverUserKey ?? ''],
841
- ['pushoverToken', 'Pushover App Token', 'a…', p.pushoverToken ?? ''],
842
- ['pushoverDevice', 'Pushover Device(可选)', '留空推送到所有设备', p.pushoverDevice ?? ''],
843
- ['serverchanSendKey', 'Server酱 SendKey', 'SCT…', p.serverchanSendKey ?? ''],
844
- ['discordWebhookUrl', 'Discord Webhook URL', 'https://discord.com/api/webhooks/…', p.discordWebhookUrl ?? ''],
845
- ['slackWebhookUrl', 'Slack Webhook URL', 'https://hooks.slack.com/services/…', p.slackWebhookUrl ?? ''],
846
- ['webhookUrl', '通用 Webhook URL', 'https://your-service.example/notify', p.webhookUrl ?? ''],
847
- ];
848
- const providerFieldsHtml = fields
849
- .map(([name, label, placeholder, value]) => `
850
- <label class="dsh-reminder-field" data-provider-field>
851
- <span>${label}</span>
852
- <input type="text" name="${name}" data-provider-input="${name}" placeholder="${escapeAttr(placeholder)}" value="${escapeAttr(value)}" autocomplete="off" spellcheck="false" />
853
- </label>`)
854
- .join('');
855
- const perm = state.permission;
981
+ const fields = PROVIDER_FIELDS[config.provider] ?? [];
982
+ const fieldsHtml = fields.length === 0
983
+ ? `<p class="dsh-reminder-panel-hint">此渠道无需凭证,保存即可使用。</p>`
984
+ : fields.map((f) => {
985
+ const value = p[f.key] ?? '';
986
+ return `
987
+ <label class="dsh-reminder-panel-field">
988
+ <span class="dsh-reminder-panel-label">${escapeHtml(f.label)}</span>
989
+ <input type="text" data-reminder-field="${escapeAttr(f.key)}" value="${escapeAttr(value)}" placeholder="${escapeAttr(f.placeholder)}" autocomplete="off" spellcheck="false" />
990
+ </label>
991
+ `;
992
+ }).join('');
993
+ const desc = PROVIDER_DESCRIPTIONS[config.provider] ?? '';
856
994
  return `
857
- <header class="dsh-reminder-panel-header">
858
- <strong>提醒设置</strong>
859
- <button type="button" class="dsh-reminder-close" data-reminder-action="close" aria-label="关闭">✕</button>
860
- </header>
861
- <div class="dsh-reminder-panel-body">
862
- <label class="dsh-reminder-field">
863
- <span>通知渠道</span>
864
- <select data-reminder-input="provider">${providerOptions}</select>
865
- </label>
995
+ <div class="dsh-reminder-panel">
996
+ <header class="dsh-reminder-panel-header">
997
+ <strong>Agent 完成提醒</strong>
998
+ <span class="dsh-reminder-panel-sub">配置通知渠道,agent 完成后通知你</span>
999
+ </header>
866
1000
 
867
- <fieldset class="dsh-reminder-group" data-reminder-group="provider-fields">
868
- <legend>渠道凭证(仅当前渠道需要)</legend>
869
- ${providerFieldsHtml}
870
- <p class="dsh-reminder-hint">
871
- 配置只保存在本浏览器的 localStorage,
872
- <strong>不会</strong> 上传到任何服务器。
873
- </p>
874
- </fieldset>
1001
+ <section class="dsh-reminder-panel-section">
1002
+ <label class="dsh-reminder-panel-field">
1003
+ <span class="dsh-reminder-panel-label">通知渠道</span>
1004
+ <select data-reminder-input="provider">${providerOptions}</select>
1005
+ </label>
1006
+ <p class="dsh-reminder-panel-hint">${escapeHtml(desc)}</p>
1007
+ </section>
1008
+
1009
+ <section class="dsh-reminder-panel-section">
1010
+ <header class="dsh-reminder-panel-section-title">凭证(仅当前渠道需要)</header>
1011
+ ${fieldsHtml}
1012
+ </section>
875
1013
 
876
- <fieldset class="dsh-reminder-group">
877
- <legend>行为</legend>
878
- <label class="dsh-reminder-row">
1014
+ <section class="dsh-reminder-panel-section">
1015
+ <header class="dsh-reminder-panel-section-title">行为</header>
1016
+ <label class="dsh-reminder-panel-row">
879
1017
  <input type="checkbox" data-reminder-input="notifyOnSuccess" ${config.notifyOnSuccess ? 'checked' : ''} />
880
1018
  <span>成功完成时通知</span>
881
1019
  </label>
882
- <label class="dsh-reminder-row">
1020
+ <label class="dsh-reminder-panel-row">
883
1021
  <input type="checkbox" data-reminder-input="notifyOnStopped" ${config.notifyOnStopped ? 'checked' : ''} />
884
1022
  <span>用户主动停止时通知</span>
885
1023
  </label>
886
- <label class="dsh-reminder-row">
1024
+ <label class="dsh-reminder-panel-row">
887
1025
  <input type="checkbox" data-reminder-input="notifyOnError" ${config.notifyOnError ? 'checked' : ''} />
888
1026
  <span>Agent 出错时通知</span>
889
1027
  </label>
890
- <label class="dsh-reminder-row">
1028
+ <label class="dsh-reminder-panel-row">
891
1029
  <input type="checkbox" data-reminder-input="suppressWhenFocused" ${config.suppressWhenFocused ? 'checked' : ''} />
892
1030
  <span>DSH 标签页可见时静默(推荐)</span>
893
1031
  </label>
894
- <label class="dsh-reminder-row">
1032
+ <label class="dsh-reminder-panel-row">
895
1033
  <input type="checkbox" data-reminder-input="autoRequestPermission" ${config.autoRequestPermission ? 'checked' : ''} />
896
1034
  <span>自动请求浏览器通知权限</span>
897
1035
  </label>
898
- <label class="dsh-reminder-field">
899
- <span>冷却(ms)— 防止连续完成时刷屏</span>
1036
+ <label class="dsh-reminder-panel-field">
1037
+ <span class="dsh-reminder-panel-label">冷却(ms)— 防止连续完成时刷屏</span>
900
1038
  <input type="number" min="0" step="500" data-reminder-input="cooldownMs" value="${config.cooldownMs}" />
901
1039
  </label>
902
- </fieldset>
1040
+ </section>
903
1041
 
904
- <div class="dsh-reminder-row dsh-reminder-status">
905
- <span>当前权限:<strong data-reminder-perm>${permissionLabel(perm)}</strong></span>
1042
+ <section class="dsh-reminder-panel-section dsh-reminder-panel-perm">
1043
+ <span>当前权限:<strong data-reminder-perm>${permissionLabel(state.permission)}</strong></span>
906
1044
  <button type="button" data-reminder-action="request-permission">请求权限</button>
907
- </div>
1045
+ </section>
908
1046
 
909
- <div class="dsh-reminder-actions">
1047
+ <footer class="dsh-reminder-panel-actions">
910
1048
  <button type="button" class="primary" data-reminder-action="test">发送测试通知</button>
911
- <button type="button" data-reminder-action="save">保存</button>
912
1049
  <button type="button" data-reminder-action="reset">重置</button>
913
- </div>
914
- <p class="dsh-reminder-hint" data-reminder-status></p>
1050
+ <span class="dsh-reminder-panel-status" data-reminder-status></span>
1051
+ </footer>
1052
+ <p class="dsh-reminder-panel-hint">配置只保存在本浏览器的 localStorage,不会上传任何服务器。</p>
915
1053
  </div>
916
1054
  `;
917
1055
  }
918
- function wireSettingsPanel(panel) {
919
- panel.querySelector('[data-reminder-action="close"]')
920
- ?.addEventListener('click', () => removeSettingsPanel());
921
- panel.addEventListener('change', (ev) => {
1056
+ function wirePanelEvents(host) {
1057
+ const root = host.querySelector('.dsh-reminder-panel');
1058
+ if (!root)
1059
+ return;
1060
+ root.addEventListener('change', (ev) => {
922
1061
  const target = ev.target;
923
1062
  if (!target)
924
1063
  return;
@@ -926,10 +1065,15 @@ window.__ModuleLoader__.load({
926
1065
  const name = target.dataset.reminderInput;
927
1066
  if (name === 'provider') {
928
1067
  config.provider = target.value;
1068
+ savePersisted();
1069
+ // Re-render so the credentials section updates to the new channel.
1070
+ renderPanelInto(host);
929
1071
  }
930
1072
  }
931
1073
  else if (target instanceof HTMLInputElement) {
932
1074
  const name = target.dataset.reminderInput;
1075
+ if (!name)
1076
+ return;
933
1077
  const value = target.type === 'checkbox' ? target.checked
934
1078
  : target.type === 'number' ? Number(target.value) || 0
935
1079
  : target.value;
@@ -945,26 +1089,20 @@ window.__ModuleLoader__.load({
945
1089
  config.autoRequestPermission = !!value;
946
1090
  else if (name === 'cooldownMs')
947
1091
  config.cooldownMs = Number(value) || 0;
1092
+ savePersisted();
948
1093
  }
949
- savePersisted();
950
1094
  });
951
- panel.addEventListener('input', (ev) => {
1095
+ root.addEventListener('input', (ev) => {
952
1096
  const target = ev.target;
953
1097
  if (!target)
954
1098
  return;
955
- const name = target.dataset.providerInput;
956
- if (name) {
957
- config.providers[name] = target.value;
1099
+ const key = target.dataset.reminderField;
1100
+ if (key) {
1101
+ config.providers[key] = target.value;
958
1102
  savePersisted();
959
1103
  }
960
1104
  });
961
- panel.querySelector('[data-reminder-action="save"]')
962
- ?.addEventListener('click', () => {
963
- savePersisted();
964
- setStatus(panel, '已保存 ✓');
965
- });
966
- panel.querySelector('[data-reminder-action="reset"]')
967
- ?.addEventListener('click', () => {
1105
+ root.querySelector('[data-reminder-action="reset"]')?.addEventListener('click', () => {
968
1106
  if (typeof localStorage !== 'undefined') {
969
1107
  try {
970
1108
  localStorage.removeItem(STORAGE_KEY);
@@ -972,18 +1110,18 @@ window.__ModuleLoader__.load({
972
1110
  catch { /* noop */ }
973
1111
  }
974
1112
  configure();
975
- removeSettingsPanel();
976
- openSettingsPanel();
1113
+ renderPanelInto(host);
1114
+ setPanelStatus(root, '已重置 ✓');
977
1115
  });
978
- panel.querySelector('[data-reminder-action="request-permission"]')
1116
+ root.querySelector('[data-reminder-action="request-permission"]')
979
1117
  ?.addEventListener('click', async () => {
980
1118
  const result = await requestBrowserPermission();
981
- const permEl = panel.querySelector('[data-reminder-perm]');
1119
+ const permEl = root.querySelector('[data-reminder-perm]');
982
1120
  if (permEl)
983
1121
  permEl.textContent = permissionLabel(result);
984
- setStatus(panel, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
1122
+ setPanelStatus(root, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
985
1123
  });
986
- panel.querySelector('[data-reminder-action="test"]')
1124
+ root.querySelector('[data-reminder-action="test"]')
987
1125
  ?.addEventListener('click', async () => {
988
1126
  try {
989
1127
  const ctx = {
@@ -1002,16 +1140,16 @@ window.__ModuleLoader__.load({
1002
1140
  };
1003
1141
  await dispatch(payload);
1004
1142
  config.onNotify(payload, config.provider);
1005
- setStatus(panel, '测试通知已发送,请查收。');
1143
+ setPanelStatus(root, '测试通知已发送,请查收。');
1006
1144
  }
1007
1145
  catch (err) {
1008
1146
  const e = toError(err);
1009
1147
  config.onError(e, config.provider);
1010
- setStatus(panel, '测试失败:' + e.message, true);
1148
+ setPanelStatus(root, '测试失败:' + e.message, true);
1011
1149
  }
1012
1150
  });
1013
1151
  }
1014
- function setStatus(panel, text, isError = false) {
1152
+ function setPanelStatus(panel, text, isError = false) {
1015
1153
  const el = panel.querySelector('[data-reminder-status]');
1016
1154
  if (!el)
1017
1155
  return;
@@ -1033,169 +1171,187 @@ window.__ModuleLoader__.load({
1033
1171
  return '不支持';
1034
1172
  return '未询问';
1035
1173
  }
1036
- function escapeAttr(s) {
1174
+ function escapeHtml(s) {
1037
1175
  return s
1038
1176
  .replace(/&/g, '&amp;')
1039
- .replace(/"/g, '&quot;')
1040
1177
  .replace(/</g, '&lt;')
1041
1178
  .replace(/>/g, '&gt;');
1042
1179
  }
1043
- function injectSettingsStyles() {
1044
- if (document.getElementById(SETTINGS_STYLE_ID))
1180
+ function escapeAttr(s) {
1181
+ return escapeHtml(s).replace(/"/g, '&quot;');
1182
+ }
1183
+ function injectPanelStyles() {
1184
+ if (document.getElementById(PANEL_STYLE_ID))
1045
1185
  return;
1046
1186
  const style = document.createElement('style');
1047
- style.id = SETTINGS_STYLE_ID;
1187
+ style.id = PANEL_STYLE_ID;
1188
+ // The host's parent (DSH settings dialog) sets `color-scheme: dark`
1189
+ // for the dark theme, so we explicitly hint the dark UA scheme on
1190
+ // selects + add explicit colors that work in both themes. Native
1191
+ // form controls otherwise default to UA colors (white-on-white in
1192
+ // dark mode).
1048
1193
  style.textContent = `
1049
- .dsh-reminder-fab {
1050
- position: fixed;
1051
- right: 18px;
1052
- bottom: 18px;
1053
- z-index: 2147483600;
1054
- width: 40px;
1055
- height: 40px;
1056
- border-radius: 50%;
1057
- background: ${DSH_CSS_VARS.buttonInfoFill};
1058
- color: #fff;
1059
- border: none;
1060
- box-shadow: ${DSH_CSS_VARS.shadowLv3};
1061
- cursor: pointer;
1062
- display: grid;
1063
- place-items: center;
1064
- opacity: 0.85;
1065
- transition: opacity .15s, transform .15s;
1066
- }
1067
- .dsh-reminder-fab:hover { opacity: 1; transform: scale(1.05); }
1068
-
1194
+ .dsh-reminder-host { color: inherit; }
1195
+ .dsh-reminder-host,
1196
+ .dsh-reminder-host * { box-sizing: border-box; }
1069
1197
  .dsh-reminder-panel {
1070
- position: fixed;
1071
- right: 18px;
1072
- bottom: 70px;
1073
- z-index: 2147483601;
1074
- width: 360px;
1075
- max-height: min(80vh, 640px);
1076
- overflow: auto;
1077
- background: ${DSH_CSS_VARS.bgModule};
1198
+ display: flex;
1199
+ flex-direction: column;
1200
+ gap: 18px;
1078
1201
  color: ${DSH_CSS_VARS.labelPrimary};
1079
- border: 1px solid ${DSH_CSS_VARS.borderL3};
1080
- border-radius: 12px;
1081
- box-shadow: ${DSH_CSS_VARS.shadowLv3};
1082
1202
  font-size: 13px;
1083
- line-height: 1.45;
1084
- font-family: var(--dsw-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
1203
+ line-height: 1.5;
1204
+ padding: 4px 4px 24px;
1085
1205
  }
1086
1206
  .dsh-reminder-panel-header {
1087
1207
  display: flex;
1088
- align-items: center;
1089
- justify-content: space-between;
1090
- padding: 10px 12px;
1208
+ flex-direction: column;
1209
+ gap: 2px;
1091
1210
  border-bottom: 1px solid ${DSH_CSS_VARS.borderL1};
1092
- font-size: 14px;
1093
- font-weight: 600;
1094
- position: sticky;
1095
- top: 0;
1096
- background: ${DSH_CSS_VARS.bgModule};
1211
+ padding-bottom: 12px;
1097
1212
  }
1098
- .dsh-reminder-close {
1099
- background: transparent;
1100
- border: none;
1213
+ .dsh-reminder-panel-header strong { font-size: 15px; font-weight: 600; }
1214
+ .dsh-reminder-panel-sub { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; }
1215
+
1216
+ .dsh-reminder-panel-section {
1217
+ display: flex;
1218
+ flex-direction: column;
1219
+ gap: 10px;
1220
+ }
1221
+ .dsh-reminder-panel-section-title {
1222
+ font-size: 12px;
1101
1223
  color: ${DSH_CSS_VARS.labelSecondary};
1102
- cursor: pointer;
1103
- font-size: 16px;
1104
- line-height: 1;
1105
- padding: 2px 6px;
1106
- border-radius: 4px;
1224
+ text-transform: uppercase;
1225
+ letter-spacing: 0.04em;
1107
1226
  }
1108
- .dsh-reminder-close:hover { background: ${DSH_CSS_VARS.borderL1}; color: ${DSH_CSS_VARS.labelPrimary}; }
1109
-
1110
- .dsh-reminder-panel-body { padding: 12px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
1111
1227
 
1112
- .dsh-reminder-group {
1113
- border: 1px solid ${DSH_CSS_VARS.borderL1};
1114
- border-radius: 8px;
1115
- padding: 8px 10px 10px;
1116
- margin: 0;
1228
+ .dsh-reminder-panel-field {
1117
1229
  display: flex;
1118
1230
  flex-direction: column;
1119
- gap: 8px;
1231
+ gap: 4px;
1120
1232
  }
1121
- .dsh-reminder-group > legend { padding: 0 4px; color: ${DSH_CSS_VARS.labelSecondary}; font-size: 12px; }
1122
-
1123
- .dsh-reminder-field { display: flex; flex-direction: column; gap: 4px; }
1124
- .dsh-reminder-field > span { color: ${DSH_CSS_VARS.labelSecondary}; font-size: 12px; }
1125
- .dsh-reminder-field input,
1126
- .dsh-reminder-field select {
1233
+ .dsh-reminder-panel-label {
1234
+ color: ${DSH_CSS_VARS.labelSecondary};
1235
+ font-size: 12px;
1236
+ }
1237
+ .dsh-reminder-panel-field input[type="text"],
1238
+ .dsh-reminder-panel-field input[type="number"],
1239
+ .dsh-reminder-panel-field select {
1240
+ color-scheme: light dark;
1241
+ width: 100%;
1127
1242
  background: transparent;
1128
1243
  color: ${DSH_CSS_VARS.labelPrimary};
1129
1244
  border: 1px solid ${DSH_CSS_VARS.borderL2};
1130
1245
  border-radius: 6px;
1131
- padding: 6px 8px;
1246
+ padding: 6px 10px;
1132
1247
  font-size: 13px;
1133
- outline: none;
1134
1248
  font-family: inherit;
1249
+ outline: none;
1135
1250
  transition: border-color .15s, box-shadow .15s;
1136
- }
1137
- .dsh-reminder-field input:focus,
1138
- .dsh-reminder-field select:focus {
1251
+ -webkit-appearance: none;
1252
+ appearance: none;
1253
+ }
1254
+ .dsh-reminder-panel-field select {
1255
+ /* Provide a small caret since we stripped the native chrome. */
1256
+ background-image: linear-gradient(45deg, transparent 50%, ${DSH_CSS_VARS.labelSecondary} 50%),
1257
+ linear-gradient(135deg, ${DSH_CSS_VARS.labelSecondary} 50%, transparent 50%);
1258
+ background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
1259
+ background-size: 5px 5px, 5px 5px;
1260
+ background-repeat: no-repeat;
1261
+ padding-right: 26px;
1262
+ }
1263
+ .dsh-reminder-panel-field input[type="text"]:focus,
1264
+ .dsh-reminder-panel-field input[type="number"]:focus,
1265
+ .dsh-reminder-panel-field select:focus {
1139
1266
  border-color: ${DSH_CSS_VARS.stateBusinessPrimary};
1140
1267
  box-shadow: 0 0 0 2px color-mix(in srgb, ${DSH_CSS_VARS.stateBusinessPrimary} 25%, transparent);
1141
1268
  }
1142
1269
 
1143
- .dsh-reminder-row { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
1144
- .dsh-reminder-row > input[type="checkbox"] { accent-color: ${DSH_CSS_VARS.buttonInfoFill}; }
1270
+ .dsh-reminder-panel-row {
1271
+ display: flex;
1272
+ align-items: center;
1273
+ gap: 8px;
1274
+ cursor: pointer;
1275
+ user-select: none;
1276
+ }
1277
+ .dsh-reminder-panel-row > input[type="checkbox"] {
1278
+ accent-color: ${DSH_CSS_VARS.buttonInfoFill};
1279
+ }
1280
+
1281
+ .dsh-reminder-panel-hint {
1282
+ color: ${DSH_CSS_VARS.labelTertiary};
1283
+ font-size: 12px;
1284
+ line-height: 1.5;
1285
+ margin: 0;
1286
+ }
1145
1287
 
1146
- .dsh-reminder-actions { display: flex; gap: 8px; }
1147
- .dsh-reminder-actions button {
1148
- flex: 1;
1149
- padding: 6px 10px;
1150
- border: 1px solid ${DSH_CSS_VARS.borderL2};
1288
+ .dsh-reminder-panel-perm {
1289
+ flex-direction: row;
1290
+ align-items: center;
1291
+ justify-content: space-between;
1292
+ flex-wrap: wrap;
1293
+ gap: 8px;
1294
+ padding: 8px 10px;
1295
+ border: 1px dashed ${DSH_CSS_VARS.borderL2};
1296
+ border-radius: 6px;
1297
+ color: ${DSH_CSS_VARS.labelSecondary};
1298
+ font-size: 12px;
1299
+ }
1300
+ .dsh-reminder-panel-perm strong { color: ${DSH_CSS_VARS.labelPrimary}; }
1301
+ .dsh-reminder-panel-perm button {
1151
1302
  background: transparent;
1152
1303
  color: ${DSH_CSS_VARS.labelPrimary};
1304
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1153
1305
  border-radius: 6px;
1306
+ padding: 4px 10px;
1154
1307
  cursor: pointer;
1155
- font-size: 13px;
1308
+ font-size: 12px;
1156
1309
  font-family: inherit;
1157
1310
  }
1158
- .dsh-reminder-actions button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1159
- .dsh-reminder-actions button.primary { background: ${DSH_CSS_VARS.buttonInfoFill}; color: #fff; border-color: transparent; }
1160
- .dsh-reminder-actions button.primary:hover { filter: brightness(1.05); }
1311
+ .dsh-reminder-panel-perm button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1161
1312
 
1162
- .dsh-reminder-status {
1163
- padding: 6px 8px;
1164
- border: 1px dashed ${DSH_CSS_VARS.borderL2};
1165
- border-radius: 6px;
1166
- justify-content: space-between;
1313
+ .dsh-reminder-panel-actions {
1314
+ display: flex;
1315
+ align-items: center;
1316
+ gap: 8px;
1167
1317
  flex-wrap: wrap;
1168
1318
  }
1169
- .dsh-reminder-status button {
1319
+ .dsh-reminder-panel-actions button {
1320
+ padding: 6px 14px;
1321
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1170
1322
  background: transparent;
1171
1323
  color: ${DSH_CSS_VARS.labelPrimary};
1172
- border: 1px solid ${DSH_CSS_VARS.borderL2};
1173
1324
  border-radius: 6px;
1174
- padding: 4px 8px;
1175
1325
  cursor: pointer;
1176
- font-size: 12px;
1326
+ font-size: 13px;
1177
1327
  font-family: inherit;
1178
1328
  }
1179
- .dsh-reminder-status button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1329
+ .dsh-reminder-panel-actions button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1330
+ .dsh-reminder-panel-actions button.primary {
1331
+ background: ${DSH_CSS_VARS.buttonInfoFill};
1332
+ color: #fff;
1333
+ border-color: transparent;
1334
+ }
1335
+ .dsh-reminder-panel-actions button.primary:hover { filter: brightness(1.05); }
1180
1336
 
1181
- .dsh-reminder-hint { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; line-height: 1.5; }
1337
+ .dsh-reminder-panel-status {
1338
+ margin-left: auto;
1339
+ color: ${DSH_CSS_VARS.stateSuccessPrimary};
1340
+ font-size: 12px;
1341
+ min-width: 0;
1342
+ flex: 1 1 0;
1343
+ text-align: right;
1344
+ }
1182
1345
  `;
1183
1346
  document.head.appendChild(style);
1184
1347
  }
1185
- // ──── DSH client plugin entry ──────────────────────────────────────────────
1186
- /**
1187
- * DSH client plugin entry — called by the Cordis Loader.
1188
- */
1189
- function apply(ctx, opts) {
1190
- configure(opts);
1191
- activate();
1192
- }
1193
1348
  exports.configure = configure;
1194
1349
  exports.activate = activate;
1195
1350
  exports.deactivate = deactivate;
1196
1351
  exports.apply = apply;
1197
1352
  exports.requestBrowserPermission = requestBrowserPermission;
1198
1353
  exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
1354
+ exports.renderPanelInto = renderPanelInto;
1199
1355
  //# sourceMappingURL=client.js.map
1200
1356
 
1201
1357
  // ── export fallback (guard against stripped 'export { ... }' lists) ──