@zhz8888/dsh-android-emulator-helper 1.0.0 → 1.1.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
@@ -39,38 +39,58 @@ window.__ModuleLoader__.load({
39
39
  const CSS_TAG = "@zhz8888/dsh-android-emulator-helper/Settings.module.css";
40
40
 
41
41
  const CSS = [
42
- ".aemh_page{flex-direction:column;gap:20px;display:flex;max-width:760px;color:var(--dsw-alias-label-primary)}",
43
- ".aemh_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:20px}",
44
- ".aemh_group{flex-direction:column;gap:14px;display:flex}",
45
- ".aemh_groupHead{flex-direction:column;gap:2px;display:flex}",
46
- ".aemh_groupTitle{margin:0;font-size:15px;font-weight:600;line-height:22px}",
47
- ".aemh_groupHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}",
48
- ".aemh_field{flex-direction:column;gap:6px;display:flex;min-width:0}",
42
+ // Page frame. The shell renders the section's nav label but no title of
43
+ // its own, so the page carries one, then a secondary intro. Below that
44
+ // it is one card per category: the card outline is what separates the
45
+ // categories, so no rule is needed between them and each group reads as
46
+ // one block. The metrics are read from the platform's own field kit —
47
+ // `@deepseek-ai/dsh-client-ui-primitives/lib/settings-form/fields.module.css`
48
+ // (MIT), which every built-in settings page uses — so the page sits
49
+ // beside them: a 13/1.5 label, a 12px hint, a 34px control on a 0.5px
50
+ // border-l4 edge. The composition above those pieces is this page's own.
51
+ ".aemh_page{flex-direction:column;display:flex;max-width:720px;color:var(--dsw-alias-label-primary)}",
52
+ ".aemh_title{margin:0;font-size:16px;font-weight:600;line-height:1.5}",
53
+ ".aemh_intro{margin:6px 0 0;font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary)}",
54
+ ".aemh_readOnly{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}",
55
+ ".aemh_group{margin-top:20px;padding:14px 16px 16px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);flex-direction:column;display:flex}",
56
+ ".aemh_groupHead{align-items:baseline;gap:8px;display:flex}",
57
+ ".aemh_groupTitle{margin:0;font-size:14px;font-weight:600;line-height:1.5}",
58
+ ".aemh_groupHint{color:var(--dsw-alias-label-tertiary);margin:2px 0 4px;font-size:12px;line-height:1.6}",
59
+ ".aemh_field{flex-direction:column;gap:6px;display:flex;padding:10px 0;min-width:0}",
49
60
  ".aemh_fieldHead{align-items:center;gap:8px;display:flex;min-width:0}",
50
- ".aemh_label{font-size:13px;font-weight:500;line-height:20px}",
51
- ".aemh_reset{font:inherit;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;padding:0;font-size:12px;line-height:18px;text-decoration:underline}",
52
- ".aemh_reset:hover{color:var(--dsw-alias-label-primary)}",
53
- ".aemh_control{border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);height:36px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}",
54
- ".aemh_control:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:inset 0 0 0 .5px var(--dsw-alias-state-business-primary)}",
55
- ".aemh_control[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary);box-shadow:none}",
56
- ".aemh_control:disabled{opacity:.6;cursor:default}",
57
- "textarea.aemh_control{height:auto;min-height:84px;padding:8px 12px;line-height:20px;resize:vertical;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px}",
58
- ".aemh_select{appearance:none;cursor:pointer;padding-right:30px;background-repeat:no-repeat;background-position:right 10px center;background-size:12px;background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 14 14' fill='none'><path d='M3.5 5.5L7 9l3.5-3.5' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>\")}",
59
- ".aemh_switchRow{align-items:center;gap:10px;display:flex}",
60
- ".aemh_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}",
61
- ".aemh_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}",
62
- ".aemh_tools{border-top:.5px solid var(--dsw-alias-border-l2);flex-direction:column;gap:12px;padding-top:16px;display:flex}",
61
+ ".aemh_label{font-size:13px;font-weight:500;line-height:1.5}",
62
+ ".aemh_reset{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);text-decoration:underline;cursor:pointer;background:none;border:0;padding:0}",
63
+ ".aemh_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}",
64
+ ".aemh_reset:disabled{cursor:default;opacity:.5}",
65
+ ".aemh_control{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;padding:0 12px;font-size:13px;line-height:1.5;width:100%}",
66
+ ".aemh_control:focus-visible{border-color:var(--dsw-alias-state-business-primary)}",
67
+ ".aemh_control[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}",
68
+ ".aemh_control:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}",
69
+ "textarea.aemh_control{height:auto;min-height:96px;padding:10px 12px;line-height:1.6;resize:vertical;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px}",
70
+ ".aemh_selectWrap{position:relative;display:block}",
71
+ ".aemh_selectWrap::after{content:\"\";position:absolute;right:12px;top:calc(50% - 2px);border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid var(--dsw-alias-label-tertiary);pointer-events:none}",
72
+ ".aemh_select{appearance:none;cursor:pointer;padding-right:28px}",
73
+ ".aemh_hint{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}",
74
+ ".aemh_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:1.6}",
63
75
  ".aemh_toolRow{flex-wrap:wrap;align-items:center;gap:10px;display:flex}",
64
76
  ".aemh_checks{flex-direction:column;gap:2px;margin:0;padding:0;list-style:none;display:flex}",
65
77
  ".aemh_check{border-bottom:.5px solid var(--dsw-alias-border-l2);align-items:flex-start;gap:10px;padding:8px 0;display:flex}",
66
78
  ".aemh_check:last-child{border-bottom:0}",
67
79
  ".aemh_checkBody{flex-direction:column;gap:2px;min-width:0;flex:1;display:flex}",
68
- ".aemh_checkName{font-size:13px;line-height:20px}",
69
- ".aemh_checkDetail{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;white-space:pre-wrap;margin:0;font-size:12px;line-height:18px}",
70
- ".aemh_checkFix{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary));overflow-wrap:anywhere;margin:0;font-size:12px;line-height:18px}",
71
- ".aemh_verdict{align-items:center;gap:8px;font-size:13px;line-height:20px;display:flex}",
72
- ".aemh_note{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}",
80
+ ".aemh_checkName{font-size:13px;line-height:1.5}",
81
+ ".aemh_checkDetail{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;white-space:pre-wrap;margin:0;font-size:12px;line-height:1.6}",
82
+ ".aemh_checkFix{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary));overflow-wrap:anywhere;margin:0;font-size:12px;line-height:1.6}",
83
+ ".aemh_verdict{align-items:center;gap:8px;font-size:13px;line-height:1.5;display:flex}",
84
+ ".aemh_note{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.6}",
73
85
  ".aemh_mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);overflow-wrap:anywhere}",
86
+ // A small pill, not a full-width panel: it hugs its message and buttons and
87
+ // centres itself in the page column with auto margins, so it reads as a
88
+ // floating notice. It is still one in-flow child held at the bottom by
89
+ // `position: sticky`, so it never covers a field and nothing has to
90
+ // reserve room for it.
91
+ ".aemh_actions{position:sticky;bottom:16px;z-index:1;box-sizing:border-box;margin:20px auto 0;padding:6px 8px 6px 14px;width:max-content;max-width:min(560px,100%);border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft,0 4px 14px -6px rgba(0,0,0,.2));align-items:center;gap:8px;display:flex}",
92
+ ".aemh_actionsText{flex:0 1 auto;min-width:0;margin:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:1.5}",
93
+ ".aemh_actionsError .aemh_actionsText{color:var(--dsw-alias-state-error-primary)}",
74
94
  ].join("");
75
95
 
76
96
  /**
@@ -174,12 +194,12 @@ window.__ModuleLoader__.load({
174
194
  },
175
195
  },
176
196
  {
177
- key: "bootTimeoutMs",
197
+ key: "bootTimeoutSeconds",
178
198
  kind: "number",
179
- label: { zh: "模拟器启动超时 (ms)", en: "Emulator boot timeout (ms)" },
199
+ label: { zh: "模拟器启动超时(秒)", en: "Emulator boot timeout (seconds)" },
180
200
  hint: {
181
- zh: "等待模拟器完成启动的最长时间,默认 180000。",
182
- en: "Longest wait for the emulator to finish booting; 180000 by default.",
201
+ zh: "等待模拟器完成启动的最长时间,默认 180 秒。",
202
+ en: "Longest wait for the emulator to finish booting; 180 seconds by default.",
183
203
  },
184
204
  },
185
205
  ],
@@ -199,8 +219,8 @@ window.__ModuleLoader__.load({
199
219
  placeholder: "35",
200
220
  label: { zh: "Android API 级别", en: "Android API level" },
201
221
  hint: {
202
- zh: "compileSdk 与系统镜像包名中的 API 级别。",
203
- en: "The API level used for compileSdk and the system-image package id.",
222
+ zh: "生成项目、安装 SDK 包与选择系统镜像时使用的 API 级别。",
223
+ en: "API level used when generating projects, installing SDK packages and picking a system image.",
204
224
  },
205
225
  },
206
226
  {
@@ -241,12 +261,12 @@ window.__ModuleLoader__.load({
241
261
  hint: { zh: "Gradle 构建的超时与附加参数。", en: "Gradle timeouts and extra arguments." },
242
262
  fields: [
243
263
  {
244
- key: "buildTimeoutMs",
264
+ key: "buildTimeoutSeconds",
245
265
  kind: "number",
246
- label: { zh: "Gradle 构建超时 (ms)", en: "Gradle build timeout (ms)" },
266
+ label: { zh: "Gradle 构建超时(秒)", en: "Gradle build timeout (seconds)" },
247
267
  hint: {
248
- zh: "一次 assemble 任务的最长运行时间,默认 600000。",
249
- en: "Longest runtime of one assemble task; 600000 by default.",
268
+ zh: "一次 assemble 任务的最长运行时间,默认 600 秒。",
269
+ en: "Longest runtime of one assemble task; 600 seconds by default.",
250
270
  },
251
271
  },
252
272
  {
@@ -374,6 +394,7 @@ window.__ModuleLoader__.load({
374
394
  function buildDictionaries() {
375
395
  const en = {
376
396
  nav: "Android emulator",
397
+ title: "Android Emulator",
377
398
  intro:
378
399
  "Parameters for the android_* tools: which Android SDK and JDK to use, how an emulator starts, and where captures and build logs are written. Changes are saved to this profile and take effect on the next tool call.",
379
400
  invalid: "Enter a whole number, or leave blank to use the default.",
@@ -381,7 +402,11 @@ window.__ModuleLoader__.load({
381
402
  reset: "Reset to default",
382
403
  save: "Save",
383
404
  saving: "Saving…",
384
- saveFailed: "The deployment did not accept these values; they were left for you to correct.",
405
+ discard: "Discard changes",
406
+ pendingWrite: "Unsaved changes",
407
+ writeBlocked: "One value is invalid; fix it before saving",
408
+ writeFailed: "These values were not accepted, so they are still staged.",
409
+ written: "Saved to this profile",
385
410
  readOnly: "This deployment stores settings read-only.",
386
411
  unavailable: "This plugin's settings are not served, so it cannot be configured right now.",
387
412
  loading: "Loading saved values…",
@@ -402,6 +427,7 @@ window.__ModuleLoader__.load({
402
427
  };
403
428
  const zh = {
404
429
  nav: "Android 模拟器",
430
+ title: "Android 模拟器",
405
431
  intro:
406
432
  "android_* 工具的运行参数:使用哪个 Android SDK 与 JDK、模拟器如何启动、截图与构建日志写到哪里。保存后写入当前 profile,并在下一次工具调用时生效。",
407
433
  invalid: "请填整数;留空表示使用默认值。",
@@ -409,7 +435,11 @@ window.__ModuleLoader__.load({
409
435
  reset: "恢复默认",
410
436
  save: "保存",
411
437
  saving: "保存中…",
412
- saveFailed: "本部署没有接受这些值,已保留供你修改。",
438
+ discard: "放弃改动",
439
+ pendingWrite: "有改动尚未保存",
440
+ writeBlocked: "有取值不合法,修正后才能保存",
441
+ writeFailed: "这批取值未被接受,仍保留在表单里。",
442
+ written: "已保存到当前 profile",
413
443
  readOnly: "本部署的设置为只读。",
414
444
  unavailable: "该插件的设置当前未被服务,暂时无法配置。",
415
445
  loading: "正在读取已保存的值…",
@@ -517,30 +547,34 @@ window.__ModuleLoader__.load({
517
547
  const { field, state, disabled, id, listId, suggestions, onChange, t } = input;
518
548
  switch (field.kind) {
519
549
  case "boolean":
520
- return h(
521
- "div",
522
- { className: "aemh_switchRow" },
523
- h(P.Switch, {
524
- checked: state.text === "true",
525
- disabled,
526
- label: t(`field.${field.key}`),
527
- onChange: (next) => onChange(next ? "true" : "false"),
528
- }),
529
- h("span", { className: "aemh_hint" }, state.text === "true" ? "on" : "off"),
530
- );
550
+ // The switch is its own state indicator: a neighbouring "on"/"off"
551
+ // label would only repeat what the track already shows.
552
+ return h(P.Switch, {
553
+ checked: state.text === "true",
554
+ disabled,
555
+ label: t(`field.${field.key}`),
556
+ onChange: (next) => onChange(next ? "true" : "false"),
557
+ });
531
558
  case "select":
559
+ // The chevron is drawn by the wrapper: a native select cannot carry a
560
+ // pseudo-element of its own, and the CSS triangle follows the theme
561
+ // where a data-URI glyph could not.
532
562
  return h(
533
- "select",
534
- {
535
- id,
536
- className: "aemh_control aemh_select",
537
- value: state.text,
538
- disabled,
539
- "aria-invalid": state.invalid ? "true" : undefined,
540
- onChange: (event) => onChange(event.target.value),
541
- },
542
- selectOptions(field, state.text).map((value) =>
543
- h("option", { key: value === "" ? "__auto__" : value, value }, optionLabel(field, value, t)),
563
+ "span",
564
+ { className: "aemh_selectWrap" },
565
+ h(
566
+ "select",
567
+ {
568
+ id,
569
+ className: "aemh_control aemh_select",
570
+ value: state.text,
571
+ disabled,
572
+ "aria-invalid": state.invalid ? "true" : undefined,
573
+ onChange: (event) => onChange(event.target.value),
574
+ },
575
+ selectOptions(field, state.text).map((value) =>
576
+ h("option", { key: value === "" ? "__auto__" : value, value }, optionLabel(field, value, t)),
577
+ ),
544
578
  ),
545
579
  );
546
580
  case "multiline":
@@ -583,6 +617,93 @@ window.__ModuleLoader__.load({
583
617
  }
584
618
  }
585
619
 
620
+ /**
621
+ * The action bar's state for one projection of the form.
622
+ *
623
+ * The bar is the only place a save starts, and it exists only while there is
624
+ * something to act on: a staged edit waiting to be written, a write in
625
+ * flight, or a failure worth retrying. A clean form answers `null`, which is
626
+ * what removes the bar from the page — nothing lingers in a hidden state.
627
+ *
628
+ * The shape is flat on purpose: the component below only maps it to
629
+ * elements, so every rule about what may be pressed lives here, in one place.
630
+ *
631
+ * @param {{dirty: boolean, invalid: boolean, saving: boolean, failed: boolean}} shell -
632
+ * the form-level state the shared model publishes.
633
+ * @param {Function} t - the page's dictionary lookup.
634
+ * @returns {{tone: string, message: string, canSave: boolean, canDiscard: boolean}|null}
635
+ * the bar state, or `null` when the bar has nothing to say.
636
+ */
637
+ function actionBarState(shell, t) {
638
+ if (shell.failed) {
639
+ return { tone: "error", message: t("writeFailed"), canDiscard: true, canSave: shell.dirty && !shell.invalid };
640
+ }
641
+ if (shell.saving) return { tone: "pending", message: t("saving"), canDiscard: false, canSave: false };
642
+ if (!shell.dirty) return null;
643
+ if (shell.invalid) return { tone: "error", message: t("writeBlocked"), canDiscard: true, canSave: false };
644
+ return { tone: "pending", message: t("pendingWrite"), canDiscard: true, canSave: true };
645
+ }
646
+
647
+ /**
648
+ * The action bar: the staged form's status plus the two ways out of it.
649
+ *
650
+ * A plain element at the end of the page column, kept on screen by the
651
+ * stylesheet while the page scrolls. Both buttons are the shared `Button`
652
+ * primitive and the leading mark is the shared `StateDot`, so what it looks
653
+ * like comes from the platform rather than from this page.
654
+ *
655
+ * @param {object} props - the bar state, the form shell and the two actions.
656
+ * @returns {object|null} the bar, or nothing while the form is clean.
657
+ */
658
+ function ActionBar(props) {
659
+ const { view, shell, t, onDiscard, onSave } = props;
660
+ if (view === null) return null;
661
+ const failed = view.tone === "error";
662
+ return h(
663
+ "div",
664
+ { className: failed ? "aemh_actions aemh_actionsError" : "aemh_actions" },
665
+ h(P.StateDot, { state: failed ? "error" : "warning", size: 8 }),
666
+ // The pill is as wide as its content, so a long failure message is
667
+ // truncated; the title attribute keeps the whole sentence reachable.
668
+ h(
669
+ "p",
670
+ { className: "aemh_actionsText", role: "status", "aria-live": "polite", title: view.message },
671
+ view.message,
672
+ ),
673
+ h(
674
+ P.Button,
675
+ { variant: "ghost", size: "sm", disabled: !view.canDiscard || shell.saving, onClick: onDiscard },
676
+ t("discard"),
677
+ ),
678
+ h(
679
+ P.Button,
680
+ { variant: "primary", size: "sm", disabled: !view.canSave || shell.saving, onClick: onSave },
681
+ shell.saving ? t("saving") : t("save"),
682
+ ),
683
+ );
684
+ }
685
+
686
+ /**
687
+ * The transient "written" confirmation.
688
+ *
689
+ * The shared `Toast` owns the hold, the fade and the teardown: it reports
690
+ * completion through `onDone`, which clears the sequence and unmounts it.
691
+ * Keying the toast by the sequence restarts the cycle when a later write
692
+ * lands, and a page that has never saved mounts nothing at all.
693
+ *
694
+ * @param {object} props - the landed-write counter and the dictionary lookup.
695
+ * @returns {object|null} the toast, or nothing.
696
+ */
697
+ function SavedToast(props) {
698
+ const { tick, t } = props;
699
+ const [shown, setShown] = React.useState(0);
700
+ React.useEffect(() => {
701
+ if (tick > 0) setShown(tick);
702
+ }, [tick]);
703
+ if (shown === 0) return null;
704
+ return h(P.Toast, { key: shown, tone: "success", text: t("written"), onDone: () => setShown(0) });
705
+ }
706
+
586
707
  /** The full settings page. */
587
708
  function SettingsPage(props) {
588
709
  const state = props.useAndroidEmulatorSettings((snapshot) => snapshot);
@@ -603,10 +724,20 @@ window.__ModuleLoader__.load({
603
724
  buildTools: unique((inventory.data?.buildTools ?? []).map((id) => id.split(";")[1] ?? "").filter(Boolean)).sort(),
604
725
  };
605
726
 
727
+ // Leaving the page abandons staged edits, exactly as the harness's own
728
+ // settings forms do: the controller outlives the page, so without this a
729
+ // half-finished edit would still be staged on the next visit.
730
+ const discard = React.useRef(props.discard);
731
+ discard.current = props.discard;
732
+ React.useEffect(() => () => discard.current(), []);
733
+
734
+ const bar = actionBarState(state, t);
735
+
606
736
  if (!state.available) {
607
737
  return h(
608
738
  "div",
609
739
  { className: "aemh_page" },
740
+ h("h2", { className: "aemh_title" }, t("title")),
610
741
  h("p", { className: "aemh_intro", role: "status" }, t(state.status === "loading" ? "loading" : "unavailable")),
611
742
  );
612
743
  }
@@ -614,51 +745,39 @@ window.__ModuleLoader__.load({
614
745
  return h(
615
746
  "div",
616
747
  { className: "aemh_page" },
748
+ h("h2", { className: "aemh_title" }, t("title")),
617
749
  h("p", { className: "aemh_intro" }, t("intro")),
618
- h(
619
- P.SettingsForm,
620
- {
621
- state,
622
- labels: {
623
- unavailable: t("unavailable"),
624
- readOnly: t("readOnly"),
625
- saveFailed: t("saveFailed"),
626
- save: t("save"),
627
- saving: t("saving"),
628
- },
629
- onSave: props.save,
630
- onDiscard: props.discard,
631
- },
632
- GROUPS.map((group) =>
750
+ state.writable ? null : h("p", { className: "aemh_readOnly", role: "status" }, t("readOnly")),
751
+ GROUPS.map((group) =>
752
+ h(
753
+ "section",
754
+ { className: "aemh_group", key: group.id },
633
755
  h(
634
- "section",
635
- { className: "aemh_group", key: group.id },
636
- h(
637
- "div",
638
- { className: "aemh_groupHead" },
639
- h("h3", { className: "aemh_groupTitle" }, t(`group.${group.id}`)),
640
- h("p", { className: "aemh_groupHint" }, t(`group.${group.id}.hint`)),
641
- ),
642
- group.fields.map((field) =>
643
- h(Field, {
644
- key: field.key,
645
- field,
646
- state: state.fields[field.key],
647
- disabled: !state.writable || state.saving,
648
- suggestions: field.suggest ? suggestions[field.suggest] : undefined,
649
- onChange: (text) => props.edit(field.key, text),
650
- onReset: () => props.resetField(field.key),
651
- t,
652
- }),
653
- ),
756
+ "div",
757
+ { className: "aemh_groupHead" },
758
+ h("h3", { className: "aemh_groupTitle" }, t(`group.${group.id}`)),
759
+ ),
760
+ h("p", { className: "aemh_groupHint" }, t(`group.${group.id}.hint`)),
761
+ group.fields.map((field) =>
762
+ h(Field, {
763
+ key: field.key,
764
+ field,
765
+ state: state.fields[field.key],
766
+ disabled: !state.writable || state.saving,
767
+ suggestions: field.suggest ? suggestions[field.suggest] : undefined,
768
+ onChange: (text) => props.edit(field.key, text),
769
+ onReset: () => props.resetField(field.key),
770
+ t,
771
+ }),
654
772
  ),
655
773
  ),
774
+ ),
775
+ h(
776
+ "section",
777
+ { className: "aemh_group" },
778
+ h("div", { className: "aemh_groupHead" }, h("h3", { className: "aemh_groupTitle" }, t("toolsTitle"))),
779
+ h("p", { className: "aemh_groupHint" }, t("toolsHint")),
656
780
  h(
657
- "section",
658
- { className: "aemh_tools" },
659
- h("h3", { className: "aemh_groupTitle" }, t("toolsTitle")),
660
- h("p", { className: "aemh_groupHint" }, t("toolsHint")),
661
- h(
662
781
  "div",
663
782
  { className: "aemh_toolRow" },
664
783
  h(
@@ -685,7 +804,8 @@ window.__ModuleLoader__.load({
685
804
  renderDiagnostics(diagnostics, t),
686
805
  renderInventory(inventory, t),
687
806
  ),
688
- ),
807
+ h(ActionBar, { view: bar, shell: state, t, onDiscard: props.discard, onSave: props.save }),
808
+ h(SavedToast, { tick: state.savedCount, t }),
689
809
  );
690
810
  }
691
811
 
@@ -810,6 +930,29 @@ window.__ModuleLoader__.load({
810
930
  return body;
811
931
  }
812
932
 
933
+ /**
934
+ * Whether a draft would actually change anything.
935
+ *
936
+ * The shared model counts any staged draft as dirty, including one that
937
+ * stages the value already in effect: typing a field's own value back left
938
+ * the form dirty and the control flagged as overridden while a save would
939
+ * write nothing. This classifies the draft by comparing the value it would
940
+ * write with the one in effect, so the page can present a control that has
941
+ * not actually changed as unchanged.
942
+ *
943
+ * @param {object} model - the bound `SettingsFormModel`.
944
+ * @param {object} field - the field descriptor.
945
+ * @param {string} text - the control's current text.
946
+ * @returns {"noop"|"real"|"invalid"} `noop` when a save would change nothing,
947
+ * `invalid` when the draft is not a value the field accepts.
948
+ */
949
+ function draftDelta(model, field, text) {
950
+ const write = specFor(field).parse(text);
951
+ if (write === undefined) return "invalid";
952
+ if (write.kind === "clear") return model.stored(field.key) ? "real" : "noop";
953
+ return Object.is(write.value, model.sectionValue(field.key)) ? "noop" : "real";
954
+ }
955
+
813
956
  /**
814
957
  * Own the page's staged form and the face its slot entry injects.
815
958
  *
@@ -824,14 +967,45 @@ window.__ModuleLoader__.load({
824
967
  scope,
825
968
  FIELDS.map((field) => specFor(field)),
826
969
  );
970
+ /** Saves that landed; the page flashes its confirmation when this grows. */
971
+ this.savedCount = 0;
972
+ /** Whether a write was in flight at the last projection. */
973
+ this.saveInFlight = false;
827
974
  this.store = this.model.bind(() => this.projection());
828
975
  }
829
976
 
830
977
  Controller.prototype.projection = function projection() {
831
978
  const shell = this.model.shell();
979
+ // The primitive reports its state, not its outcome, so a save that landed
980
+ // is observed as the transition out of "saving" with no failure and no
981
+ // edits left staged. Counting it here keeps the page's confirmation free
982
+ // of timers of its own.
983
+ if (shell.saving) this.saveInFlight = true;
984
+ else if (this.saveInFlight) {
985
+ this.saveInFlight = false;
986
+ if (!shell.failed && !shell.dirty) this.savedCount += 1;
987
+ }
832
988
  const fields = {};
833
- for (const field of FIELDS) fields[field.key] = this.model.field(field.key);
834
- return { ...shell, status: this.scope.getSnapshot().status, fields };
989
+ let changes = 0;
990
+ for (const field of FIELDS) {
991
+ const state = this.model.field(field.key);
992
+ const delta = draftDelta(this.model, field, state.text);
993
+ if (delta !== "noop") changes += 1;
994
+ fields[field.key] =
995
+ delta === "noop" && state.overridden
996
+ ? { ...state, overridden: this.model.stored(field.key) }
997
+ : state;
998
+ }
999
+ return {
1000
+ ...shell,
1001
+ // The model counts any staged draft as dirty, including one that would
1002
+ // write the value already in effect; the bar and the save button follow
1003
+ // this count instead, so reverting an edit really does clean the form.
1004
+ dirty: shell.dirty && changes > 0,
1005
+ savedCount: this.savedCount,
1006
+ status: this.scope.getSnapshot().status,
1007
+ fields,
1008
+ };
835
1009
  };
836
1010
 
837
1011
  Controller.prototype.inject = function inject() {
@@ -903,17 +1077,35 @@ window.__ModuleLoader__.load({
903
1077
 
904
1078
  exports.NS = NS;
905
1079
  /**
906
- * Field descriptors as this half sees them.
1080
+ * The field table as this half sees it, complete.
907
1081
  *
908
1082
  * Exported for the drift check in `scripts/check-bundle.mjs` only: the
909
- * browser cannot import the host's `lib/settings.js`, so the two field
910
- * tables are written twice and must be kept identical by construction.
1083
+ * browser cannot import the host's `lib/settings.js`, so the labels,
1084
+ * hints and options are written twice and must be kept identical by
1085
+ * construction. Exporting the *whole* descriptor rather than a projection
1086
+ * is what lets the gate catch a reworded label, not just a renamed field.
911
1087
  * Nothing at runtime reads this.
912
1088
  */
913
1089
  exports.FIELD_META = FIELDS.map((field) => ({
914
1090
  key: field.key,
915
1091
  kind: field.kind,
916
1092
  options: field.options,
1093
+ label: field.label,
1094
+ hint: field.hint,
1095
+ placeholder: field.placeholder,
1096
+ optionLabel: field.optionLabel,
1097
+ }));
1098
+ /**
1099
+ * The group table as this half sees it: the headings and hints the page
1100
+ * renders around each card, plus the field keys each card holds.
1101
+ *
1102
+ * Also for the drift check. Nothing at runtime reads this.
1103
+ */
1104
+ exports.GROUP_META = GROUPS.map((group) => ({
1105
+ id: group.id,
1106
+ title: group.title,
1107
+ hint: group.hint,
1108
+ fields: group.fields.map((field) => field.key),
917
1109
  }));
918
1110
  exports.apply = apply;
919
1111
  exports.inject = inject;
package/lib/index.js CHANGED
@@ -105,6 +105,16 @@ function registerSkill(ctx, logger) {
105
105
  description: loaded.description,
106
106
  ...(loaded.whenToUse === undefined ? {} : { whenToUse: loaded.whenToUse }),
107
107
  content: loaded.content,
108
+ // `source` and `path` are not optional in practice. The registry builds a
109
+ // discovery CANDIDATE out of the registered skill and validates it with
110
+ // `typeof candidate.source !== "string" -> throw`, so a registration that
111
+ // omits `source` fails when the skill is looked up, not when it is
112
+ // registered: `apply` succeeds and `skills.get('android-dev')` then throws
113
+ // "loaded skill android-dev source must be a string". `path` is what a
114
+ // caller shows and resolves the body from.
115
+ source: 'runtime',
116
+ path: path.join(SKILL_DIR, 'SKILL.md'),
117
+ provider: 'runtime',
108
118
  resourceBase: { kind: 'directory', path: SKILL_DIR },
109
119
  })
110
120
  }, 'android-emulator-helper: android-dev skill')
@@ -247,8 +257,11 @@ function registerRoutes(ctx, { config, logger }) {
247
257
  methods: ['GET'],
248
258
  requestBody: 'buffered',
249
259
  fetch: async () => {
250
- const rt = createRuntime({ config, exec: undefined, logger })
260
+ // `createRuntime` reads the live settings, so it belongs inside the
261
+ // guard: a reference that cannot be read must still answer with a
262
+ // JSON body instead of rejecting the request.
251
263
  try {
264
+ const rt = createRuntime({ config, exec: undefined, logger })
252
265
  return jsonResponse(200, await preflight(rt))
253
266
  } catch (error) {
254
267
  return jsonResponse(500, { error: message(error) })
@@ -264,8 +277,8 @@ function registerRoutes(ctx, { config, logger }) {
264
277
  methods: ['GET'],
265
278
  requestBody: 'buffered',
266
279
  fetch: async () => {
267
- const rt = createRuntime({ config, exec: undefined, logger })
268
280
  try {
281
+ const rt = createRuntime({ config, exec: undefined, logger })
269
282
  return jsonResponse(200, await inventory(rt))
270
283
  } catch (error) {
271
284
  return jsonResponse(500, { error: message(error) })