@obvi/blueprint 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/blueprint.js CHANGED
@@ -9,7 +9,7 @@ function extractRationale(root) {
9
9
  if (!node) return null;
10
10
  const wrap = node.ownerDocument.createElement("div");
11
11
  wrap.className = "bp-choice-rationale";
12
- wrap.append(...node.childNodes);
12
+ wrap.append(...[...node.childNodes].map((child) => child.cloneNode(true)));
13
13
  return wrap;
14
14
  }
15
15
  function bodyWithoutRationale(root) {
@@ -120,6 +120,187 @@ function buildResolvedChoice(doc, { options, verdictKicker, resolvedValue }) {
120
120
  root.append(stack);
121
121
  return root;
122
122
  }
123
+ function buildChoiceManifest(host, layout, options, resolvedValue) {
124
+ return {
125
+ id: host.id || host.getAttribute("name") || "",
126
+ label: host.getAttribute("data-obvious-choice-label") || host.getAttribute("aria-label") || host.getAttribute("name") || host.id || "",
127
+ layout,
128
+ resolvedValue,
129
+ options: options.map((option) => ({
130
+ value: option.value,
131
+ label: option.title || option.tab || option.value,
132
+ title: option.title
133
+ }))
134
+ };
135
+ }
136
+ function dispatchChoiceEvent(host, type, detail) {
137
+ host.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail }));
138
+ }
139
+ function setChoiceControlsDisabled(root, disabled) {
140
+ for (const control of root.querySelectorAll("input, button")) {
141
+ if (control instanceof HTMLInputElement || control instanceof HTMLButtonElement) {
142
+ control.disabled = disabled;
143
+ }
144
+ }
145
+ }
146
+ function appendLockedHostActions(doc, root, state) {
147
+ const actions = el(doc, "div", "bp-choice__actions");
148
+ const status = el(doc, "output", "bp-choice__hint");
149
+ status.setAttribute("aria-live", "polite");
150
+ status.textContent = state.message;
151
+ const reset = el(doc, "button", "bp-choice__reset");
152
+ reset.type = "button";
153
+ reset.disabled = state.busy;
154
+ reset.textContent = state.busy ? "Saving…" : state.reconsider;
155
+ actions.append(status, reset);
156
+ root.append(actions);
157
+ return reset;
158
+ }
159
+ function buildInteractiveChoice(doc, source) {
160
+ const {
161
+ layout,
162
+ options,
163
+ verdictKicker,
164
+ adoptLabel,
165
+ hint,
166
+ reconsider,
167
+ compareSummary,
168
+ compareBody,
169
+ scopeId,
170
+ initialValue,
171
+ busy,
172
+ message
173
+ } = source;
174
+ const viewName = `${scopeId}-view`;
175
+ const pickName = `${scopeId}-pick`;
176
+ const form = el(doc, "form", `bp-choice bp-choice--${layout}`);
177
+ form.dataset.bpChoice = scopeId;
178
+ const titleOpts = options.map((option) => ({ value: option.value, title: option.title }));
179
+ form.append(buildVerdict(doc, verdictKicker, titleOpts));
180
+ if (layout === "tabs") {
181
+ const selectedView = options.some((option) => option.value === initialValue) ? initialValue : options[0]?.value;
182
+ const seg = el(doc, "div", "bp-choice__seg");
183
+ seg.setAttribute("role", "tablist");
184
+ for (const opt of options) {
185
+ const segOpt = el(doc, "label", "bp-choice__seg-opt");
186
+ const input = doc.createElement("input");
187
+ input.type = "radio";
188
+ input.name = viewName;
189
+ input.value = opt.value;
190
+ input.className = "bp-choice__view";
191
+ if (opt.value === selectedView) {
192
+ input.defaultChecked = true;
193
+ input.checked = true;
194
+ }
195
+ segOpt.append(input, doc.createTextNode(opt.tab));
196
+ seg.append(segOpt);
197
+ }
198
+ form.append(seg);
199
+ const panels = el(doc, "div", "bp-choice__panels");
200
+ for (const opt of options) {
201
+ const panel = el(doc, "div", "bp-choice__panel");
202
+ panel.dataset.value = opt.value;
203
+ const h3 = el(doc, "h3");
204
+ h3.textContent = opt.title;
205
+ panel.append(h3);
206
+ panel.append(bodyWithoutRationale(opt.el));
207
+ const rationale = extractRationale(opt.el);
208
+ if (rationale) panel.append(rationale);
209
+ const actions = el(doc, "div", "bp-choice__actions");
210
+ const adopt = el(doc, "label", "bp-choice__adopt");
211
+ const commit = doc.createElement("input");
212
+ commit.type = "radio";
213
+ commit.name = pickName;
214
+ commit.value = opt.value;
215
+ commit.className = "bp-choice__commit";
216
+ adopt.append(commit, doc.createTextNode(adoptLabel));
217
+ actions.append(adopt);
218
+ panel.append(actions);
219
+ panels.append(panel);
220
+ }
221
+ form.append(panels);
222
+ const style = el(doc, "style");
223
+ style.textContent = scopedTabRules(`[data-bp-choice="${scopeId}"]`, viewName, pickName, titleOpts);
224
+ form.prepend(style);
225
+ form.append(buildFooter(doc, hint || "Switch tabs to preview · adopt to commit", reconsider));
226
+ }
227
+ if (layout === "stack") {
228
+ const stack = el(doc, "div", "bp-choice__stack");
229
+ for (const opt of options) {
230
+ const card = el(doc, "label", "bp-choice__card");
231
+ const input = doc.createElement("input");
232
+ input.type = "radio";
233
+ input.name = pickName;
234
+ input.value = opt.value;
235
+ input.className = "bp-choice__pick";
236
+ card.append(input);
237
+ const head = el(doc, "div", "bp-choice__card-head");
238
+ if (opt.optionLabel) head.append(label(doc, opt.optionLabel));
239
+ const chosen = el(doc, "span", "bp-choice__tag bp-choice__tag--ink bp-choice__card-chosen");
240
+ chosen.textContent = "✓ Chosen";
241
+ const rejected = el(doc, "span", "bp-choice__tag bp-choice__tag--out bp-choice__card-rejected");
242
+ rejected.textContent = "Not chosen";
243
+ head.append(chosen, rejected);
244
+ card.append(head);
245
+ const h4 = el(doc, "h4");
246
+ h4.textContent = opt.title;
247
+ card.append(h4);
248
+ card.append(bodyWithoutRationale(opt.el));
249
+ const rationale = extractRationale(opt.el);
250
+ if (rationale) card.append(rationale);
251
+ stack.append(card);
252
+ }
253
+ form.append(stack);
254
+ form.append(buildFooter(doc, hint || "Select a card to commit · rejected drafts stay readable below", reconsider));
255
+ }
256
+ if (layout === "gallery") {
257
+ const gallery = el(doc, "div", "bp-choice__gallery");
258
+ for (const opt of options) {
259
+ const mock = el(doc, "label", "bp-choice__mock");
260
+ const input = doc.createElement("input");
261
+ input.type = "radio";
262
+ input.name = pickName;
263
+ input.value = opt.value;
264
+ input.className = "bp-choice__pick";
265
+ mock.append(input);
266
+ const frame = el(doc, "div", "bp-choice__mock-frame");
267
+ const frameInner = frameContent(opt.el);
268
+ if (frameInner) frame.append(frameInner);
269
+ mock.append(frame);
270
+ const cap = el(doc, "div", "bp-choice__mock-cap");
271
+ const h4 = el(doc, "h4");
272
+ h4.textContent = opt.caption;
273
+ cap.append(h4);
274
+ const chosen = el(doc, "span", "bp-choice__tag bp-choice__tag--ink bp-choice__mock-pick bp-choice__mock-chosen");
275
+ chosen.textContent = "✓ Chosen";
276
+ const rejected = el(doc, "span", "bp-choice__tag bp-choice__tag--out bp-choice__mock-pick bp-choice__mock-rejected");
277
+ rejected.textContent = "Not chosen";
278
+ cap.append(chosen, rejected);
279
+ mock.append(cap);
280
+ gallery.append(mock);
281
+ }
282
+ form.append(gallery);
283
+ if (compareSummary && compareBody) {
284
+ const details = el(doc, "details", "bp-choice__compare");
285
+ const summary = el(doc, "summary");
286
+ summary.textContent = compareSummary;
287
+ details.append(summary);
288
+ const body = el(doc, "div", "bp-choice__compare-body");
289
+ body.append(compareBody.cloneNode(true));
290
+ details.append(body);
291
+ form.append(details);
292
+ }
293
+ form.append(buildFooter(doc, hint || "Select a mockup to track the decision", reconsider));
294
+ }
295
+ if (message) {
296
+ const status = el(doc, "output", "bp-choice__hint");
297
+ status.setAttribute("aria-live", "polite");
298
+ status.textContent = message;
299
+ form.append(status);
300
+ }
301
+ setChoiceControlsDisabled(form, busy);
302
+ return form;
303
+ }
123
304
  var BlueprintRationaleElement = class extends HTMLElement {
124
305
  connectedCallback() {
125
306
  if (this.dataset.bpRendered) return;
@@ -130,158 +311,104 @@ var BlueprintRationaleElement = class extends HTMLElement {
130
311
  var BlueprintChoiceOptionElement = class extends HTMLElement {
131
312
  };
132
313
  var BlueprintChoiceElement = class extends HTMLElement {
314
+ constructor() {
315
+ super();
316
+ this.choiceSource = null;
317
+ this.choiceHostState = null;
318
+ this.choiceScopeId = null;
319
+ }
133
320
  connectedCallback() {
134
- if (this.dataset.bpRendered) return;
321
+ if (this.choiceSource) return;
135
322
  this.dataset.bpRendered = "1";
136
- const doc = this.ownerDocument;
137
323
  const layout = (this.getAttribute("layout") || "stack").toLowerCase();
138
- const verdictKicker = this.getAttribute("verdict") ?? "Chosen";
139
- const adoptLabel = this.getAttribute("adopt") ?? "Adopt this direction";
140
- const hint = this.getAttribute("hint") ?? "";
141
- const reconsider = this.getAttribute("reconsider") ?? "Reconsider";
142
- const compareSummary = this.getAttribute("compare");
143
- const compareBody = this.querySelector(':scope > [slot="compare"]');
144
- const options = readOptions(this);
145
- const resolvedValue = this.getAttribute("resolved");
146
- if (resolvedValue !== null) {
147
- this.replaceChildren(buildResolvedChoice(doc, { options, verdictKicker, resolvedValue }));
148
- return;
149
- }
150
- const scopeId = nextId("bp-choice");
151
- const viewName = `${scopeId}-view`;
152
- const pickName = `${scopeId}-pick`;
153
- const form = el(doc, "form", `bp-choice bp-choice--${layout}`);
154
- form.dataset.bpChoice = scopeId;
155
- const titleOpts = options.map((o) => ({ value: o.value, title: o.title }));
156
- form.append(buildVerdict(doc, verdictKicker, titleOpts));
157
- if (layout === "tabs") {
158
- const seg = el(doc, "div", "bp-choice__seg");
159
- seg.setAttribute("role", "tablist");
160
- for (const [index, opt] of options.entries()) {
161
- const segOpt = el(doc, "label", "bp-choice__seg-opt");
162
- const input = doc.createElement("input");
163
- input.type = "radio";
164
- input.name = viewName;
165
- input.value = opt.value;
166
- input.className = "bp-choice__view";
167
- if (index === 0) {
168
- input.defaultChecked = true;
169
- input.checked = true;
170
- }
171
- segOpt.append(input, doc.createTextNode(opt.tab));
172
- seg.append(segOpt);
173
- }
174
- form.append(seg);
175
- const panels = el(doc, "div", "bp-choice__panels");
176
- for (const opt of options) {
177
- const panel = el(doc, "div", "bp-choice__panel");
178
- panel.dataset.value = opt.value;
179
- const h3 = el(doc, "h3");
180
- h3.textContent = opt.title;
181
- panel.append(h3);
182
- panel.append(bodyWithoutRationale(opt.el));
183
- const rationale = extractRationale(opt.el);
184
- if (rationale) panel.append(rationale);
185
- const actions = el(doc, "div", "bp-choice__actions");
186
- const adopt = el(doc, "label", "bp-choice__adopt");
187
- const commit = doc.createElement("input");
188
- commit.type = "radio";
189
- commit.name = pickName;
190
- commit.value = opt.value;
191
- commit.className = "bp-choice__commit";
192
- adopt.append(commit, doc.createTextNode(adoptLabel));
193
- actions.append(adopt);
194
- panel.append(actions);
195
- panels.append(panel);
196
- }
197
- form.append(panels);
198
- const style = el(doc, "style");
199
- style.textContent = scopedTabRules(`[data-bp-choice="${scopeId}"]`, viewName, pickName, titleOpts);
200
- form.prepend(style);
201
- form.append(
202
- buildFooter(
203
- doc,
204
- hint || "Switch tabs to preview · adopt to commit",
205
- reconsider
206
- )
207
- );
208
- }
209
- if (layout === "stack") {
210
- const stack = el(doc, "div", "bp-choice__stack");
211
- for (const opt of options) {
212
- const card = el(doc, "label", "bp-choice__card");
213
- const input = doc.createElement("input");
214
- input.type = "radio";
215
- input.name = pickName;
216
- input.value = opt.value;
217
- input.className = "bp-choice__pick";
218
- card.append(input);
219
- const head = el(doc, "div", "bp-choice__card-head");
220
- if (opt.optionLabel) head.append(label(doc, opt.optionLabel));
221
- const chosen = el(doc, "span", "bp-choice__tag bp-choice__tag--ink bp-choice__card-chosen");
222
- chosen.textContent = "✓ Chosen";
223
- const rejected = el(doc, "span", "bp-choice__tag bp-choice__tag--out bp-choice__card-rejected");
224
- rejected.textContent = "Not chosen";
225
- head.append(chosen, rejected);
226
- card.append(head);
227
- const h4 = el(doc, "h4");
228
- h4.textContent = opt.title;
229
- card.append(h4);
230
- card.append(bodyWithoutRationale(opt.el));
231
- const rationale = extractRationale(opt.el);
232
- if (rationale) card.append(rationale);
233
- stack.append(card);
234
- }
235
- form.append(stack);
236
- form.append(
237
- buildFooter(
238
- doc,
239
- hint || "Select a card to commit · rejected drafts stay readable below",
240
- reconsider
241
- )
242
- );
243
- }
244
- if (layout === "gallery") {
245
- const gallery = el(doc, "div", "bp-choice__gallery");
246
- for (const opt of options) {
247
- const mock = el(doc, "label", "bp-choice__mock");
248
- const input = doc.createElement("input");
249
- input.type = "radio";
250
- input.name = pickName;
251
- input.value = opt.value;
252
- input.className = "bp-choice__pick";
253
- mock.append(input);
254
- const frame = el(doc, "div", "bp-choice__mock-frame");
255
- const frameInner = frameContent(opt.el);
256
- if (frameInner) frame.append(frameInner);
257
- mock.append(frame);
258
- const cap = el(doc, "div", "bp-choice__mock-cap");
259
- const h4 = el(doc, "h4");
260
- h4.textContent = opt.caption;
261
- cap.append(h4);
262
- const chosen = el(doc, "span", "bp-choice__tag bp-choice__tag--ink bp-choice__mock-pick bp-choice__mock-chosen");
263
- chosen.textContent = "✓ Chosen";
264
- const rejected = el(doc, "span", "bp-choice__tag bp-choice__tag--out bp-choice__mock-pick bp-choice__mock-rejected");
265
- rejected.textContent = "Not chosen";
266
- cap.append(chosen, rejected);
267
- mock.append(cap);
268
- gallery.append(mock);
269
- }
270
- form.append(gallery);
271
- if (compareSummary && compareBody) {
272
- const details = el(doc, "details", "bp-choice__compare");
273
- const summary = el(doc, "summary");
274
- summary.textContent = compareSummary;
275
- details.append(summary);
276
- const body = el(doc, "div", "bp-choice__compare-body");
277
- body.append(compareBody.cloneNode(true));
278
- details.append(body);
279
- form.append(details);
324
+ this.choiceSource = {
325
+ layout,
326
+ options: readOptions(this),
327
+ verdictKicker: this.getAttribute("verdict") ?? "Chosen",
328
+ adoptLabel: this.getAttribute("adopt") ?? "Adopt this direction",
329
+ hint: this.getAttribute("hint") ?? "",
330
+ reconsider: this.getAttribute("reconsider") ?? "Reconsider",
331
+ compareSummary: this.getAttribute("compare"),
332
+ compareBody: this.querySelector(':scope > [slot="compare"]'),
333
+ resolvedValue: this.getAttribute("resolved")
334
+ };
335
+ this.choiceScopeId = nextId("bp-choice");
336
+ this.renderChoice();
337
+ dispatchChoiceEvent(this, "bp-choice-ready", this.getDecisionManifest());
338
+ }
339
+ getDecisionManifest() {
340
+ if (!this.choiceSource) return null;
341
+ return buildChoiceManifest(
342
+ this,
343
+ this.choiceSource.layout,
344
+ this.choiceSource.options,
345
+ this.choiceSource.resolvedValue
346
+ );
347
+ }
348
+ /**
349
+ * Apply ephemeral host-owned state without mutating authored attributes.
350
+ * Passing null restores the authored open/resolved rendering.
351
+ *
352
+ * @param {{ status: 'open' | 'locked', value: string | null, busy?: boolean, message?: string } | null} state
353
+ */
354
+ applyDecisionState(state) {
355
+ if (!this.choiceSource) return false;
356
+ if (state !== null) {
357
+ const valueExists = state.value === null || this.choiceSource.options.some((option) => option.value === state.value);
358
+ if (state.status !== "open" && state.status !== "locked" || !valueExists) return false;
359
+ if (state.status === "locked" && state.value === null) return false;
360
+ }
361
+ this.choiceHostState = state ? { status: state.status, value: state.value, busy: Boolean(state.busy), message: state.message ?? "" } : null;
362
+ this.renderChoice();
363
+ return true;
364
+ }
365
+ renderChoice() {
366
+ if (!this.choiceSource || !this.choiceScopeId) return;
367
+ const source = this.choiceSource;
368
+ const hostState = this.choiceHostState;
369
+ const lockedValue = hostState?.status === "locked" ? hostState.value : hostState ? null : source.resolvedValue;
370
+ if (lockedValue !== null) {
371
+ const root = buildResolvedChoice(this.ownerDocument, {
372
+ options: source.options,
373
+ verdictKicker: source.verdictKicker,
374
+ resolvedValue: lockedValue
375
+ });
376
+ if (hostState) {
377
+ const reset = appendLockedHostActions(this.ownerDocument, root, {
378
+ message: hostState.message,
379
+ reconsider: source.reconsider,
380
+ busy: hostState.busy
381
+ });
382
+ reset.addEventListener("click", () => {
383
+ dispatchChoiceEvent(this, "bp-choice-reconsider", this.getDecisionManifest());
384
+ });
280
385
  }
281
- form.append(
282
- buildFooter(doc, hint || "Select a mockup to track the decision", reconsider)
283
- );
386
+ this.replaceChildren(root);
387
+ return;
284
388
  }
389
+ const form = buildInteractiveChoice(this.ownerDocument, {
390
+ ...source,
391
+ scopeId: this.choiceScopeId,
392
+ initialValue: hostState?.value ?? null,
393
+ busy: hostState?.busy ?? false,
394
+ message: hostState?.message ?? ""
395
+ });
396
+ form.addEventListener("change", (event) => {
397
+ const input = event.target;
398
+ if (!(input instanceof HTMLInputElement) || !input.checked) return;
399
+ const isCommit = source.layout === "tabs" ? input.classList.contains("bp-choice__commit") : input.classList.contains("bp-choice__pick");
400
+ if (!isCommit) return;
401
+ const option = source.options.find((candidate) => candidate.value === input.value);
402
+ if (!option) return;
403
+ dispatchChoiceEvent(this, "bp-choice-commit", {
404
+ ...this.getDecisionManifest(),
405
+ value: option.value,
406
+ option: { value: option.value, label: option.title || option.tab || option.value, title: option.title }
407
+ });
408
+ });
409
+ form.addEventListener("reset", () => {
410
+ dispatchChoiceEvent(this, "bp-choice-reconsider", this.getDecisionManifest());
411
+ });
285
412
  this.replaceChildren(form);
286
413
  }
287
414
  };
@@ -756,11 +883,31 @@ function injectSidebar(doc, win) {
756
883
  searchHint.append(searchHintCmd, searchHintKey);
757
884
  searchField.append(searchIcon, searchLabel, searchHint);
758
885
  search.append(searchField);
759
- panel.append(search);
760
886
  const switcher = buildDocSwitcher(doc, win);
761
- if (switcher) panel.append(switcher);
887
+ const tocSwitcher = doc.createElement("details");
888
+ tocSwitcher.className = "bp-toc-switcher";
889
+ tocSwitcher.open = true;
890
+ const tocSummary = doc.createElement("summary");
891
+ tocSummary.className = "bp-toc-switcher__current sidebar-field";
892
+ tocSummary.setAttribute("aria-label", "Jump to section");
893
+ const tocLabel = doc.createElement("span");
894
+ tocLabel.className = "bp-toc-switcher__label";
895
+ const tocTitle = doc.createElement("span");
896
+ tocTitle.className = "bp-toc-switcher__title";
897
+ tocTitle.textContent = "Contents";
898
+ tocLabel.append(tocTitle);
899
+ const tocChevron = doc.createElement("span");
900
+ tocChevron.className = "bp-toc-switcher__chevron bp-transition-transform bp-duration-normal bp-ease-in-out";
901
+ tocChevron.setAttribute("aria-hidden", "true");
902
+ tocChevron.append(createChromeSvg(doc, DOC_CHEVRON_ICON));
903
+ tocSummary.append(tocLabel, tocChevron);
762
904
  const list = doc.createElement("ul");
763
- panel.append(list);
905
+ tocSwitcher.append(tocSummary, list);
906
+ const mobileHead = doc.createElement("div");
907
+ mobileHead.className = "sidebar-mobile-head";
908
+ mobileHead.append(tocSwitcher, search);
909
+ panel.append(mobileHead);
910
+ if (switcher) panel.append(switcher);
764
911
  nav.append(panel, buildSidebarFooter(doc));
765
912
  return nav;
766
913
  }
@@ -834,6 +981,7 @@ function setThemeAnimated(doc, win, theme) {
834
981
  transition = doc.startViewTransition(runUpdate);
835
982
  }
836
983
  if (!applied) runUpdate();
984
+ transition?.ready?.catch(clearSwitching);
837
985
  if (transition?.finished) {
838
986
  transition.finished.finally(clearSwitching).catch(clearSwitching);
839
987
  } else {
@@ -931,14 +1079,14 @@ function wireSearchPalette(doc, win) {
931
1079
  let panel = null;
932
1080
  let input = null;
933
1081
  let results = null;
934
- let footStatus = null;
1082
+ let actionDefs = [];
935
1083
  let entries = [];
936
1084
  let visible = [];
937
1085
  let selected = 0;
938
1086
  let returnFocus = null;
939
1087
  let prevOverflow = "";
940
1088
  const collectEntries = () => {
941
- const list = doc.querySelector(".bp-sidebar .bp-sidebar__panel > ul") ?? doc.querySelector(".bp-toc > ul");
1089
+ const list = doc.querySelector(".bp-sidebar .bp-sidebar__panel .bp-toc-switcher > ul") ?? doc.querySelector(".bp-sidebar .bp-sidebar__panel > ul") ?? doc.querySelector(".bp-toc > ul");
942
1090
  if (!list) return [];
943
1091
  const out = [];
944
1092
  let group = "";
@@ -961,16 +1109,57 @@ function wireSearchPalette(doc, win) {
961
1109
  }
962
1110
  return out;
963
1111
  };
964
- const keyHint = (caps, labelText) => {
965
- const span = doc.createElement("span");
966
- span.className = "docs-search__key";
967
- for (const cap of caps) {
968
- const kbd = doc.createElement("kbd");
969
- kbd.textContent = cap;
970
- span.append(kbd);
971
- }
972
- span.append(doc.createTextNode(` ${labelText}`));
973
- return span;
1112
+ const modGlyph = isMacLike ? "⌘" : "⌃";
1113
+ const matchesShortcut = (event, key) => {
1114
+ const mod = isMacLike ? event.metaKey : event.ctrlKey;
1115
+ if (!mod || !event.shiftKey) return false;
1116
+ return event.key.toLowerCase() === key.toLowerCase();
1117
+ };
1118
+ const buildActionDefs = () => {
1119
+ const defs = [];
1120
+ if (doc.querySelector(".theme-toggle")) {
1121
+ defs.push({
1122
+ label: "Toggle theme",
1123
+ icon: THEME_MOON_ICON,
1124
+ chip: `${modGlyph}⇧L`,
1125
+ shortcut: "l",
1126
+ run: () => doc.querySelector(".theme-toggle")?.click()
1127
+ });
1128
+ }
1129
+ if (doc.querySelector(".sidebar-toggle")) {
1130
+ defs.push({
1131
+ label: "Toggle sidebar",
1132
+ icon: SIDEBAR_COLLAPSE_ICON,
1133
+ chip: `${modGlyph}⇧B`,
1134
+ shortcut: "b",
1135
+ run: () => doc.querySelector(".sidebar-toggle")?.click()
1136
+ });
1137
+ }
1138
+ return defs;
1139
+ };
1140
+ const buildAction = (def) => {
1141
+ const button = doc.createElement("button");
1142
+ button.type = "button";
1143
+ button.className = "docs-search__action bp-transition-colors bp-ease";
1144
+ const icon = createChromeSvg(doc, def.icon, 16);
1145
+ icon.classList.add("docs-search__action-icon");
1146
+ const label2 = doc.createElement("span");
1147
+ label2.className = "docs-search__action-label";
1148
+ label2.textContent = def.label;
1149
+ button.append(icon, label2);
1150
+ if (def.chip) {
1151
+ const chip = doc.createElement("kbd");
1152
+ chip.className = "docs-search__chip";
1153
+ for (const glyph of Array.from(def.chip)) {
1154
+ const key = doc.createElement("span");
1155
+ key.textContent = glyph;
1156
+ chip.append(key);
1157
+ }
1158
+ button.append(chip);
1159
+ }
1160
+ button.addEventListener("click", () => def.run(button));
1161
+ def.button = button;
1162
+ return button;
974
1163
  };
975
1164
  const highlight = (text, q) => {
976
1165
  if (!q) return [doc.createTextNode(text)];
@@ -1000,6 +1189,11 @@ function wireSearchPalette(doc, win) {
1000
1189
  });
1001
1190
  const mainCol = doc.createElement("span");
1002
1191
  mainCol.className = "docs-search__row-main";
1192
+ const iconBox = doc.createElement("span");
1193
+ iconBox.className = "docs-search__icon-box";
1194
+ iconBox.setAttribute("aria-hidden", "true");
1195
+ iconBox.textContent = "#";
1196
+ mainCol.append(iconBox);
1003
1197
  if (entry.crumb) {
1004
1198
  const crumb = doc.createElement("span");
1005
1199
  crumb.className = "docs-search__crumb";
@@ -1008,11 +1202,7 @@ function wireSearchPalette(doc, win) {
1008
1202
  }
1009
1203
  const title = doc.createElement("span");
1010
1204
  title.className = "docs-search__title";
1011
- const hash = doc.createElement("span");
1012
- hash.className = "docs-search__hash";
1013
- hash.setAttribute("aria-hidden", "true");
1014
- hash.textContent = "#";
1015
- title.append(hash, ...highlight(entry.title, q));
1205
+ title.append(...highlight(entry.title, q));
1016
1206
  mainCol.append(title);
1017
1207
  const enter = doc.createElement("kbd");
1018
1208
  enter.className = "docs-search__enter";
@@ -1020,19 +1210,22 @@ function wireSearchPalette(doc, win) {
1020
1210
  row.append(mainCol, enter);
1021
1211
  return row;
1022
1212
  };
1023
- const buildEmpty = (query) => {
1213
+ const buildEmpty = () => {
1024
1214
  const wrap = doc.createElement("div");
1025
1215
  wrap.className = "docs-search__empty";
1026
- const iconWrap = doc.createElement("span");
1027
- iconWrap.className = "docs-search__empty-icon";
1028
- iconWrap.append(createChromeSvg(doc, SEARCH_ICON, 28));
1029
- const title = doc.createElement("span");
1216
+ const center = doc.createElement("div");
1217
+ center.className = "docs-search__empty-center";
1218
+ const copy = doc.createElement("div");
1219
+ copy.className = "docs-search__empty-copy";
1220
+ const title = doc.createElement("p");
1030
1221
  title.className = "docs-search__empty-title";
1031
- title.textContent = `No results for “${(query || "").trim()}”`;
1032
- const note = doc.createElement("span");
1222
+ title.textContent = "No results found";
1223
+ const note = doc.createElement("p");
1033
1224
  note.className = "docs-search__empty-note";
1034
- note.textContent = "Try a different term, or check your spelling.";
1035
- wrap.append(iconWrap, title, note);
1225
+ note.textContent = "Try searching with different keywords";
1226
+ copy.append(title, note);
1227
+ center.append(copy);
1228
+ wrap.append(center);
1036
1229
  return wrap;
1037
1230
  };
1038
1231
  const applySelection = () => {
@@ -1060,8 +1253,7 @@ function wireSearchPalette(doc, win) {
1060
1253
  selected = 0;
1061
1254
  results.replaceChildren();
1062
1255
  if (visible.length === 0) {
1063
- results.append(buildEmpty(query));
1064
- footStatus.textContent = "0 results";
1256
+ results.append(buildEmpty());
1065
1257
  input.removeAttribute("aria-activedescendant");
1066
1258
  return;
1067
1259
  }
@@ -1075,9 +1267,14 @@ function wireSearchPalette(doc, win) {
1075
1267
  results.append(group);
1076
1268
  visible.forEach((entry, index) => results.append(buildRow(entry, index, q)));
1077
1269
  applySelection();
1078
- footStatus.textContent = q ? `${visible.length} result${visible.length === 1 ? "" : "s"}` : "Jump to a section";
1079
1270
  };
1080
1271
  const onKeydown = (event) => {
1272
+ const action = actionDefs.find((def) => matchesShortcut(event, def.shortcut));
1273
+ if (action) {
1274
+ event.preventDefault();
1275
+ action.run(action.button);
1276
+ return;
1277
+ }
1081
1278
  if (event.key === "Escape") {
1082
1279
  event.preventDefault();
1083
1280
  close();
@@ -1088,6 +1285,7 @@ function wireSearchPalette(doc, win) {
1088
1285
  event.preventDefault();
1089
1286
  move(-1);
1090
1287
  } else if (event.key === "Enter") {
1288
+ if (event.target?.closest?.(".docs-search__action")) return;
1091
1289
  const row = results.querySelector(
1092
1290
  `.docs-search__row[data-index="${selected}"]`
1093
1291
  );
@@ -1125,12 +1323,12 @@ function wireSearchPalette(doc, win) {
1125
1323
  input = doc.createElement("input");
1126
1324
  input.className = "docs-search__input";
1127
1325
  input.type = "search";
1128
- input.placeholder = "Search the docs…";
1326
+ input.placeholder = "Search the blueprint…";
1129
1327
  input.setAttribute("role", "combobox");
1130
1328
  input.setAttribute("aria-autocomplete", "list");
1131
1329
  input.setAttribute("aria-controls", "docs-search-listbox");
1132
1330
  input.setAttribute("aria-expanded", "false");
1133
- input.setAttribute("aria-label", "Search the docs");
1331
+ input.setAttribute("aria-label", "Search the blueprint");
1134
1332
  input.autocomplete = "off";
1135
1333
  input.spellcheck = false;
1136
1334
  const esc = doc.createElement("span");
@@ -1144,15 +1342,11 @@ function wireSearchPalette(doc, win) {
1144
1342
  results.setAttribute("aria-label", "Search results");
1145
1343
  const foot = doc.createElement("div");
1146
1344
  foot.className = "docs-search__foot";
1147
- footStatus = doc.createElement("span");
1148
- const keys = doc.createElement("span");
1149
- keys.className = "docs-search__keys";
1150
- keys.append(
1151
- keyHint(["↑", "↓"], "navigate"),
1152
- keyHint(["↵"], "open"),
1153
- keyHint(["esc"], "close")
1154
- );
1155
- foot.append(footStatus, keys);
1345
+ const actions = doc.createElement("div");
1346
+ actions.className = "docs-search__actions";
1347
+ actionDefs = buildActionDefs();
1348
+ for (const def of actionDefs) actions.append(buildAction(def));
1349
+ foot.append(actions);
1156
1350
  panel.append(field, results, foot);
1157
1351
  overlay.append(panel);
1158
1352
  (doc.body || doc.documentElement).append(overlay);
@@ -1393,7 +1587,7 @@ function initializeBlueprintRuntime(doc = document, win = window) {
1393
1587
  if (!list || !listItem) return true;
1394
1588
  const container = link.closest(".bp-sidebar, .bp-toc, .sidebar");
1395
1589
  if (!container) return true;
1396
- const topList = container.querySelector(":scope > .bp-sidebar__panel > ul") ?? container.querySelector(":scope > ul");
1590
+ const topList = container.querySelector(":scope > .bp-sidebar__panel .bp-toc-switcher > ul") ?? container.querySelector(":scope > .bp-sidebar__panel > ul") ?? container.querySelector(":scope > ul");
1397
1591
  return list === topList;
1398
1592
  };
1399
1593
  const entries = [...doc.querySelectorAll('.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"]')].filter(isPrimaryNavLink).map((link) => ({
@@ -1405,6 +1599,14 @@ function initializeBlueprintRuntime(doc = document, win = window) {
1405
1599
  );
1406
1600
  let scheduled = false;
1407
1601
  let scrollingTo = null;
1602
+ const tocSwitchers = [...doc.querySelectorAll(".bp-toc-switcher")];
1603
+ const tocSwitcherTitles = [...doc.querySelectorAll(".bp-toc-switcher__title")];
1604
+ const railColumnQuery = win.matchMedia("(min-width: 861px)");
1605
+ const syncTocSwitcherOpen = () => {
1606
+ for (const sw of tocSwitchers) sw.open = railColumnQuery.matches;
1607
+ };
1608
+ syncTocSwitcherOpen();
1609
+ railColumnQuery.addEventListener("change", syncTocSwitcherOpen);
1408
1610
  const placeMarker = (link) => {
1409
1611
  const container = link.closest(".bp-sidebar, .bp-toc, .sidebar");
1410
1612
  const list = container?.querySelector(":scope > ul") ?? container?.querySelector("ul");
@@ -1412,18 +1614,59 @@ function initializeBlueprintRuntime(doc = document, win = window) {
1412
1614
  list.style.setProperty("--bp-toc-y", `${link.offsetTop}px`);
1413
1615
  list.style.setProperty("--bp-toc-h", `${link.offsetHeight}px`);
1414
1616
  };
1617
+ const revealInRail = (link) => {
1618
+ const panel = link.closest(".bp-sidebar__panel");
1619
+ if (!panel || panel.scrollHeight <= panel.clientHeight) return;
1620
+ const style = win.getComputedStyle(panel);
1621
+ const padTop = Number.parseFloat(style.scrollPaddingTop) || 0;
1622
+ const padBottom = Number.parseFloat(style.scrollPaddingBottom) || 0;
1623
+ const panelRect = panel.getBoundingClientRect();
1624
+ const linkRect = link.getBoundingClientRect();
1625
+ const safeTop = panelRect.top + padTop;
1626
+ const safeBottom = panelRect.bottom - padBottom;
1627
+ let delta = 0;
1628
+ if (linkRect.top < safeTop) delta = linkRect.top - safeTop;
1629
+ else if (linkRect.bottom > safeBottom) delta = linkRect.bottom - safeBottom;
1630
+ if (delta === 0) return;
1631
+ panel.scrollBy({
1632
+ top: delta,
1633
+ behavior: prefersReducedMotion() ? "auto" : "smooth"
1634
+ });
1635
+ };
1636
+ let lastCurrent = null;
1415
1637
  const setCurrent = (section) => {
1638
+ const changed = section !== lastCurrent;
1639
+ let activeLabel = "";
1416
1640
  for (const item of entries) {
1417
1641
  if (section && item.section === section) {
1418
1642
  item.link.setAttribute("aria-current", "location");
1419
1643
  placeMarker(item.link);
1644
+ if (changed) revealInRail(item.link);
1645
+ if (!activeLabel) activeLabel = item.link.textContent.trim();
1420
1646
  } else {
1421
1647
  item.link.removeAttribute("aria-current");
1422
1648
  }
1423
1649
  }
1650
+ lastCurrent = section;
1651
+ if (changed) {
1652
+ const summaryLabel = activeLabel || "Contents";
1653
+ for (const title of tocSwitcherTitles) title.textContent = summaryLabel;
1654
+ }
1655
+ };
1656
+ const mobileHead = doc.querySelector(".sidebar-mobile-head");
1657
+ const contentsRail = doc.querySelector("#bp-contents-rail");
1658
+ const syncHeadDivider = () => {
1659
+ if (!mobileHead || !contentsRail) return;
1660
+ if (win.getComputedStyle(mobileHead).position !== "fixed") {
1661
+ mobileHead.removeAttribute("data-bp-head-stuck");
1662
+ return;
1663
+ }
1664
+ const stuck = contentsRail.getBoundingClientRect().bottom <= mobileHead.getBoundingClientRect().bottom + 0.5;
1665
+ mobileHead.toggleAttribute("data-bp-head-stuck", stuck);
1424
1666
  };
1425
1667
  const update = () => {
1426
1668
  scheduled = false;
1669
+ syncHeadDivider();
1427
1670
  if (progress) {
1428
1671
  const max = scrollHeight() - clientHeight();
1429
1672
  progress.style.transform = `scaleX(${max > 0 ? scrollTop() / max : 0})`;
@@ -1505,6 +1748,9 @@ function initializeBlueprintRuntime(doc = document, win = window) {
1505
1748
  event.preventDefault();
1506
1749
  if (win.location.hash !== link.hash) win.history.pushState(null, "", link.hash);
1507
1750
  if (entries.some(({ section: target }) => target === section)) setCurrent(section);
1751
+ if (!railColumnQuery.matches) {
1752
+ for (const sw of tocSwitchers) sw.open = false;
1753
+ }
1508
1754
  scrollToSection(section);
1509
1755
  });
1510
1756
  let scrollSource = shellScroller ?? win;
@@ -1765,7 +2011,7 @@ function renderContentsTree(doc, tree) {
1765
2011
  function findSidebarNavLists(doc) {
1766
2012
  const lists = [];
1767
2013
  for (const nav of doc.querySelectorAll(".bp-sidebar:not([data-bp-nav-manual])")) {
1768
- const list = nav.querySelector(":scope > .bp-sidebar__panel > ul") ?? nav.querySelector(":scope > ul");
2014
+ const list = nav.querySelector(":scope > .bp-sidebar__panel .bp-toc-switcher > ul") ?? nav.querySelector(":scope > .bp-sidebar__panel > ul") ?? nav.querySelector(":scope > ul");
1769
2015
  if (list) lists.push(list);
1770
2016
  }
1771
2017
  for (const nav of doc.querySelectorAll(".bp-toc:not([data-bp-nav-manual])")) {