dsh-completion-reminder 1.1.0 → 1.2.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,343 @@ 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
+ // Try to register a settings section. If the host doesn't expose
781
+ // `ctx.slots` (older / different host), we silently fall back to the
782
+ // detection-only behaviour and surface a one-time hint.
783
+ try {
784
+ if (ctx && ctx.inject && ctx.slots) {
785
+ const slots = ctx.slots;
786
+ const inject = ctx.inject;
787
+ // The `slots.inject(name, factory)` API registers a slot
788
+ // contributor. The factory returns a teardown function (often
789
+ // `ctx.slots.register(...)`) that adds the entry. We keep the
790
+ // teardown around for symmetry but it is not currently used.
791
+ slots.inject('settings.section', () => {
792
+ // Register the section. The third arg is the React component
793
+ // factory; we provide a component that yields a ref-bound host.
794
+ const sectionFactory = makeSectionComponent((el) => {
795
+ state.panelHostEl = el;
796
+ injectPanelInto(el);
797
+ });
798
+ const off = slots.register({
799
+ name: 'settings.section',
800
+ id: 'reminder',
801
+ order: 50,
802
+ label: () => '🔔 提醒',
803
+ locale: '@dsh-completion-reminder',
804
+ inject: () => ({}),
805
+ }, sectionFactory);
806
+ return off;
807
+ });
808
+ }
809
+ else {
810
+ showFirstRunHint();
811
+ }
778
812
  }
779
- else if (state.settingsButtonEl) {
780
- state.settingsButtonEl.remove();
781
- state.settingsButtonEl = null;
782
- removeSettingsPanel();
813
+ catch (err) {
814
+ try {
815
+ console.warn('[dsh-completion-reminder] failed to register settings section:', err);
816
+ }
817
+ catch { /* noop */ }
818
+ showFirstRunHint();
783
819
  }
784
820
  }
785
- function removeSettingsUI() {
786
- removeSettingsPanel();
787
- if (state.settingsButtonEl) {
788
- state.settingsButtonEl.remove();
789
- state.settingsButtonEl = null;
821
+ /**
822
+ * Show a single, dismissable hint that points the user to the DSH
823
+ * settings dialog. We only show it once and only when the section
824
+ * registration failed (older host, no slots service, etc.).
825
+ */
826
+ function showFirstRunHint() {
827
+ if (state.hintEl || hasPersistedConfig())
828
+ return;
829
+ injectHintStyles();
830
+ const el = document.createElement('div');
831
+ el.className = 'dsh-reminder-hint';
832
+ el.innerHTML = `
833
+ <span>🔔 DSH Completion Reminder 已激活。在「设置 → 🔔 提醒」中配置通知渠道。</span>
834
+ <button type="button" data-reminder-hint-dismiss>知道了</button>
835
+ `;
836
+ el.querySelector('[data-reminder-hint-dismiss]')?.addEventListener('click', () => {
837
+ el.remove();
838
+ state.hintEl = null;
839
+ });
840
+ document.body.appendChild(el);
841
+ state.hintEl = el;
842
+ setTimeout(() => {
843
+ el.classList.add('dsh-reminder-hint-leave');
844
+ setTimeout(() => { el.remove(); state.hintEl = null; }, 600);
845
+ }, 12000);
846
+ }
847
+ function removeHint() {
848
+ if (state.hintEl) {
849
+ state.hintEl.remove();
850
+ state.hintEl = null;
790
851
  }
791
852
  }
792
- function toggleSettingsPanel() {
793
- if (state.panelOpen)
794
- removeSettingsPanel();
795
- else
796
- openSettingsPanel();
853
+ function hasPersistedConfig() {
854
+ if (typeof localStorage === 'undefined')
855
+ return false;
856
+ try {
857
+ return !!localStorage.getItem(STORAGE_KEY);
858
+ }
859
+ catch {
860
+ return false;
861
+ }
797
862
  }
798
- function openSettingsPanel() {
799
- if (state.settingsPanelEl)
863
+ const HINT_STYLE_ID = 'dsh-completion-reminder-hint-style';
864
+ function injectHintStyles() {
865
+ if (document.getElementById(HINT_STYLE_ID))
800
866
  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;
867
+ const style = document.createElement('style');
868
+ style.id = HINT_STYLE_ID;
869
+ style.textContent = `
870
+ .dsh-reminder-hint {
871
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
872
+ z-index: 2147483600;
873
+ display: flex; align-items: center; gap: 10px;
874
+ background: ${DSH_CSS_VARS.bgModule}; color: ${DSH_CSS_VARS.labelPrimary};
875
+ border: 1px solid ${DSH_CSS_VARS.borderL3}; border-radius: 999px;
876
+ box-shadow: ${DSH_CSS_VARS.shadowLv3};
877
+ padding: 8px 8px 8px 14px;
878
+ font-size: 13px; line-height: 1.2;
879
+ transition: opacity .4s, transform .4s;
880
+ }
881
+ .dsh-reminder-hint button {
882
+ background: ${DSH_CSS_VARS.buttonInfoFill}; color: #fff;
883
+ border: none; border-radius: 999px; padding: 4px 10px;
884
+ font-size: 12px; cursor: pointer; font-family: inherit;
885
+ }
886
+ .dsh-reminder-hint-leave { opacity: 0; transform: translate(-50%, 12px); }
887
+ `;
888
+ document.head.appendChild(style);
889
+ }
890
+ // ──── Panel rendering (vanilla DOM inside the DSH settings dialog) ─────────
891
+ /**
892
+ * Provider descriptions shown next to the picker.
893
+ */
894
+ const PROVIDER_DESCRIPTIONS = {
895
+ browser: '使用浏览器原生通知 API,首次使用需用户授权。',
896
+ telegram: '通过 Telegram Bot 推送到你的聊天 / 频道 / 群组。',
897
+ bark: '通过 Bark HTTP API 推送到 iPhone。',
898
+ pushover: '通过 Pushover 推送到 Android / iOS / 桌面。',
899
+ serverchan: '推送到微信(sct.ftqq.com,SendKey)。',
900
+ discord: '通过 Discord Webhook 推送到频道。',
901
+ slack: '通过 Slack Incoming Webhook 推送到频道。',
902
+ webhook: 'POST JSON 到你提供的 URL。',
903
+ custom: '通过 customSend(payload) 函数自己实现。',
904
+ };
905
+ function injectPanelInto(host) {
906
+ state.panelHostEl = host;
907
+ renderPanelInto(host);
908
+ }
909
+ function rerenderPanel() {
910
+ if (state.panelHostEl) {
911
+ renderPanelInto(state.panelHostEl);
816
912
  }
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>`)
913
+ }
914
+ function renderPanelInto(host) {
915
+ injectPanelStyles();
916
+ host.innerHTML = buildPanelHtml();
917
+ wirePanelEvents(host);
918
+ }
919
+ function buildPanelHtml() {
920
+ const providerOptions = PROVIDER_LABELS
921
+ .map(([id, label]) => `<option value="${id}" ${config.provider === id ? 'selected' : ''}>${escapeHtml(label)}</option>`)
833
922
  .join('');
834
923
  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;
924
+ const fields = PROVIDER_FIELDS[config.provider] ?? [];
925
+ const fieldsHtml = fields.length === 0
926
+ ? `<p class="dsh-reminder-panel-hint">此渠道无需凭证,保存即可使用。</p>`
927
+ : fields.map((f) => {
928
+ const value = p[f.key] ?? '';
929
+ return `
930
+ <label class="dsh-reminder-panel-field">
931
+ <span class="dsh-reminder-panel-label">${escapeHtml(f.label)}</span>
932
+ <input type="text" data-reminder-field="${escapeAttr(f.key)}" value="${escapeAttr(value)}" placeholder="${escapeAttr(f.placeholder)}" autocomplete="off" spellcheck="false" />
933
+ </label>
934
+ `;
935
+ }).join('');
936
+ const desc = PROVIDER_DESCRIPTIONS[config.provider] ?? '';
856
937
  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>
938
+ <div class="dsh-reminder-panel">
939
+ <header class="dsh-reminder-panel-header">
940
+ <strong>Agent 完成提醒</strong>
941
+ <span class="dsh-reminder-panel-sub">配置通知渠道,agent 完成后通知你</span>
942
+ </header>
943
+
944
+ <section class="dsh-reminder-panel-section">
945
+ <label class="dsh-reminder-panel-field">
946
+ <span class="dsh-reminder-panel-label">通知渠道</span>
947
+ <select data-reminder-input="provider">${providerOptions}</select>
948
+ </label>
949
+ <p class="dsh-reminder-panel-hint">${escapeHtml(desc)}</p>
950
+ </section>
866
951
 
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>
952
+ <section class="dsh-reminder-panel-section">
953
+ <header class="dsh-reminder-panel-section-title">凭证(仅当前渠道需要)</header>
954
+ ${fieldsHtml}
955
+ </section>
875
956
 
876
- <fieldset class="dsh-reminder-group">
877
- <legend>行为</legend>
878
- <label class="dsh-reminder-row">
957
+ <section class="dsh-reminder-panel-section">
958
+ <header class="dsh-reminder-panel-section-title">行为</header>
959
+ <label class="dsh-reminder-panel-row">
879
960
  <input type="checkbox" data-reminder-input="notifyOnSuccess" ${config.notifyOnSuccess ? 'checked' : ''} />
880
961
  <span>成功完成时通知</span>
881
962
  </label>
882
- <label class="dsh-reminder-row">
963
+ <label class="dsh-reminder-panel-row">
883
964
  <input type="checkbox" data-reminder-input="notifyOnStopped" ${config.notifyOnStopped ? 'checked' : ''} />
884
965
  <span>用户主动停止时通知</span>
885
966
  </label>
886
- <label class="dsh-reminder-row">
967
+ <label class="dsh-reminder-panel-row">
887
968
  <input type="checkbox" data-reminder-input="notifyOnError" ${config.notifyOnError ? 'checked' : ''} />
888
969
  <span>Agent 出错时通知</span>
889
970
  </label>
890
- <label class="dsh-reminder-row">
971
+ <label class="dsh-reminder-panel-row">
891
972
  <input type="checkbox" data-reminder-input="suppressWhenFocused" ${config.suppressWhenFocused ? 'checked' : ''} />
892
973
  <span>DSH 标签页可见时静默(推荐)</span>
893
974
  </label>
894
- <label class="dsh-reminder-row">
975
+ <label class="dsh-reminder-panel-row">
895
976
  <input type="checkbox" data-reminder-input="autoRequestPermission" ${config.autoRequestPermission ? 'checked' : ''} />
896
977
  <span>自动请求浏览器通知权限</span>
897
978
  </label>
898
- <label class="dsh-reminder-field">
899
- <span>冷却(ms)— 防止连续完成时刷屏</span>
979
+ <label class="dsh-reminder-panel-field">
980
+ <span class="dsh-reminder-panel-label">冷却(ms)— 防止连续完成时刷屏</span>
900
981
  <input type="number" min="0" step="500" data-reminder-input="cooldownMs" value="${config.cooldownMs}" />
901
982
  </label>
902
- </fieldset>
983
+ </section>
903
984
 
904
- <div class="dsh-reminder-row dsh-reminder-status">
905
- <span>当前权限:<strong data-reminder-perm>${permissionLabel(perm)}</strong></span>
985
+ <section class="dsh-reminder-panel-section dsh-reminder-panel-perm">
986
+ <span>当前权限:<strong data-reminder-perm>${permissionLabel(state.permission)}</strong></span>
906
987
  <button type="button" data-reminder-action="request-permission">请求权限</button>
907
- </div>
988
+ </section>
908
989
 
909
- <div class="dsh-reminder-actions">
990
+ <footer class="dsh-reminder-panel-actions">
910
991
  <button type="button" class="primary" data-reminder-action="test">发送测试通知</button>
911
- <button type="button" data-reminder-action="save">保存</button>
912
992
  <button type="button" data-reminder-action="reset">重置</button>
913
- </div>
914
- <p class="dsh-reminder-hint" data-reminder-status></p>
993
+ <span class="dsh-reminder-panel-status" data-reminder-status></span>
994
+ </footer>
995
+ <p class="dsh-reminder-panel-hint">配置只保存在本浏览器的 localStorage,不会上传任何服务器。</p>
915
996
  </div>
916
997
  `;
917
998
  }
918
- function wireSettingsPanel(panel) {
919
- panel.querySelector('[data-reminder-action="close"]')
920
- ?.addEventListener('click', () => removeSettingsPanel());
921
- panel.addEventListener('change', (ev) => {
999
+ function wirePanelEvents(host) {
1000
+ const root = host.querySelector('.dsh-reminder-panel');
1001
+ if (!root)
1002
+ return;
1003
+ root.addEventListener('change', (ev) => {
922
1004
  const target = ev.target;
923
1005
  if (!target)
924
1006
  return;
@@ -926,10 +1008,15 @@ window.__ModuleLoader__.load({
926
1008
  const name = target.dataset.reminderInput;
927
1009
  if (name === 'provider') {
928
1010
  config.provider = target.value;
1011
+ savePersisted();
1012
+ // Re-render so the credentials section updates to the new channel.
1013
+ renderPanelInto(host);
929
1014
  }
930
1015
  }
931
1016
  else if (target instanceof HTMLInputElement) {
932
1017
  const name = target.dataset.reminderInput;
1018
+ if (!name)
1019
+ return;
933
1020
  const value = target.type === 'checkbox' ? target.checked
934
1021
  : target.type === 'number' ? Number(target.value) || 0
935
1022
  : target.value;
@@ -945,26 +1032,20 @@ window.__ModuleLoader__.load({
945
1032
  config.autoRequestPermission = !!value;
946
1033
  else if (name === 'cooldownMs')
947
1034
  config.cooldownMs = Number(value) || 0;
1035
+ savePersisted();
948
1036
  }
949
- savePersisted();
950
1037
  });
951
- panel.addEventListener('input', (ev) => {
1038
+ root.addEventListener('input', (ev) => {
952
1039
  const target = ev.target;
953
1040
  if (!target)
954
1041
  return;
955
- const name = target.dataset.providerInput;
956
- if (name) {
957
- config.providers[name] = target.value;
1042
+ const key = target.dataset.reminderField;
1043
+ if (key) {
1044
+ config.providers[key] = target.value;
958
1045
  savePersisted();
959
1046
  }
960
1047
  });
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', () => {
1048
+ root.querySelector('[data-reminder-action="reset"]')?.addEventListener('click', () => {
968
1049
  if (typeof localStorage !== 'undefined') {
969
1050
  try {
970
1051
  localStorage.removeItem(STORAGE_KEY);
@@ -972,18 +1053,18 @@ window.__ModuleLoader__.load({
972
1053
  catch { /* noop */ }
973
1054
  }
974
1055
  configure();
975
- removeSettingsPanel();
976
- openSettingsPanel();
1056
+ renderPanelInto(host);
1057
+ setPanelStatus(root, '已重置 ✓');
977
1058
  });
978
- panel.querySelector('[data-reminder-action="request-permission"]')
1059
+ root.querySelector('[data-reminder-action="request-permission"]')
979
1060
  ?.addEventListener('click', async () => {
980
1061
  const result = await requestBrowserPermission();
981
- const permEl = panel.querySelector('[data-reminder-perm]');
1062
+ const permEl = root.querySelector('[data-reminder-perm]');
982
1063
  if (permEl)
983
1064
  permEl.textContent = permissionLabel(result);
984
- setStatus(panel, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
1065
+ setPanelStatus(root, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
985
1066
  });
986
- panel.querySelector('[data-reminder-action="test"]')
1067
+ root.querySelector('[data-reminder-action="test"]')
987
1068
  ?.addEventListener('click', async () => {
988
1069
  try {
989
1070
  const ctx = {
@@ -1002,16 +1083,16 @@ window.__ModuleLoader__.load({
1002
1083
  };
1003
1084
  await dispatch(payload);
1004
1085
  config.onNotify(payload, config.provider);
1005
- setStatus(panel, '测试通知已发送,请查收。');
1086
+ setPanelStatus(root, '测试通知已发送,请查收。');
1006
1087
  }
1007
1088
  catch (err) {
1008
1089
  const e = toError(err);
1009
1090
  config.onError(e, config.provider);
1010
- setStatus(panel, '测试失败:' + e.message, true);
1091
+ setPanelStatus(root, '测试失败:' + e.message, true);
1011
1092
  }
1012
1093
  });
1013
1094
  }
1014
- function setStatus(panel, text, isError = false) {
1095
+ function setPanelStatus(panel, text, isError = false) {
1015
1096
  const el = panel.querySelector('[data-reminder-status]');
1016
1097
  if (!el)
1017
1098
  return;
@@ -1033,169 +1114,187 @@ window.__ModuleLoader__.load({
1033
1114
  return '不支持';
1034
1115
  return '未询问';
1035
1116
  }
1036
- function escapeAttr(s) {
1117
+ function escapeHtml(s) {
1037
1118
  return s
1038
1119
  .replace(/&/g, '&amp;')
1039
- .replace(/"/g, '&quot;')
1040
1120
  .replace(/</g, '&lt;')
1041
1121
  .replace(/>/g, '&gt;');
1042
1122
  }
1043
- function injectSettingsStyles() {
1044
- if (document.getElementById(SETTINGS_STYLE_ID))
1123
+ function escapeAttr(s) {
1124
+ return escapeHtml(s).replace(/"/g, '&quot;');
1125
+ }
1126
+ function injectPanelStyles() {
1127
+ if (document.getElementById(PANEL_STYLE_ID))
1045
1128
  return;
1046
1129
  const style = document.createElement('style');
1047
- style.id = SETTINGS_STYLE_ID;
1130
+ style.id = PANEL_STYLE_ID;
1131
+ // The host's parent (DSH settings dialog) sets `color-scheme: dark`
1132
+ // for the dark theme, so we explicitly hint the dark UA scheme on
1133
+ // selects + add explicit colors that work in both themes. Native
1134
+ // form controls otherwise default to UA colors (white-on-white in
1135
+ // dark mode).
1048
1136
  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
-
1137
+ .dsh-reminder-host { color: inherit; }
1138
+ .dsh-reminder-host,
1139
+ .dsh-reminder-host * { box-sizing: border-box; }
1069
1140
  .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};
1141
+ display: flex;
1142
+ flex-direction: column;
1143
+ gap: 18px;
1078
1144
  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
1145
  font-size: 13px;
1083
- line-height: 1.45;
1084
- font-family: var(--dsw-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
1146
+ line-height: 1.5;
1147
+ padding: 4px 4px 24px;
1085
1148
  }
1086
1149
  .dsh-reminder-panel-header {
1087
1150
  display: flex;
1088
- align-items: center;
1089
- justify-content: space-between;
1090
- padding: 10px 12px;
1151
+ flex-direction: column;
1152
+ gap: 2px;
1091
1153
  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};
1154
+ padding-bottom: 12px;
1097
1155
  }
1098
- .dsh-reminder-close {
1099
- background: transparent;
1100
- border: none;
1156
+ .dsh-reminder-panel-header strong { font-size: 15px; font-weight: 600; }
1157
+ .dsh-reminder-panel-sub { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; }
1158
+
1159
+ .dsh-reminder-panel-section {
1160
+ display: flex;
1161
+ flex-direction: column;
1162
+ gap: 10px;
1163
+ }
1164
+ .dsh-reminder-panel-section-title {
1165
+ font-size: 12px;
1101
1166
  color: ${DSH_CSS_VARS.labelSecondary};
1102
- cursor: pointer;
1103
- font-size: 16px;
1104
- line-height: 1;
1105
- padding: 2px 6px;
1106
- border-radius: 4px;
1167
+ text-transform: uppercase;
1168
+ letter-spacing: 0.04em;
1107
1169
  }
1108
- .dsh-reminder-close:hover { background: ${DSH_CSS_VARS.borderL1}; color: ${DSH_CSS_VARS.labelPrimary}; }
1109
1170
 
1110
- .dsh-reminder-panel-body { padding: 12px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
1111
-
1112
- .dsh-reminder-group {
1113
- border: 1px solid ${DSH_CSS_VARS.borderL1};
1114
- border-radius: 8px;
1115
- padding: 8px 10px 10px;
1116
- margin: 0;
1171
+ .dsh-reminder-panel-field {
1117
1172
  display: flex;
1118
1173
  flex-direction: column;
1119
- gap: 8px;
1174
+ gap: 4px;
1120
1175
  }
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 {
1176
+ .dsh-reminder-panel-label {
1177
+ color: ${DSH_CSS_VARS.labelSecondary};
1178
+ font-size: 12px;
1179
+ }
1180
+ .dsh-reminder-panel-field input[type="text"],
1181
+ .dsh-reminder-panel-field input[type="number"],
1182
+ .dsh-reminder-panel-field select {
1183
+ color-scheme: light dark;
1184
+ width: 100%;
1127
1185
  background: transparent;
1128
1186
  color: ${DSH_CSS_VARS.labelPrimary};
1129
1187
  border: 1px solid ${DSH_CSS_VARS.borderL2};
1130
1188
  border-radius: 6px;
1131
- padding: 6px 8px;
1189
+ padding: 6px 10px;
1132
1190
  font-size: 13px;
1133
- outline: none;
1134
1191
  font-family: inherit;
1192
+ outline: none;
1135
1193
  transition: border-color .15s, box-shadow .15s;
1136
- }
1137
- .dsh-reminder-field input:focus,
1138
- .dsh-reminder-field select:focus {
1194
+ -webkit-appearance: none;
1195
+ appearance: none;
1196
+ }
1197
+ .dsh-reminder-panel-field select {
1198
+ /* Provide a small caret since we stripped the native chrome. */
1199
+ background-image: linear-gradient(45deg, transparent 50%, ${DSH_CSS_VARS.labelSecondary} 50%),
1200
+ linear-gradient(135deg, ${DSH_CSS_VARS.labelSecondary} 50%, transparent 50%);
1201
+ background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
1202
+ background-size: 5px 5px, 5px 5px;
1203
+ background-repeat: no-repeat;
1204
+ padding-right: 26px;
1205
+ }
1206
+ .dsh-reminder-panel-field input[type="text"]:focus,
1207
+ .dsh-reminder-panel-field input[type="number"]:focus,
1208
+ .dsh-reminder-panel-field select:focus {
1139
1209
  border-color: ${DSH_CSS_VARS.stateBusinessPrimary};
1140
1210
  box-shadow: 0 0 0 2px color-mix(in srgb, ${DSH_CSS_VARS.stateBusinessPrimary} 25%, transparent);
1141
1211
  }
1142
1212
 
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}; }
1213
+ .dsh-reminder-panel-row {
1214
+ display: flex;
1215
+ align-items: center;
1216
+ gap: 8px;
1217
+ cursor: pointer;
1218
+ user-select: none;
1219
+ }
1220
+ .dsh-reminder-panel-row > input[type="checkbox"] {
1221
+ accent-color: ${DSH_CSS_VARS.buttonInfoFill};
1222
+ }
1145
1223
 
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};
1224
+ .dsh-reminder-panel-hint {
1225
+ color: ${DSH_CSS_VARS.labelTertiary};
1226
+ font-size: 12px;
1227
+ line-height: 1.5;
1228
+ margin: 0;
1229
+ }
1230
+
1231
+ .dsh-reminder-panel-perm {
1232
+ flex-direction: row;
1233
+ align-items: center;
1234
+ justify-content: space-between;
1235
+ flex-wrap: wrap;
1236
+ gap: 8px;
1237
+ padding: 8px 10px;
1238
+ border: 1px dashed ${DSH_CSS_VARS.borderL2};
1239
+ border-radius: 6px;
1240
+ color: ${DSH_CSS_VARS.labelSecondary};
1241
+ font-size: 12px;
1242
+ }
1243
+ .dsh-reminder-panel-perm strong { color: ${DSH_CSS_VARS.labelPrimary}; }
1244
+ .dsh-reminder-panel-perm button {
1151
1245
  background: transparent;
1152
1246
  color: ${DSH_CSS_VARS.labelPrimary};
1247
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1153
1248
  border-radius: 6px;
1249
+ padding: 4px 10px;
1154
1250
  cursor: pointer;
1155
- font-size: 13px;
1251
+ font-size: 12px;
1156
1252
  font-family: inherit;
1157
1253
  }
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); }
1254
+ .dsh-reminder-panel-perm button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1161
1255
 
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;
1256
+ .dsh-reminder-panel-actions {
1257
+ display: flex;
1258
+ align-items: center;
1259
+ gap: 8px;
1167
1260
  flex-wrap: wrap;
1168
1261
  }
1169
- .dsh-reminder-status button {
1262
+ .dsh-reminder-panel-actions button {
1263
+ padding: 6px 14px;
1264
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1170
1265
  background: transparent;
1171
1266
  color: ${DSH_CSS_VARS.labelPrimary};
1172
- border: 1px solid ${DSH_CSS_VARS.borderL2};
1173
1267
  border-radius: 6px;
1174
- padding: 4px 8px;
1175
1268
  cursor: pointer;
1176
- font-size: 12px;
1269
+ font-size: 13px;
1177
1270
  font-family: inherit;
1178
1271
  }
1179
- .dsh-reminder-status button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1272
+ .dsh-reminder-panel-actions button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1273
+ .dsh-reminder-panel-actions button.primary {
1274
+ background: ${DSH_CSS_VARS.buttonInfoFill};
1275
+ color: #fff;
1276
+ border-color: transparent;
1277
+ }
1278
+ .dsh-reminder-panel-actions button.primary:hover { filter: brightness(1.05); }
1180
1279
 
1181
- .dsh-reminder-hint { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; line-height: 1.5; }
1280
+ .dsh-reminder-panel-status {
1281
+ margin-left: auto;
1282
+ color: ${DSH_CSS_VARS.stateSuccessPrimary};
1283
+ font-size: 12px;
1284
+ min-width: 0;
1285
+ flex: 1 1 0;
1286
+ text-align: right;
1287
+ }
1182
1288
  `;
1183
1289
  document.head.appendChild(style);
1184
1290
  }
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
1291
  exports.configure = configure;
1194
1292
  exports.activate = activate;
1195
1293
  exports.deactivate = deactivate;
1196
1294
  exports.apply = apply;
1197
1295
  exports.requestBrowserPermission = requestBrowserPermission;
1198
1296
  exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
1297
+ exports.renderPanelInto = renderPanelInto;
1199
1298
  //# sourceMappingURL=client.js.map
1200
1299
 
1201
1300
  // ── export fallback (guard against stripped 'export { ... }' lists) ──