@3sln/nook 0.1.0 → 0.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/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
- import swipeDial from "./ui/swipeDial.js";
1
+ import { keyboardLabels } from "./ui/keyboardLabels.js";
2
+ import { tutorialGraphic } from "./ui/tutorialGraphics.js";
3
+ import swipeDial, { directionalChoice } from "./ui/swipeDial.js";
2
4
  import bounds, { hasAppContent } from "./ui/bounds.js";
3
5
  import localization from "./ui/localization.js";
4
6
  import feedbackUI from "./ui/feedback.js";
@@ -28,7 +30,8 @@ import {
28
30
  import styles from "./ui/styles.js";
29
31
 
30
32
  const mark = icon("nook");
31
- const tags = "nook-action,nook-menu,nook-panel,nook-setting,nook-command";
33
+ const tags =
34
+ "nook-item,nook-action,nook-menu,nook-panel,nook-setting,nook-command";
32
35
  const make = (tag, part, text) => {
33
36
  const node = document.createElement(tag);
34
37
  if (part) node.setAttribute("part", part);
@@ -54,9 +57,36 @@ const slotText = (item, slot) => {
54
57
  : node?.textContent
55
58
  )?.trim();
56
59
  };
60
+ const isLeaf = (item) => ["nook-item", "nook-action"].includes(item.localName);
61
+ const commandTarget = (item) => {
62
+ if (!isLeaf(item) || !item.hasAttribute("command")) return null;
63
+ const dock = item.closest("nook-dock"),
64
+ id = item.getAttribute("command")?.trim();
65
+ if (!dock || !id) return null;
66
+ return (
67
+ [...dock.querySelectorAll("nook-command")].find(
68
+ (command) => command.id === id && command.closest("nook-dock") === dock,
69
+ ) || null
70
+ );
71
+ };
72
+ const iconSlot = (item) =>
73
+ childSlot(item, "icon") ||
74
+ (commandTarget(item) && childSlot(commandTarget(item), "icon"));
57
75
  const label = (item) =>
58
- item.getAttribute("label") || slotText(item, "label") || "Untitled";
59
- const available = (item) => !item.closest("[hidden],[disabled],template");
76
+ item.getAttribute("label") ||
77
+ slotText(item, "label") ||
78
+ commandTarget(item)?.getAttribute("name") ||
79
+ "Untitled";
80
+ const available = (item) => {
81
+ if (item.closest("[hidden],[disabled],template")) return false;
82
+ if (!isLeaf(item) || !item.hasAttribute("command")) return true;
83
+ const command = commandTarget(item);
84
+ return Boolean(
85
+ command &&
86
+ validCommand(command) &&
87
+ !command.closest("[hidden],[disabled],template"),
88
+ );
89
+ };
60
90
 
61
91
  export class NookItem extends HTMLElement {
62
92
  constructor() {
@@ -84,7 +114,18 @@ export class NookItem extends HTMLElement {
84
114
  set root(value) {
85
115
  this.toggleAttribute("root", Boolean(value));
86
116
  }
117
+ get command() {
118
+ return this.getAttribute("command") || "";
119
+ }
120
+ set command(value) {
121
+ if (value == null || value === "") this.removeAttribute("command");
122
+ else this.setAttribute("command", String(value));
123
+ }
87
124
  get keybind() {
125
+ if (isLeaf(this) && this.hasAttribute("command"))
126
+ return commandTarget(this)?.getAttribute("keybind") || "";
127
+ // Legacy actions retain their old binding; new items put bindings on commands.
128
+ if (this.localName === "nook-item") return "";
88
129
  return this.getAttribute("keybind") || "";
89
130
  }
90
131
  set keybind(value) {
@@ -166,7 +207,8 @@ export class NookDock extends HTMLElement {
166
207
  this.anchor.append(this.handle, this.live, this.rootMeasure);
167
208
  this.overlayAnchor = this.make("div", "overlay-anchor");
168
209
  this.overlayAnchor.className = "anchor overlay-anchor";
169
- this.shadowRoot.append(this.anchor, this.overlayAnchor);
210
+ // Keep the handle above overlay material when their configured elevations match.
211
+ this.shadowRoot.append(this.overlayAnchor, this.anchor);
170
212
  const mirrored = [
171
213
  "data-corner",
172
214
  "data-position",
@@ -236,6 +278,7 @@ export class NookDock extends HTMLElement {
236
278
  });
237
279
  }
238
280
  connectedCallback() {
281
+ this.queryReady = false;
239
282
  this.syncLayers();
240
283
  this.layerObserver.observe(this.anchor, {
241
284
  attributes: true,
@@ -415,6 +458,8 @@ export class NookDock extends HTMLElement {
415
458
  );
416
459
  }
417
460
  disconnectedCallback() {
461
+ this.queryReady = false;
462
+ this.pendingIntents = [];
418
463
  this.layerObserver.disconnect();
419
464
  this.altHeld = false;
420
465
  this.anchor.removeAttribute("data-alt-held");
@@ -598,20 +643,13 @@ export class NookDock extends HTMLElement {
598
643
  ? this.getAttribute("root-keybind")
599
644
  : "Shift+?";
600
645
  const supported = bindingSupported(binding, this.browserKeyboardLayout);
601
- const text = supported
602
- ? `Menu · ${binding
603
- .split("+")
604
- .map((key) => key.trim())
605
- .join(" + ")}`
606
- : "Menu";
607
646
  this.i18n.text(
608
647
  this.handleTipShortcut,
609
648
  supported ? "Menu · {keybind}" : "Menu",
610
649
  {
611
- keybind: binding
612
- .split("+")
613
- .map((key) => key.trim())
614
- .join(" + "),
650
+ keybind: keyboardLabels(
651
+ this.ownerDocument.defaultView.navigator,
652
+ ).format(binding),
615
653
  },
616
654
  );
617
655
  }
@@ -632,6 +670,7 @@ export class NookDock extends HTMLElement {
632
670
  if (this.isConnected) this.reconcile();
633
671
  }
634
672
  if (name === "root-keybind") this.updateHandleTip();
673
+ if (name.endsWith("-keybind") && this.isConnected) this.reconcile();
635
674
  if (
636
675
  ["feedback-disabled", "settings-href"].includes(name) &&
637
676
  this.isConnected
@@ -649,6 +688,10 @@ export class NookDock extends HTMLElement {
649
688
  }
650
689
  }
651
690
  send(detail) {
691
+ if (this.isConnected && !this.queryReady) {
692
+ (this.pendingIntents ??= []).push(detail);
693
+ return;
694
+ }
652
695
  this.intents.dispatchEvent(new CustomEvent("intent", { detail }));
653
696
  }
654
697
  open(view = "root") {
@@ -695,10 +738,10 @@ export class NookDock extends HTMLElement {
695
738
  for (const [id, title] of [
696
739
  ["root", "Nook"],
697
740
  ["full", "More"],
698
- ["all", "All actions"],
741
+ ["all", "Full menu"],
699
742
  ["settings", "Quick settings"],
700
743
  ["commands", "Commands"],
701
- ["welcome", "Welcome to Nook"],
744
+ ["welcome", "Quick tour"],
702
745
  ["feedback", "Feedback"],
703
746
  ])
704
747
  this.view(id, title);
@@ -719,45 +762,57 @@ export class NookDock extends HTMLElement {
719
762
  copy.className = "tutorial";
720
763
  const keyboard = this.make("div", "tutorial-desktop");
721
764
  keyboard.className = "keyboard-only";
722
- const lead = this.make("p");
723
- lead.append(
724
- this.make("strong", null, "A small corner for useful extras."),
725
- document.createTextNode(" "),
726
- this.make(
727
- "span",
728
- null,
729
- "Click Nook to find actions, settings, and help for this page.",
730
- ),
765
+ const lead = this.make(
766
+ "p",
767
+ null,
768
+ "Arrows move; Enter selects. Backspace goes back; Esc closes.",
731
769
  );
732
770
  const numberTip = this.make(
733
771
  "p",
734
772
  null,
735
- "Hold Alt / Option to reveal numbered shortcuts.",
773
+ "Hold Alt / Option for labels and numbers.",
736
774
  );
737
775
  numberTip.className = "layout-hint";
738
- keyboard.append(
739
- lead,
740
- this.make(
741
- "p",
742
- null,
743
- "Use arrows to move and Enter to choose. Backspace goes back; Escape closes the menu.",
744
- ),
745
- numberTip,
776
+ const keyboardGraphic = tutorialGraphic(this.make, "keyboard");
777
+ this.i18n.text(
778
+ keyboardGraphic.querySelector(".tutorial-enter-label"),
779
+ "Enter",
746
780
  );
781
+ const shortcuts = this.make("dl", "tutorial-shortcuts");
782
+ shortcuts.className = "tutorial-shortcuts";
783
+ const shortcutRows = new Map();
784
+ for (const [id, title] of [
785
+ ["root", "Menu"],
786
+ ["all", "Full menu"],
787
+ ["settings", "Quick settings"],
788
+ ["commands", "Commands"],
789
+ ["feedback", "Feedback"],
790
+ ]) {
791
+ const row = this.make("div", "tutorial-shortcut");
792
+ const label = this.make("dt", "tutorial-shortcut-label", title);
793
+ const value = this.make("kbd", "tutorial-shortcut-keybind");
794
+ const description = this.make("dd");
795
+ description.append(value);
796
+ row.append(label, description);
797
+ shortcuts.append(row);
798
+ shortcutRows.set(id, { row, value });
799
+ }
800
+ keyboard.append(shortcuts, keyboardGraphic, lead, numberTip);
747
801
  const touch = this.make("div", "tutorial-touch");
748
802
  touch.className = "touch-only";
749
- const touchLead = this.make("p");
750
- touchLead.append(
751
- this.make("strong", null, "Your extras, within reach."),
752
- document.createTextNode(" "),
753
- this.make("span", null, "Tap Nook for actions, settings, and help."),
803
+ const touchLead = this.make(
804
+ "p",
805
+ null,
806
+ "Tap to open. Swipe sideways for the full menu or vertically for settings.",
754
807
  );
808
+ const touchGraphic = tutorialGraphic(this.make, "touch");
755
809
  touch.append(
810
+ touchGraphic,
756
811
  touchLead,
757
812
  this.make(
758
813
  "p",
759
814
  null,
760
- "When available, swipe sideways or diagonally for all actions, or up or down for settings.",
815
+ "Hold a swipe to pick. Release elsewhere to cancel.",
761
816
  ),
762
817
  );
763
818
  const welcomeMove = this.make(
@@ -766,7 +821,8 @@ export class NookDock extends HTMLElement {
766
821
  "Hold Nook, then drag to move it.",
767
822
  );
768
823
  welcomeMove.setAttribute("part", "drag-hint tutorial-drag");
769
- copy.append(keyboard, touch, welcomeMove);
824
+ const dragGraphic = tutorialGraphic(this.make, "drag");
825
+ copy.append(keyboard, touch, welcomeMove, dragGraphic);
770
826
  const done = this.make("button", "tutorial-continue", "Got it");
771
827
  done.className = "command-run";
772
828
  done.onclick = () => {
@@ -786,11 +842,18 @@ export class NookDock extends HTMLElement {
786
842
  this.tutorialUI = {
787
843
  welcome,
788
844
  done,
845
+ lead,
846
+ numberTip,
847
+ shortcuts,
848
+ shortcutRows,
789
849
  regions: [
790
850
  ["tutorial", copy],
791
851
  ["tutorial-desktop", keyboard],
792
852
  ["tutorial-touch", touch],
793
853
  ["tutorial-drag", welcomeMove],
854
+ ["tutorial-keyboard-graphic", keyboardGraphic],
855
+ ["tutorial-touch-graphic", touchGraphic],
856
+ ["tutorial-drag-graphic", dragGraphic],
794
857
  ].map(([name, element]) => ({
795
858
  name,
796
859
  element,
@@ -802,12 +865,21 @@ export class NookDock extends HTMLElement {
802
865
  for (const [id, title, iconText] of [
803
866
  ["feedback", "Feedback", icon("feedback")],
804
867
  ["quick", "More", icon("more")],
868
+ ["all", "Full menu", icon("menu")],
869
+ ["welcome", "Quick tour", icon("guide")],
805
870
  ["settings", "Settings", icon("settings")],
806
871
  ["commands", "Commands", icon("commands")],
807
872
  ]) {
808
873
  const b = this.itemButton(null, title, iconText);
809
874
  b.onclick = () =>
810
875
  this.send({ type: "enter", view: id === "quick" ? "full" : id });
876
+ if (id === "welcome") {
877
+ const badge = this.make("span", "tutorial-badge");
878
+ badge.className = "tutorial-badge";
879
+ badge.setAttribute("aria-hidden", "true");
880
+ b.append(badge);
881
+ this.i18n.attr(b, "aria-description", "New: learn how to use Nook.");
882
+ }
811
883
  this.builtins.set(id, b);
812
884
  root.grid.append(b);
813
885
  }
@@ -823,13 +895,17 @@ export class NookDock extends HTMLElement {
823
895
  bindingSupported(binding, this.browserKeyboardLayout),
824
896
  });
825
897
  }
826
- itemButton(item, title, iconText = icon("file")) {
898
+ itemButton(
899
+ item,
900
+ title,
901
+ iconText = icon(item && commandTarget(item) ? "commands" : "file"),
902
+ ) {
827
903
  const button = this.make("button", "item");
828
904
  button.className = "item";
829
905
  button.type = "button";
830
906
  const icon = this.make("span", "icon");
831
907
  icon.className = "icon";
832
- const template = item && childSlot(item, "icon");
908
+ const template = item && iconSlot(item);
833
909
  if (template)
834
910
  icon.append(
835
911
  template instanceof HTMLTemplateElement
@@ -862,13 +938,63 @@ export class NookDock extends HTMLElement {
862
938
  this.i18n.attr(button, "aria-label", title);
863
939
  return button;
864
940
  }
941
+ reconcileTutorialShortcuts(items) {
942
+ const ui = this.tutorialUI;
943
+ if (!ui) return;
944
+ const labels = keyboardLabels(this.ownerDocument.defaultView.navigator);
945
+ this.i18n.text(
946
+ ui.lead,
947
+ "Arrows move; Enter selects. {back} goes back; Esc closes.",
948
+ { back: this.i18n.t(labels.back) },
949
+ );
950
+ this.i18n.text(ui.numberTip, "Hold {modifier} for labels and numbers.", {
951
+ modifier: this.i18n.t(labels.alt),
952
+ });
953
+ ui.numberTip.hidden =
954
+ !this.browserKeyboardLayout ||
955
+ ![...this.browserKeyboardLayout.values()].some((value) =>
956
+ /^[1-9]$/.test(value),
957
+ );
958
+ const defaults = {
959
+ root: "Shift+?",
960
+ all: "Alt+Shift+M",
961
+ settings: "Alt+Shift+S",
962
+ commands: labels.mac ? "Meta+Shift+P" : "Ctrl+Shift+P",
963
+ feedback: "Alt+Shift+F",
964
+ };
965
+ let visible = 0;
966
+ for (const [id, { row, value }] of ui.shortcutRows) {
967
+ const attribute = `${id}-keybind`;
968
+ const binding = this.hasAttribute(attribute)
969
+ ? this.getAttribute(attribute)
970
+ : defaults[id];
971
+ const available =
972
+ id === "root" ||
973
+ this.surfaceAvailable(id) ||
974
+ (id === "feedback" &&
975
+ !this.feedbackDisabled() &&
976
+ items.some((item) => item.hasAttribute("feedback")));
977
+ row.hidden =
978
+ !available || !bindingSupported(binding, this.browserKeyboardLayout);
979
+ if (!row.hidden) visible++;
980
+ const text = labels.format(binding);
981
+ if (value.textContent !== text) value.textContent = text;
982
+ }
983
+ ui.shortcuts.hidden = !visible;
984
+ }
985
+
865
986
  reconcileTutorial() {
866
987
  const ui = this.tutorialUI;
867
988
  if (!ui) return;
868
989
  const options = this.tutorial || {};
869
- for (const [view, title] of [
870
- [ui.welcome, options.title ?? "Welcome to Nook"],
871
- ]) {
990
+ const guide = this.builtins?.get("welcome");
991
+ if (guide) {
992
+ const title = options.label ?? "Quick tour";
993
+ this.i18n.attr(guide, "aria-label", title);
994
+ this.i18n.text(guide.querySelector(".item-label"), title);
995
+ this.i18n.text(guide.querySelector(".tooltip-text"), title);
996
+ }
997
+ for (const [view, title] of [[ui.welcome, options.title ?? "Quick tour"]]) {
872
998
  this.i18n.text(view.heading, title);
873
999
  this.i18n.attr(view.el, "aria-label", title);
874
1000
  }
@@ -907,6 +1033,7 @@ export class NookDock extends HTMLElement {
907
1033
  const set = new Set(items);
908
1034
  const placements = new Map();
909
1035
  this.planItems(items);
1036
+ this.reconcileTutorialShortcuts(items);
910
1037
  for (const [item, record] of this.records)
911
1038
  if (!set.has(item)) {
912
1039
  record.enum?.dispose();
@@ -939,7 +1066,7 @@ export class NookDock extends HTMLElement {
939
1066
  const topLevel = this.topLevelItems.includes(item);
940
1067
  const destinations =
941
1068
  item.localName === "nook-setting"
942
- ? [root ? "root" : "settings"]
1069
+ ? ["settings"]
943
1070
  : topLevel
944
1071
  ? [this.rootItems.includes(item) ? "root" : "full"]
945
1072
  : parent && this.records.has(parent)
@@ -987,7 +1114,7 @@ export class NookDock extends HTMLElement {
987
1114
  "|" +
988
1115
  item.keybind +
989
1116
  "|" +
990
- childSlot(item, "icon")?.outerHTML +
1117
+ iconSlot(item)?.outerHTML +
991
1118
  "|" +
992
1119
  bindingSupported(item.keybind, this.browserKeyboardLayout);
993
1120
  if (record.signature !== signature) {
@@ -1081,9 +1208,9 @@ export class NookDock extends HTMLElement {
1081
1208
  this.feedbackUI?.setEnabled(feedbackEnabled);
1082
1209
  this.feedbackUI?.reconcile();
1083
1210
  if (!feedbackEnabled && this.state?.path.includes("feedback")) this.close();
1084
- this.builtins.get("quick").hidden = !this.overflowEntries.length;
1211
+ this.builtins.get("quick").hidden = true;
1085
1212
  const commands = items.filter((item) => item.localName === "nook-command");
1086
- for (const id of ["feedback", "settings", "commands"])
1213
+ for (const id of ["feedback", "settings", "commands", "all", "welcome"])
1087
1214
  this.builtins.get(id).hidden = !this.rootEntries.some(
1088
1215
  (entry) => entry.id === id,
1089
1216
  );
@@ -1123,9 +1250,8 @@ export class NookDock extends HTMLElement {
1123
1250
  : `${id}-presentation`,
1124
1251
  )
1125
1252
  : id.getAttribute("presentation");
1126
- view.presentation = presentation(kind, count, override);
1127
- if (id === "root" && !["compact", "expanded"].includes(override))
1128
- view.presentation = count > this.rootCapacity ? "expanded" : "compact";
1253
+ view.presentation =
1254
+ id === "root" ? "compact" : presentation(kind, count, override);
1129
1255
  if (view.grid && id !== "full" && id !== "all") {
1130
1256
  const labelsVisible = id !== "root" || view.presentation === "expanded";
1131
1257
  view.el.dataset.layout =
@@ -1169,39 +1295,18 @@ export class NookDock extends HTMLElement {
1169
1295
  this.state.path.at(-1) === focusedView &&
1170
1296
  this.shadowRoot.activeElement === focused
1171
1297
  )
1172
- (focusedView === "root" && !this.builtins.get("quick").hidden
1173
- ? this.builtins.get("quick")
1174
- : this.focusables()[0]
1175
- )?.focus();
1298
+ this.focusables()[0]?.focus();
1176
1299
  });
1177
1300
  }
1178
1301
  }
1179
1302
  }
1180
1303
  planItems(items) {
1181
- const style = this.ownerDocument.defaultView.getComputedStyle(
1182
- this.rootMeasure,
1183
- );
1184
- const size = parseFloat(style.width) || 48;
1185
- const measuredGap = parseFloat(style.marginInlineEnd);
1186
- const gap = Number.isFinite(measuredGap) ? measuredGap : 8;
1304
+ const style = this.ownerDocument.defaultView.getComputedStyle(this);
1187
1305
  this.rootCapacity = Math.max(
1188
- 1,
1306
+ 2,
1189
1307
  Math.min(
1190
- Math.max(
1191
- 1,
1192
- parseInt(style.getPropertyValue("--nook-root-max-items"), 10) || 5,
1193
- ),
1194
- Math.floor(
1195
- ((this.anchorRect?.width ??
1196
- this.ownerDocument.defaultView.innerWidth) -
1197
- (this.position === "left-center" || this.position === "right-center"
1198
- ? (parseFloat(style.marginBlockStart) || 56) +
1199
- (parseFloat(style.marginBlockEnd) || 12) +
1200
- 16
1201
- : 32) +
1202
- gap) /
1203
- (size + gap),
1204
- ),
1308
+ 6,
1309
+ parseInt(style.getPropertyValue("--nook-root-max-items"), 10) || 6,
1205
1310
  ),
1206
1311
  );
1207
1312
  this.topLevelItems = items.filter(
@@ -1218,7 +1323,7 @@ export class NookDock extends HTMLElement {
1218
1323
  this.availableBuiltins.add("feedback");
1219
1324
  if (
1220
1325
  this.settingsHref ||
1221
- items.some((item) => item.localName === "nook-setting" && !item.root)
1326
+ items.some((item) => item.localName === "nook-setting")
1222
1327
  )
1223
1328
  this.availableBuiltins.add("settings");
1224
1329
  if (items.some((item) => item.localName === "nook-command"))
@@ -1245,32 +1350,36 @@ export class NookDock extends HTMLElement {
1245
1350
  .filter((id) => this.availableBuiltins.has(id))
1246
1351
  .map((id) => builtin(id, id === "settings" ? 20 : 10)),
1247
1352
  ];
1248
- const pinnedSettings = items.filter(
1249
- (item) => item.localName === "nook-setting" && item.root,
1250
- ).length;
1251
- const plan = distributeItems(
1252
- entries,
1253
- Math.max(1, this.rootCapacity - pinnedSettings),
1254
- );
1353
+ const tour = this.state?.needsIntro
1354
+ ? [{ id: "welcome", priority: Number.MAX_SAFE_INTEGER, group: "" }]
1355
+ : [];
1255
1356
  this.allEntries = distributeItems(
1256
- [
1257
- ...entries,
1258
- ...items
1259
- .filter((item) => item.localName === "nook-setting" && item.root)
1260
- .map((item) => ({
1261
- id: item,
1262
- priority: item.priority,
1263
- group: item.group.trim(),
1264
- })),
1265
- ],
1357
+ [...tour, ...entries],
1266
1358
  Number.MAX_SAFE_INTEGER,
1267
1359
  ).root;
1268
- this.rootEntries = plan.root;
1269
- this.overflowEntries = plan.overflow;
1270
- this.rootItems = plan.root
1360
+ if (this.allEntries.length) this.availableBuiltins.add("all");
1361
+ if (this.state?.needsIntro) this.availableBuiltins.add("welcome");
1362
+ const reserved = [
1363
+ ...(this.allEntries.length
1364
+ ? [{ id: "all", priority: 0, group: "" }]
1365
+ : []),
1366
+ ...(this.availableBuiltins.has("settings")
1367
+ ? [builtin("settings", 20)]
1368
+ : []),
1369
+ ];
1370
+ const extras = this.allEntries.filter((entry) => entry.id !== "settings");
1371
+ this.rootEntries = [
1372
+ ...reserved,
1373
+ ...extras.slice(0, Math.min(4, this.rootCapacity - reserved.length)),
1374
+ ];
1375
+ const picked = new Set(this.rootEntries.map((entry) => entry.id));
1376
+ this.overflowEntries = this.allEntries.filter(
1377
+ (entry) => !picked.has(entry.id),
1378
+ );
1379
+ this.rootItems = this.rootEntries
1271
1380
  .map((entry) => entry.id)
1272
1381
  .filter((id) => typeof id !== "string");
1273
- this.overflowItems = plan.overflow
1382
+ this.overflowItems = this.overflowEntries
1274
1383
  .map((entry) => entry.id)
1275
1384
  .filter((id) => typeof id !== "string");
1276
1385
  }
@@ -1292,7 +1401,8 @@ export class NookDock extends HTMLElement {
1292
1401
  // Only launch buttons are mirrored. Panels and setting controls have one
1293
1402
  // mounted instance, even when a menu is projected as nested sections.
1294
1403
  for (const [id, view] of this.views) {
1295
- if (!view.grid || id === "full" || id === "all") continue;
1404
+ if (!view.grid || id === "root" || id === "full" || id === "all")
1405
+ continue;
1296
1406
  if (!view.grouped) {
1297
1407
  view.grouped = this.make("div", "groups");
1298
1408
  view.grouped.className = "groups";
@@ -1454,11 +1564,12 @@ export class NookDock extends HTMLElement {
1454
1564
  const source = typeof entry.id === "string" ? null : entry.id;
1455
1565
  const title = source
1456
1566
  ? label(source)
1457
- : entry.id === "feedback"
1458
- ? "Feedback"
1459
- : entry.id === "commands"
1460
- ? "Commands"
1461
- : "Settings";
1567
+ : {
1568
+ feedback: "Feedback",
1569
+ commands: "Commands",
1570
+ settings: "Settings",
1571
+ welcome: this.tutorial?.label ?? "Quick tour",
1572
+ }[entry.id];
1462
1573
  const signature = source ? this.records.get(source).signature : title;
1463
1574
  let record = view.overflowButtons.get(entry.id);
1464
1575
  if (!record) {
@@ -1466,19 +1577,31 @@ export class NookDock extends HTMLElement {
1466
1577
  source,
1467
1578
  title,
1468
1579
  icon(
1469
- entry.id === "feedback"
1470
- ? "feedback"
1471
- : entry.id === "commands"
1580
+ source
1581
+ ? commandTarget(source)
1472
1582
  ? "commands"
1473
- : "settings",
1583
+ : "file"
1584
+ : {
1585
+ feedback: "feedback",
1586
+ commands: "commands",
1587
+ settings: "settings",
1588
+ welcome: "guide",
1589
+ }[entry.id],
1474
1590
  ),
1475
1591
  );
1476
1592
  button.onclick = () => {
1477
1593
  if (source?.localName === "nook-setting") {
1478
- this.open("root");
1594
+ this.open("settings");
1479
1595
  queueMicrotask(() => source._control?.focus());
1480
1596
  } else if (source) this.activate(source);
1481
- else this.builtins.get(entry.id).click();
1597
+ else if (
1598
+ view === this.views.get("all") &&
1599
+ entry.id === "settings" &&
1600
+ this.settingsHref
1601
+ ) {
1602
+ this.views.get("settings").link.click();
1603
+ this.close();
1604
+ } else this.builtins.get(entry.id).click();
1482
1605
  };
1483
1606
  record = { button, signature };
1484
1607
  view.overflowButtons.set(entry.id, record);
@@ -1648,22 +1771,41 @@ export class NookDock extends HTMLElement {
1648
1771
  this.chooseCommand(item);
1649
1772
  return;
1650
1773
  }
1651
- if (item.localName === "nook-menu" || item.localName === "nook-panel")
1774
+ if (item.localName === "nook-menu" || item.localName === "nook-panel") {
1652
1775
  this.send({ type: "enter", view: item });
1653
- else if (
1654
- emit(item, "nook-action", {
1655
- source: item,
1656
- id: item.id,
1657
- context: this.context,
1658
- })
1659
- )
1660
- this.close();
1776
+ return;
1777
+ }
1778
+ const command = commandTarget(item);
1779
+ if (
1780
+ emit(
1781
+ item,
1782
+ item.localName === "nook-item" ? "nook-select" : "nook-action",
1783
+ {
1784
+ source: item,
1785
+ id: item.id,
1786
+ command,
1787
+ context: this.context,
1788
+ },
1789
+ )
1790
+ ) {
1791
+ if (command) this.chooseCommand(command);
1792
+ else this.close();
1793
+ }
1661
1794
  }
1795
+
1662
1796
  project(state, focus = true) {
1663
1797
  const previousCorner = this.state?.corner;
1664
1798
  const previousPreferred = this.state?.preferredPosition;
1665
1799
  const previous = this.state?.path.at(-1);
1800
+ const previousNeedsIntro = this.state?.needsIntro;
1666
1801
  this.state = state;
1802
+ this.queryReady = true;
1803
+ if (this.pendingIntents?.length) {
1804
+ const pending = this.pendingIntents.splice(0);
1805
+ queueMicrotask(() => {
1806
+ if (this.isConnected) for (const detail of pending) this.send(detail);
1807
+ });
1808
+ }
1667
1809
  const movable = this.canDrag();
1668
1810
  this.anchor.dataset.draggable = String(movable);
1669
1811
  if (
@@ -1698,7 +1840,7 @@ export class NookDock extends HTMLElement {
1698
1840
  (id) => this.views.get(id)?.presentation || "compact",
1699
1841
  );
1700
1842
  for (const [id, view] of this.views) {
1701
- const visible = id === active;
1843
+ const visible = id === active || (id === "root" && this.swipeDial?.shown);
1702
1844
  view.el.dataset.presentation = visible
1703
1845
  ? effective
1704
1846
  : view.presentation || "compact";
@@ -1748,6 +1890,7 @@ export class NookDock extends HTMLElement {
1748
1890
  view.picker?.syncOpen(visible);
1749
1891
  view.back.hidden = state.path.length <= 1;
1750
1892
  }
1893
+ this.swipeDial?.sync();
1751
1894
  if (!active) delete this.anchor.dataset.numbering;
1752
1895
  if (active && this.anchor.dataset.numbering) this.showNumbers();
1753
1896
  this.handle.setAttribute("aria-expanded", String(Boolean(active)));
@@ -1776,6 +1919,7 @@ export class NookDock extends HTMLElement {
1776
1919
  ).focus();
1777
1920
  }
1778
1921
  }
1922
+ if (previousNeedsIntro !== state.needsIntro) this.reconcile();
1779
1923
  if (previousCorner && previousCorner !== state.corner) {
1780
1924
  this.settleCorner();
1781
1925
  emit(this, "nook-corner-change", { corner: state.corner });
@@ -1796,7 +1940,7 @@ export class NookDock extends HTMLElement {
1796
1940
  }
1797
1941
  focusables() {
1798
1942
  const view = this.views.get(this.state?.path.at(-1));
1799
- return view
1943
+ const nodes = view
1800
1944
  ? [
1801
1945
  ...view.el.querySelectorAll(
1802
1946
  "button,input,select,textarea,a[href],[tabindex]",
@@ -1805,8 +1949,17 @@ export class NookDock extends HTMLElement {
1805
1949
  (n) => !n.disabled && n.tabIndex >= 0 && n.getClientRects().length,
1806
1950
  )
1807
1951
  : [];
1952
+ return this.state?.path.at(-1) === "root"
1953
+ ? nodes.sort(
1954
+ (a, b) => Number(a.dataset.dialOrder) - Number(b.dataset.dialOrder),
1955
+ )
1956
+ : nodes;
1808
1957
  }
1809
1958
  numberedItems() {
1959
+ if (this.state?.path.at(-1) === "root")
1960
+ return this.focusables()
1961
+ .filter((node) => node.classList.contains("item"))
1962
+ .slice(0, 9);
1810
1963
  const view = this.views.get(this.state?.path.at(-1));
1811
1964
  return view
1812
1965
  ? [...view.body.querySelectorAll(".item,.command-result")]
@@ -1947,7 +2100,10 @@ export class NookDock extends HTMLElement {
1947
2100
  return;
1948
2101
  }
1949
2102
  for (const item of this.records.keys())
1950
- if (bindingMatches(item.keybind)) {
2103
+ if (
2104
+ !(isLeaf(item) && item.hasAttribute("command")) &&
2105
+ bindingMatches(item.keybind)
2106
+ ) {
1951
2107
  e.preventDefault();
1952
2108
  this.activate(item);
1953
2109
  return;
@@ -1976,6 +2132,10 @@ export class NookDock extends HTMLElement {
1976
2132
  ].includes(e.key)
1977
2133
  ) {
1978
2134
  e.preventDefault();
2135
+ if (this.state.path.at(-1) === "root" && e.key.startsWith("Arrow")) {
2136
+ directionalChoice(nodes, current, e.key)?.focus();
2137
+ return;
2138
+ }
1979
2139
  const next =
1980
2140
  e.key === "Home"
1981
2141
  ? 0
@@ -2100,8 +2260,19 @@ export class NookDock extends HTMLElement {
2100
2260
  ? this.swipeDial.release(e.clientX, e.clientY)
2101
2261
  : view;
2102
2262
  if (view || p.swipeStarted || dialShown) this.suppressClick = true;
2103
- if (destination && this.surfaceAvailable(destination))
2104
- this.open(destination);
2263
+ this.cancelPointer();
2264
+ if (
2265
+ typeof destination === "string" &&
2266
+ this.surfaceAvailable(destination)
2267
+ ) {
2268
+ if (dialShown) {
2269
+ this.open("root");
2270
+ this.send({ type: "enter", view: destination });
2271
+ } else this.open(destination);
2272
+ } else if (destination && this.records.has(destination)) {
2273
+ if (dialShown) this.open("root");
2274
+ this.activate(destination);
2275
+ } else if (dialShown) this.close();
2105
2276
  }
2106
2277
  this.cancelPointer();
2107
2278
  }
@@ -2122,6 +2293,7 @@ export class NookDock extends HTMLElement {
2122
2293
 
2123
2294
  export function defineNook(registry = customElements) {
2124
2295
  for (const tag of [
2296
+ "nook-item",
2125
2297
  "nook-action",
2126
2298
  "nook-menu",
2127
2299
  "nook-setting",