@obvi/blueprint 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  };
@@ -4286,10 +4286,16 @@
4286
4286
  margin: 0;
4287
4287
  }
4288
4288
  /* Expanded mock floats fullscreen (position: fixed): collapse the host so
4289
- no empty dotted mat is left behind in the document flow. */
4289
+ no empty dotted mat is left behind in the document flow, and clear any
4290
+ host clipping / board paint so browsers that clip fixed descendants
4291
+ through overflow-hidden ancestors still show the overlay fullscreen. */
4290
4292
  :where(bp-mock:has(.bp-mock.is-expanded)) {
4291
4293
  margin: 0;
4292
4294
  padding: 0;
4295
+ overflow: visible;
4296
+ border-radius: 0;
4297
+ background-color: oklch(1 0 0 / 0);
4298
+ background-image: none;
4293
4299
  }
4294
4300
 
4295
4301
  /* Single-surface specimens read as ONE continuous paper card on the mat;