@validation-os/dashboard 0.2.0 → 0.4.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/index.js CHANGED
@@ -10,6 +10,15 @@ var REGISTER_LABEL = {
10
10
  glossary: "Glossary",
11
11
  people: "People"
12
12
  };
13
+ var REGISTER_SINGULAR = {
14
+ assumptions: "Assumption",
15
+ experiments: "Experiment",
16
+ readings: "Reading",
17
+ goals: "Goal",
18
+ decisions: "Decision",
19
+ glossary: "Glossary term",
20
+ people: "Person"
21
+ };
13
22
  var REGISTER_ORDER = [
14
23
  "assumptions",
15
24
  "experiments",
@@ -234,30 +243,29 @@ function RegisterTable({
234
243
  }
235
244
 
236
245
  // src/record-drawer.tsx
246
+ import { useEffect as useEffect3, useState as useState2 } from "react";
247
+
248
+ // src/drawer-shell.tsx
237
249
  import { useEffect, useRef } from "react";
238
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
239
- var META_FIELDS = /* @__PURE__ */ new Set(["id", "version", "createdAt", "updatedAt", "derived"]);
240
- function RecordDrawer({
241
- register,
242
- record,
243
- loading,
244
- error,
250
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
251
+ function DrawerShell({
245
252
  open,
246
- onClose
253
+ onClose,
254
+ ariaLabel,
255
+ scroll = true,
256
+ children
247
257
  }) {
248
- const closeButtonRef = useRef(null);
258
+ const panelRef = useRef(null);
249
259
  useEffect(() => {
250
260
  if (!open) return;
251
261
  const onKey = (e) => {
252
262
  if (e.key === "Escape") onClose();
253
263
  };
254
264
  document.addEventListener("keydown", onKey);
255
- closeButtonRef.current?.focus();
265
+ panelRef.current?.focus();
256
266
  return () => document.removeEventListener("keydown", onKey);
257
267
  }, [open, onClose]);
258
268
  if (!open) return null;
259
- const derived = record && record.derived && typeof record.derived === "object" ? record.derived : null;
260
- const fields = record ? Object.keys(record).filter((k) => !META_FIELDS.has(k)) : [];
261
269
  return /* @__PURE__ */ jsxs3("div", { className: "fixed inset-0 z-50 flex justify-end", children: [
262
270
  /* @__PURE__ */ jsx3(
263
271
  "button",
@@ -268,66 +276,134 @@ function RecordDrawer({
268
276
  className: "absolute inset-0 bg-neutral-950/30 backdrop-blur-sm"
269
277
  }
270
278
  ),
271
- /* @__PURE__ */ jsxs3(
272
- "aside",
279
+ /* @__PURE__ */ jsx3(
280
+ "div",
273
281
  {
282
+ ref: panelRef,
274
283
  role: "dialog",
275
284
  "aria-modal": "true",
276
- "aria-label": `${REGISTER_LABEL[register]} record`,
277
- className: "relative flex h-full w-full max-w-md flex-col overflow-y-auto border-l border-neutral-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-950",
278
- children: [
279
- /* @__PURE__ */ jsxs3("header", { className: "flex items-start justify-between gap-4 border-b border-neutral-200 p-5 dark:border-neutral-800", children: [
280
- /* @__PURE__ */ jsxs3("div", { children: [
281
- /* @__PURE__ */ jsx3("p", { className: "text-xs font-medium uppercase tracking-wide text-neutral-400", children: REGISTER_LABEL[register] }),
282
- /* @__PURE__ */ jsx3("h2", { className: "mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-50", children: record ? primaryLabel(record) : loading ? "Loading\u2026" : "\u2014" })
283
- ] }),
284
- /* @__PURE__ */ jsx3(
285
- "button",
286
- {
287
- ref: closeButtonRef,
288
- type: "button",
289
- onClick: onClose,
290
- className: "rounded-md px-2 py-1 text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-900",
291
- children: "Close"
292
- }
293
- )
294
- ] }),
295
- /* @__PURE__ */ jsx3("div", { className: "flex-1 p-5", children: loading ? /* @__PURE__ */ jsx3("p", { className: "text-sm text-neutral-500", children: "Loading record\u2026" }) : error ? /* @__PURE__ */ jsx3("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : !record ? /* @__PURE__ */ jsx3("p", { className: "text-sm text-neutral-500", children: "No record." }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
296
- derived ? /* @__PURE__ */ jsxs3("section", { className: "mb-6", children: [
297
- /* @__PURE__ */ jsx3("h3", { className: "mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400", children: "Computed \xB7 not editable" }),
298
- /* @__PURE__ */ jsx3("dl", { className: "grid grid-cols-3 gap-3", children: Object.entries(derived).map(([key, value]) => /* @__PURE__ */ jsxs3(
299
- "div",
300
- {
301
- className: "rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900",
302
- children: [
303
- /* @__PURE__ */ jsx3("dt", { className: "text-xs text-neutral-500 dark:text-neutral-400", children: derivedLabel(key) }),
304
- /* @__PURE__ */ jsx3("dd", { className: "mt-1 text-xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-50", children: formatValue(value) })
305
- ]
306
- },
307
- key
308
- )) })
309
- ] }) : null,
310
- /* @__PURE__ */ jsx3("dl", { className: "divide-y divide-neutral-100 dark:divide-neutral-900", children: fields.map((key) => /* @__PURE__ */ jsxs3("div", { className: "grid grid-cols-3 gap-3 py-2.5", children: [
311
- /* @__PURE__ */ jsx3("dt", { className: "text-sm text-neutral-500 dark:text-neutral-400", children: fieldLabel(key) }),
312
- /* @__PURE__ */ jsx3("dd", { className: "col-span-2 text-sm text-neutral-800 dark:text-neutral-200", children: formatValue(record[key]) })
313
- ] }, key)) })
314
- ] }) }),
315
- record ? /* @__PURE__ */ jsxs3("footer", { className: "border-t border-neutral-200 p-5 text-xs text-neutral-400 dark:border-neutral-800", children: [
316
- record.id,
317
- " \xB7 version ",
318
- String(record.version),
319
- " \xB7 updated",
320
- " ",
321
- formatValue(record.updatedAt)
322
- ] }) : null
323
- ]
285
+ "aria-label": ariaLabel,
286
+ tabIndex: -1,
287
+ className: `relative flex h-full w-full max-w-md flex-col border-l border-neutral-200 bg-white shadow-xl outline-none dark:border-neutral-800 dark:bg-neutral-950 ${scroll ? "overflow-y-auto" : "overflow-hidden"}`,
288
+ children
324
289
  }
325
290
  )
326
291
  ] });
327
292
  }
328
293
 
329
- // src/register-browser.tsx
330
- import { useState as useState2 } from "react";
294
+ // src/edit.ts
295
+ var CONFLICT_MESSAGE = "Someone edited this while you had it open \u2014 your changes are safe, take a look before saving again.";
296
+ var t = (key, label) => ({ key, label, kind: "text" });
297
+ var area = (key, label) => ({
298
+ key,
299
+ label,
300
+ kind: "textarea"
301
+ });
302
+ var num = (key, label) => ({
303
+ key,
304
+ label,
305
+ kind: "number",
306
+ nullable: true
307
+ });
308
+ var sel = (key, label, options, nullable = false) => ({ key, label, kind: "select", options, nullable });
309
+ var READING_RUNGS = [
310
+ "Opinion",
311
+ "Pitch-deck reaction",
312
+ "Anecdotal",
313
+ "Desk research",
314
+ "Survey at scale",
315
+ "Prototype usage",
316
+ "Signed intent",
317
+ "Paying users"
318
+ ];
319
+ var SOURCE_QUALITY = ["1", "0.7", "0.5"];
320
+ var EDITORS = {
321
+ assumptions: [
322
+ t("Title", "Assumption"),
323
+ area("Description", "Description"),
324
+ num("Impact", "Impact"),
325
+ sel("Status", "Status", ["Draft", "Live", "Invalidated"]),
326
+ t("Lens", "Lens"),
327
+ area("5 Whys", "5 Whys"),
328
+ area("Metric for truth", "Metric for truth"),
329
+ area("Scoring justification", "Scoring justification")
330
+ // `moot` is deliberately not editable here — mooting an assumption is a
331
+ // gated business action (see core `relations.ts`), not a free-form toggle.
332
+ ],
333
+ experiments: [
334
+ t("Title", "Experiment"),
335
+ t("Instrument", "Instrument"),
336
+ sel("Feasibility", "Feasibility", ["High", "Medium", "Low"], true),
337
+ sel("Status", "Status", ["Running", "Closed"]),
338
+ sel(
339
+ "closureReason",
340
+ "Closure reason",
341
+ ["Completed", "Early-stop", "Kill"],
342
+ true
343
+ ),
344
+ t("Date", "Date")
345
+ ],
346
+ readings: [
347
+ t("Title", "Reading"),
348
+ t("Source", "Source"),
349
+ sel("Rung", "Rung", READING_RUNGS),
350
+ sel("Result", "Result", ["Validated", "Invalidated", "Inconclusive"]),
351
+ sel("Representativeness", "Representativeness", SOURCE_QUALITY),
352
+ sel("Credibility", "Credibility", SOURCE_QUALITY),
353
+ sel("magnitudeBand", "Magnitude band", ["Low", "Typical", "High"], true),
354
+ t("Date", "Date")
355
+ ],
356
+ goals: [
357
+ t("Title", "Goal"),
358
+ sel("Status", "Status", ["Draft", "Active", "Closed"]),
359
+ sel("Outcome", "Outcome", ["Achieved", "Missed", "Dropped"], true),
360
+ t("Date", "Date")
361
+ ],
362
+ decisions: [
363
+ t("Title", "Decision"),
364
+ sel("Status", "Status", ["Active", "Provisional", "Superseded", "Reversed"])
365
+ ],
366
+ glossary: [
367
+ t("Title", "Term"),
368
+ sel("Status", "Status", ["Active", "Provisional", "Superseded"])
369
+ ],
370
+ people: [t("Name", "Name"), t("Role", "Role"), t("Segment", "Segment")]
371
+ };
372
+ function editableFields(register) {
373
+ return EDITORS[register];
374
+ }
375
+ function draftFrom(register, record) {
376
+ const draft = {};
377
+ for (const f of editableFields(register)) {
378
+ const value = record[f.key];
379
+ draft[f.key] = value === null || value === void 0 ? "" : String(value);
380
+ }
381
+ return draft;
382
+ }
383
+ function coerce(field, raw) {
384
+ const str2 = String(raw);
385
+ if (field.kind === "number") {
386
+ if (str2.trim() === "") return null;
387
+ const n = Number(str2);
388
+ return Number.isNaN(n) ? null : n;
389
+ }
390
+ if (str2 === "" && (field.kind === "select" || field.nullable)) return null;
391
+ return str2;
392
+ }
393
+ function norm(value) {
394
+ return value === void 0 || value === "" ? null : value;
395
+ }
396
+ function buildPatch(register, original, draft) {
397
+ const patch = { version: original.version };
398
+ for (const field of editableFields(register)) {
399
+ const next = coerce(field, draft[field.key] ?? "");
400
+ if (norm(next) !== norm(original[field.key])) patch[field.key] = next;
401
+ }
402
+ return patch;
403
+ }
404
+ function hasEdits(register, original, draft) {
405
+ return Object.keys(buildPatch(register, original, draft)).length > 1;
406
+ }
331
407
 
332
408
  // src/use-records.ts
333
409
  import { useCallback, useEffect as useEffect2, useState } from "react";
@@ -362,7 +438,7 @@ function useJsonResource(url) {
362
438
  live = false;
363
439
  };
364
440
  }, [url, tick]);
365
- const refresh = useCallback(() => setTick((t) => t + 1), []);
441
+ const refresh = useCallback(() => setTick((t2) => t2 + 1), []);
366
442
  return { ...state, refresh };
367
443
  }
368
444
  function useList(register, basePath = "/api") {
@@ -373,26 +449,1020 @@ function useList(register, basePath = "/api") {
373
449
  }
374
450
  function useRecord(register, id, basePath = "/api") {
375
451
  const url = id ? `${basePath}/${register}/${encodeURIComponent(id)}` : null;
376
- const { data, loading, error } = useJsonResource(url);
377
- return { record: data, loading, error };
452
+ const { data, loading, error, refresh } = useJsonResource(url);
453
+ return { record: data, loading, error, refresh };
454
+ }
455
+ var SAVE_FAILED = "Couldn't save your changes \u2014 please try again.";
456
+ function interpretSave(status, body) {
457
+ if (status >= 200 && status < 300) {
458
+ return { ok: true, record: pickData(body) };
459
+ }
460
+ const message = body?.message;
461
+ const text = typeof message === "string" ? message : null;
462
+ if (status === 409) {
463
+ return { ok: false, conflict: true, message: text ?? CONFLICT_MESSAGE };
464
+ }
465
+ return { ok: false, conflict: false, message: text ?? SAVE_FAILED };
466
+ }
467
+ function useUpdate(register, basePath = "/api") {
468
+ const [saving, setSaving] = useState(false);
469
+ const [conflict, setConflict] = useState(null);
470
+ const [error, setError] = useState(null);
471
+ const save = useCallback(
472
+ async (id, patch) => {
473
+ setSaving(true);
474
+ setConflict(null);
475
+ setError(null);
476
+ try {
477
+ const res = await fetch(
478
+ `${basePath}/${register}/${encodeURIComponent(id)}`,
479
+ {
480
+ method: "PATCH",
481
+ headers: { "content-type": "application/json" },
482
+ body: JSON.stringify(patch)
483
+ }
484
+ );
485
+ const body = await res.json().catch(() => null);
486
+ const result = interpretSave(res.status, body);
487
+ if (!result.ok && result.conflict) setConflict(result.message);
488
+ else if (!result.ok) setError(result.message);
489
+ return result;
490
+ } catch (e) {
491
+ const message = e instanceof Error ? e.message : "Couldn't save your changes.";
492
+ setError(message);
493
+ return { ok: false, conflict: false, message };
494
+ } finally {
495
+ setSaving(false);
496
+ }
497
+ },
498
+ [register, basePath]
499
+ );
500
+ const reset = useCallback(() => {
501
+ setConflict(null);
502
+ setError(null);
503
+ }, []);
504
+ return { save, saving, conflict, error, reset };
505
+ }
506
+
507
+ // src/understanding.ts
508
+ import {
509
+ toReadingInput
510
+ } from "@validation-os/core";
511
+ import {
512
+ confidenceAttribution,
513
+ confidenceTrajectory,
514
+ experimentProgress,
515
+ isConcluded
516
+ } from "@validation-os/core/derivation";
517
+ function str(v) {
518
+ return typeof v === "string" && v !== "" ? v : null;
519
+ }
520
+ function testsAssumption(exp, assumptionId) {
521
+ const bars = exp.barLines;
522
+ if (bars?.some((b) => b.assumptionId === assumptionId)) return true;
523
+ const ids = exp.barLineAssumptionIds;
524
+ return ids?.includes(assumptionId) ?? false;
525
+ }
526
+ function buildUnderstanding(assumption, readings, experiments) {
527
+ const mine = readings.filter((r) => r.assumptionId === assumption.id);
528
+ const inputs = mine.map(toReadingInput);
529
+ const { confidence, movers } = confidenceAttribution(inputs);
530
+ const experimentsById = new Map(experiments.map((e) => [e.id, e]));
531
+ const moverByExperiment = new Map(
532
+ movers.filter((m) => m.kind === "experiment").map((m) => [m.experimentId, m])
533
+ );
534
+ const experimentIds = /* @__PURE__ */ new Set([
535
+ ...experiments.filter((e) => testsAssumption(e, assumption.id)).map((e) => e.id),
536
+ ...moverByExperiment.keys()
537
+ ]);
538
+ const experimentViews = [...experimentIds].map((id) => {
539
+ const exp = experimentsById.get(id);
540
+ const mover = moverByExperiment.get(id);
541
+ const bars = exp?.barLines ?? [];
542
+ const progress = bars.length ? experimentProgress(bars) : null;
543
+ const status = exp ? str(exp.Status) : null;
544
+ return {
545
+ experimentId: id,
546
+ title: exp ? str(exp.Title) : null,
547
+ status,
548
+ contribution: mover?.contribution ?? 0,
549
+ magnitude: mover?.magnitude ?? 0,
550
+ readingCount: mover?.readingCount ?? 0,
551
+ progress,
552
+ done: status === "Closed" || progress?.concluded === true
553
+ };
554
+ });
555
+ experimentViews.sort(
556
+ (a, b) => b.magnitude - a.magnitude || Number(a.done) - Number(b.done) || a.experimentId.localeCompare(b.experimentId)
557
+ );
558
+ const otherMovers = movers.filter((m) => m.kind !== "experiment").map((m) => ({
559
+ key: m.key,
560
+ kind: m.kind,
561
+ goalId: m.goalId,
562
+ contribution: m.contribution,
563
+ magnitude: m.magnitude,
564
+ readingCount: m.readingCount
565
+ }));
566
+ return {
567
+ confidence,
568
+ experiments: experimentViews,
569
+ otherMovers,
570
+ trajectory: confidenceTrajectory(inputs),
571
+ readingCount: inputs.filter((r) => isConcluded(r.result)).length
572
+ };
573
+ }
574
+
575
+ // src/understanding-panel.tsx
576
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
577
+ function UnderstandingPanel({
578
+ assumption,
579
+ basePath
580
+ }) {
581
+ const readings = useList("readings", basePath);
582
+ const experiments = useList("experiments", basePath);
583
+ if (readings.loading || experiments.loading) {
584
+ return /* @__PURE__ */ jsx4(Muted, { children: "Working out what moved the number\u2026" });
585
+ }
586
+ if (readings.error || experiments.error) {
587
+ return /* @__PURE__ */ jsx4(Muted, { children: "Couldn't load the evidence behind this number." });
588
+ }
589
+ const u = buildUnderstanding(
590
+ assumption,
591
+ readings.records ?? [],
592
+ experiments.records ?? []
593
+ );
594
+ if (u.experiments.length === 0 && u.otherMovers.length === 0) {
595
+ return /* @__PURE__ */ jsx4(Muted, { children: "No experiments or concluded readings yet \u2014 Confidence rests on the neutral prior alone." });
596
+ }
597
+ const maxMagnitude = Math.max(
598
+ ...u.experiments.map((e) => e.magnitude),
599
+ ...u.otherMovers.map((m) => m.magnitude),
600
+ 0.01
601
+ );
602
+ return /* @__PURE__ */ jsxs4("div", { className: "mt-2 space-y-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-900", children: [
603
+ u.experiments.length ? /* @__PURE__ */ jsxs4("section", { children: [
604
+ /* @__PURE__ */ jsx4(Heading, { children: "What's moving Confidence" }),
605
+ /* @__PURE__ */ jsx4("ul", { className: "space-y-2.5", children: u.experiments.map((e) => /* @__PURE__ */ jsxs4("li", { children: [
606
+ /* @__PURE__ */ jsx4(
607
+ PushRow,
608
+ {
609
+ label: e.title ?? `Experiment ${e.experimentId}`,
610
+ contribution: e.contribution,
611
+ magnitude: e.magnitude,
612
+ max: maxMagnitude
613
+ }
614
+ ),
615
+ /* @__PURE__ */ jsx4("p", { className: "mt-1 text-xs text-neutral-500 dark:text-neutral-400", children: experimentDetail(e) })
616
+ ] }, e.experimentId)) })
617
+ ] }) : null,
618
+ u.otherMovers.length ? /* @__PURE__ */ jsxs4("section", { children: [
619
+ /* @__PURE__ */ jsx4(Heading, { children: "Other evidence" }),
620
+ /* @__PURE__ */ jsx4("ul", { className: "space-y-2.5", children: u.otherMovers.map((m) => /* @__PURE__ */ jsx4("li", { children: /* @__PURE__ */ jsx4(
621
+ PushRow,
622
+ {
623
+ label: otherMoverLabel(m),
624
+ contribution: m.contribution,
625
+ magnitude: m.magnitude,
626
+ max: maxMagnitude
627
+ }
628
+ ) }, m.key)) })
629
+ ] }) : null,
630
+ /* @__PURE__ */ jsxs4("section", { children: [
631
+ /* @__PURE__ */ jsx4(Heading, { children: "Confidence over time" }),
632
+ /* @__PURE__ */ jsx4(Trajectory, { points: u.trajectory })
633
+ ] })
634
+ ] });
635
+ }
636
+ function PushRow({
637
+ label,
638
+ contribution,
639
+ magnitude,
640
+ max
641
+ }) {
642
+ const up = contribution >= 0;
643
+ const moving = magnitude > 0;
644
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
645
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-baseline justify-between gap-2", children: [
646
+ /* @__PURE__ */ jsx4("span", { className: "truncate text-sm text-neutral-800 dark:text-neutral-200", children: label }),
647
+ moving ? /* @__PURE__ */ jsxs4(
648
+ "span",
649
+ {
650
+ className: `shrink-0 text-sm font-semibold tabular-nums ${up ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"}`,
651
+ children: [
652
+ up ? "+" : "",
653
+ contribution
654
+ ]
655
+ }
656
+ ) : /* @__PURE__ */ jsx4("span", { className: "shrink-0 text-xs text-neutral-400", children: "no readings yet" })
657
+ ] }),
658
+ /* @__PURE__ */ jsx4("div", { className: "mt-1 h-1.5 rounded-full bg-neutral-200 dark:bg-neutral-800", children: /* @__PURE__ */ jsx4(
659
+ "div",
660
+ {
661
+ className: `h-full rounded-full ${up ? "bg-emerald-500" : "bg-rose-500"}`,
662
+ style: { width: `${magnitude / max * 100}%` }
663
+ }
664
+ ) })
665
+ ] });
666
+ }
667
+ function Trajectory({ points }) {
668
+ if (points.length < 2) {
669
+ return /* @__PURE__ */ jsx4("p", { className: "text-xs text-neutral-500 dark:text-neutral-400", children: points.length === 1 ? `One dated reading so far \u2014 Confidence ${points[0].confidence} on ${points[0].date}.` : "No dated readings yet to chart a trajectory." });
670
+ }
671
+ return /* @__PURE__ */ jsx4(Sparkline, { points });
672
+ }
673
+ var SPARK_W = 260;
674
+ var SPARK_H = 44;
675
+ function Sparkline({ points }) {
676
+ const n = points.length;
677
+ const x = (i) => i / (n - 1) * SPARK_W;
678
+ const y = (c) => (100 - c) / 200 * SPARK_H;
679
+ const path = points.map((p, i) => `${x(i)},${y(p.confidence)}`).join(" ");
680
+ const first = points[0];
681
+ const last = points[n - 1];
682
+ return /* @__PURE__ */ jsxs4("div", { children: [
683
+ /* @__PURE__ */ jsxs4(
684
+ "svg",
685
+ {
686
+ viewBox: `0 0 ${SPARK_W} ${SPARK_H}`,
687
+ className: "w-full",
688
+ role: "img",
689
+ "aria-label": `Confidence moved from ${first.confidence} to ${last.confidence}`,
690
+ children: [
691
+ /* @__PURE__ */ jsx4(
692
+ "line",
693
+ {
694
+ x1: 0,
695
+ x2: SPARK_W,
696
+ y1: y(0),
697
+ y2: y(0),
698
+ className: "stroke-neutral-300 dark:stroke-neutral-700",
699
+ strokeWidth: 1,
700
+ strokeDasharray: "3 3"
701
+ }
702
+ ),
703
+ /* @__PURE__ */ jsx4(
704
+ "polyline",
705
+ {
706
+ points: path,
707
+ fill: "none",
708
+ className: "stroke-neutral-700 dark:stroke-neutral-200",
709
+ strokeWidth: 1.5,
710
+ strokeLinejoin: "round",
711
+ strokeLinecap: "round"
712
+ }
713
+ ),
714
+ /* @__PURE__ */ jsx4(
715
+ "circle",
716
+ {
717
+ cx: x(n - 1),
718
+ cy: y(last.confidence),
719
+ r: 2.5,
720
+ className: "fill-neutral-900 dark:fill-neutral-50"
721
+ }
722
+ )
723
+ ]
724
+ }
725
+ ),
726
+ /* @__PURE__ */ jsxs4("div", { className: "mt-1 flex justify-between text-[11px] text-neutral-400", children: [
727
+ /* @__PURE__ */ jsx4("span", { children: first.date }),
728
+ /* @__PURE__ */ jsxs4("span", { className: "tabular-nums text-neutral-600 dark:text-neutral-300", children: [
729
+ "now ",
730
+ last.confidence
731
+ ] })
732
+ ] })
733
+ ] });
734
+ }
735
+ function experimentDetail(e) {
736
+ const evidence = e.readingCount === 0 ? "No readings yet" : `${e.readingCount} reading${e.readingCount === 1 ? "" : "s"}`;
737
+ const p = e.progress;
738
+ if (e.done) {
739
+ return p ? `Concluded \xB7 ${p.total} of ${p.total} bars settled` : `Concluded \xB7 ${evidence}`;
740
+ }
741
+ if (!p || p.total === 0) return `${evidence} \xB7 no pre-registered bars`;
742
+ return `${evidence} \xB7 ${p.settled} of ${p.total} bars settled \xB7 ${p.toGo} to go`;
743
+ }
744
+ function otherMoverLabel(m) {
745
+ if (m.kind === "goal") return `Goal ${m.goalId ?? ""}`.trim();
746
+ return m.readingCount === 1 ? "A direct reading" : "Direct readings";
747
+ }
748
+ function Heading({ children }) {
749
+ return /* @__PURE__ */ jsx4("h4", { className: "mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400", children });
750
+ }
751
+ function Muted({ children }) {
752
+ return /* @__PURE__ */ jsx4("p", { className: "mt-2 rounded-lg bg-neutral-50 p-3 text-xs leading-relaxed text-neutral-600 dark:bg-neutral-900 dark:text-neutral-300", children });
753
+ }
754
+
755
+ // src/record-drawer.tsx
756
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
757
+ var META_FIELDS = /* @__PURE__ */ new Set(["id", "version", "createdAt", "updatedAt", "derived"]);
758
+ function RecordDrawer({
759
+ register,
760
+ record,
761
+ loading,
762
+ error,
763
+ open,
764
+ onClose,
765
+ basePath,
766
+ onChanged,
767
+ children
768
+ }) {
769
+ const [editing, setEditing] = useState2(false);
770
+ const [draft, setDraft] = useState2({});
771
+ const [baseline, setBaseline] = useState2(null);
772
+ const [why, setWhy] = useState2(false);
773
+ const { save, saving, conflict, error: saveError, reset } = useUpdate(
774
+ register,
775
+ basePath
776
+ );
777
+ const recordId = record?.id ?? null;
778
+ useEffect3(() => {
779
+ setEditing(false);
780
+ setBaseline(null);
781
+ setWhy(false);
782
+ reset();
783
+ }, [recordId, reset]);
784
+ const derived = record && record.derived && typeof record.derived === "object" ? record.derived : null;
785
+ const fields = record ? Object.keys(record).filter((k) => !META_FIELDS.has(k)) : [];
786
+ function startEditing() {
787
+ if (!record) return;
788
+ setBaseline(record);
789
+ setDraft(draftFrom(register, record));
790
+ reset();
791
+ setEditing(true);
792
+ }
793
+ function cancelEditing() {
794
+ setEditing(false);
795
+ reset();
796
+ }
797
+ async function onSave() {
798
+ if (!record || !baseline) return;
799
+ const patch = buildPatch(register, baseline, draft);
800
+ patch.version = record.version;
801
+ if (Object.keys(patch).length <= 1) {
802
+ setEditing(false);
803
+ return;
804
+ }
805
+ const result = await save(record.id, patch);
806
+ if (result.ok) {
807
+ setEditing(false);
808
+ onChanged?.();
809
+ }
810
+ }
811
+ function reloadLatest() {
812
+ reset();
813
+ onChanged?.();
814
+ }
815
+ const setField = (key, value) => setDraft((d) => ({ ...d, [key]: value }));
816
+ return /* @__PURE__ */ jsxs5(
817
+ DrawerShell,
818
+ {
819
+ open,
820
+ onClose,
821
+ ariaLabel: `${REGISTER_LABEL[register]} record`,
822
+ children: [
823
+ /* @__PURE__ */ jsxs5("header", { className: "flex items-start justify-between gap-4 border-b border-neutral-200 p-5 dark:border-neutral-800", children: [
824
+ /* @__PURE__ */ jsxs5("div", { children: [
825
+ /* @__PURE__ */ jsx5("p", { className: "text-xs font-medium uppercase tracking-wide text-neutral-400", children: REGISTER_LABEL[register] }),
826
+ /* @__PURE__ */ jsx5("h2", { className: "mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-50", children: record ? primaryLabel(record) : loading ? "Loading\u2026" : "\u2014" })
827
+ ] }),
828
+ /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2", children: [
829
+ record && !editing ? /* @__PURE__ */ jsx5(
830
+ "button",
831
+ {
832
+ type: "button",
833
+ onClick: startEditing,
834
+ className: "rounded-md border border-neutral-300 px-2.5 py-1 text-sm font-medium text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-900",
835
+ children: "Edit"
836
+ }
837
+ ) : null,
838
+ /* @__PURE__ */ jsx5(
839
+ "button",
840
+ {
841
+ type: "button",
842
+ onClick: onClose,
843
+ className: "rounded-md px-2 py-1 text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-900",
844
+ children: "Close"
845
+ }
846
+ )
847
+ ] })
848
+ ] }),
849
+ /* @__PURE__ */ jsx5("div", { className: "flex-1 p-5", children: loading ? /* @__PURE__ */ jsx5("p", { className: "text-sm text-neutral-500", children: "Loading record\u2026" }) : error ? /* @__PURE__ */ jsx5("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : !record ? /* @__PURE__ */ jsx5("p", { className: "text-sm text-neutral-500", children: "No record." }) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
850
+ derived ? /* @__PURE__ */ jsxs5("section", { className: "mb-6", children: [
851
+ /* @__PURE__ */ jsx5("h3", { className: "mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400", children: "Computed \xB7 not editable" }),
852
+ /* @__PURE__ */ jsx5("dl", { className: "grid grid-cols-3 gap-3", children: Object.entries(derived).map(([key, value]) => /* @__PURE__ */ jsxs5(
853
+ "div",
854
+ {
855
+ className: "rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900",
856
+ children: [
857
+ /* @__PURE__ */ jsx5("dt", { className: "text-xs text-neutral-500 dark:text-neutral-400", children: derivedLabel(key) }),
858
+ /* @__PURE__ */ jsx5("dd", { className: "mt-1 text-xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-50", children: formatValue(value) })
859
+ ]
860
+ },
861
+ key
862
+ )) }),
863
+ "confidence" in derived ? /* @__PURE__ */ jsx5(
864
+ WhyReveal,
865
+ {
866
+ open: why,
867
+ onToggle: () => setWhy((w) => !w),
868
+ assumption: record,
869
+ basePath
870
+ }
871
+ ) : null
872
+ ] }) : null,
873
+ conflict ? /* @__PURE__ */ jsx5(ConflictBanner, { message: conflict, onReload: reloadLatest }) : null,
874
+ saveError ? /* @__PURE__ */ jsx5(
875
+ "p",
876
+ {
877
+ role: "alert",
878
+ className: "mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300",
879
+ children: saveError
880
+ }
881
+ ) : null,
882
+ editing ? /* @__PURE__ */ jsx5(
883
+ EditFields,
884
+ {
885
+ register,
886
+ draft,
887
+ onField: setField
888
+ }
889
+ ) : /* @__PURE__ */ jsx5("dl", { className: "divide-y divide-neutral-100 dark:divide-neutral-900", children: fields.map((key) => /* @__PURE__ */ jsxs5("div", { className: "grid grid-cols-3 gap-3 py-2.5", children: [
890
+ /* @__PURE__ */ jsx5("dt", { className: "text-sm text-neutral-500 dark:text-neutral-400", children: fieldLabel(key) }),
891
+ /* @__PURE__ */ jsx5("dd", { className: "col-span-2 text-sm text-neutral-800 dark:text-neutral-200", children: formatValue(record[key]) })
892
+ ] }, key)) })
893
+ ] }) }),
894
+ record && !loading && !error && !editing ? children : null,
895
+ record && editing ? /* @__PURE__ */ jsxs5("footer", { className: "flex items-center justify-end gap-2 border-t border-neutral-200 p-5 dark:border-neutral-800", children: [
896
+ /* @__PURE__ */ jsx5(
897
+ "button",
898
+ {
899
+ type: "button",
900
+ onClick: cancelEditing,
901
+ disabled: saving,
902
+ className: "rounded-md px-3 py-1.5 text-sm text-neutral-600 hover:bg-neutral-100 disabled:opacity-50 dark:text-neutral-300 dark:hover:bg-neutral-900",
903
+ children: "Cancel"
904
+ }
905
+ ),
906
+ /* @__PURE__ */ jsx5(
907
+ "button",
908
+ {
909
+ type: "button",
910
+ onClick: onSave,
911
+ disabled: saving,
912
+ className: "rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-700 disabled:opacity-50 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300",
913
+ children: saving ? "Saving\u2026" : "Save"
914
+ }
915
+ )
916
+ ] }) : record ? /* @__PURE__ */ jsxs5("footer", { className: "border-t border-neutral-200 p-5 text-xs text-neutral-400 dark:border-neutral-800", children: [
917
+ record.id,
918
+ " \xB7 updated ",
919
+ formatValue(record.updatedAt)
920
+ ] }) : null
921
+ ]
922
+ }
923
+ );
924
+ }
925
+ function WhyReveal({
926
+ open,
927
+ onToggle,
928
+ assumption,
929
+ basePath
930
+ }) {
931
+ return /* @__PURE__ */ jsxs5("div", { className: "mt-3", children: [
932
+ /* @__PURE__ */ jsx5(
933
+ "button",
934
+ {
935
+ type: "button",
936
+ onClick: onToggle,
937
+ "aria-expanded": open,
938
+ className: "text-xs font-medium text-neutral-500 underline underline-offset-2 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100",
939
+ children: "Why?"
940
+ }
941
+ ),
942
+ open ? /* @__PURE__ */ jsx5(UnderstandingPanel, { assumption, basePath }) : null
943
+ ] });
944
+ }
945
+ function ConflictBanner({
946
+ message,
947
+ onReload
948
+ }) {
949
+ return /* @__PURE__ */ jsxs5(
950
+ "div",
951
+ {
952
+ role: "alert",
953
+ className: "mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950/40",
954
+ children: [
955
+ /* @__PURE__ */ jsx5("p", { className: "text-sm text-amber-800 dark:text-amber-200", children: message }),
956
+ /* @__PURE__ */ jsx5(
957
+ "button",
958
+ {
959
+ type: "button",
960
+ onClick: onReload,
961
+ className: "mt-2 rounded-md border border-amber-300 px-2.5 py-1 text-xs font-medium text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40",
962
+ children: "Reload the latest"
963
+ }
964
+ )
965
+ ]
966
+ }
967
+ );
968
+ }
969
+ function EditFields({
970
+ register,
971
+ draft,
972
+ onField
973
+ }) {
974
+ return /* @__PURE__ */ jsx5("div", { className: "space-y-4", children: editableFields(register).map((field) => /* @__PURE__ */ jsx5(
975
+ FieldInput,
976
+ {
977
+ field,
978
+ value: draft[field.key],
979
+ onChange: (v) => onField(field.key, v)
980
+ },
981
+ field.key
982
+ )) });
983
+ }
984
+ var INPUT_CLASS = "w-full rounded-md border border-neutral-300 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50";
985
+ function FieldInput({
986
+ field,
987
+ value,
988
+ onChange
989
+ }) {
990
+ const id = `field-${field.key.replace(/\s+/g, "-")}`;
991
+ return /* @__PURE__ */ jsxs5("div", { children: [
992
+ /* @__PURE__ */ jsx5(
993
+ "label",
994
+ {
995
+ htmlFor: id,
996
+ className: "mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400",
997
+ children: field.label
998
+ }
999
+ ),
1000
+ field.kind === "textarea" ? /* @__PURE__ */ jsx5(
1001
+ "textarea",
1002
+ {
1003
+ id,
1004
+ value: String(value ?? ""),
1005
+ onChange: (e) => onChange(e.target.value),
1006
+ rows: 3,
1007
+ className: INPUT_CLASS
1008
+ }
1009
+ ) : field.kind === "select" ? /* @__PURE__ */ jsxs5(
1010
+ "select",
1011
+ {
1012
+ id,
1013
+ value: String(value ?? ""),
1014
+ onChange: (e) => onChange(e.target.value),
1015
+ className: INPUT_CLASS,
1016
+ children: [
1017
+ field.nullable ? /* @__PURE__ */ jsx5("option", { value: "", children: "\u2014" }) : null,
1018
+ field.options?.map((opt) => /* @__PURE__ */ jsx5("option", { value: opt, children: opt }, opt))
1019
+ ]
1020
+ }
1021
+ ) : /* @__PURE__ */ jsx5(
1022
+ "input",
1023
+ {
1024
+ id,
1025
+ type: field.kind === "number" ? "number" : "text",
1026
+ value: String(value ?? ""),
1027
+ onChange: (e) => onChange(e.target.value),
1028
+ className: INPUT_CLASS
1029
+ }
1030
+ )
1031
+ ] });
1032
+ }
1033
+
1034
+ // src/record-form.tsx
1035
+ import { useMemo, useState as useState4 } from "react";
1036
+
1037
+ // src/form-fields.ts
1038
+ import {
1039
+ GOAL_RUNG_VALUES,
1040
+ TESTING_RUNGS
1041
+ } from "@validation-os/core";
1042
+ var ASSUMPTION_STATUS = ["Draft", "Live", "Invalidated"];
1043
+ var EXPERIMENT_STATUS = ["Running", "Closed"];
1044
+ var CLOSURE_REASON = ["Completed", "Early-stop", "Kill"];
1045
+ var GOAL_STATUS = ["Draft", "Active", "Closed"];
1046
+ var GOAL_OUTCOME = ["Achieved", "Missed", "Dropped"];
1047
+ var DECISION_STATUS = ["Active", "Provisional", "Superseded", "Reversed"];
1048
+ var GLOSSARY_STATUS = ["Active", "Provisional", "Superseded"];
1049
+ var RESULT = ["Validated", "Invalidated", "Inconclusive"];
1050
+ var MAGNITUDE = ["Low", "Typical", "High"];
1051
+ var FEASIBILITY = ["High", "Medium", "Low"];
1052
+ var RUNGS = [...TESTING_RUNGS, ...GOAL_RUNG_VALUES];
1053
+ var QUALITY = ["1", "0.7", "0.5"];
1054
+ var FIELDS = {
1055
+ assumptions: [
1056
+ { key: "Title", label: "Assumption", kind: "text", required: true },
1057
+ { key: "Description", label: "Description", kind: "textarea" },
1058
+ { key: "Lens", label: "Lens", kind: "text" },
1059
+ { key: "Impact", label: "Impact (0\u2013100)", kind: "number" },
1060
+ { key: "Status", label: "Status", kind: "select", options: ASSUMPTION_STATUS },
1061
+ { key: "5 Whys", label: "5 Whys", kind: "textarea" },
1062
+ { key: "Metric for truth", label: "Metric for truth", kind: "textarea" },
1063
+ {
1064
+ key: "Scoring justification",
1065
+ label: "Scoring justification",
1066
+ kind: "textarea"
1067
+ }
1068
+ ],
1069
+ experiments: [
1070
+ { key: "Title", label: "Experiment", kind: "text", required: true },
1071
+ { key: "Instrument", label: "Instrument", kind: "text" },
1072
+ { key: "Feasibility", label: "Feasibility", kind: "select", options: FEASIBILITY },
1073
+ { key: "Status", label: "Status", kind: "select", options: EXPERIMENT_STATUS },
1074
+ {
1075
+ key: "closureReason",
1076
+ label: "Closure reason",
1077
+ kind: "select",
1078
+ options: CLOSURE_REASON
1079
+ },
1080
+ { key: "Date", label: "Date", kind: "text", placeholder: "YYYY-MM-DD" }
1081
+ ],
1082
+ readings: [
1083
+ { key: "Title", label: "Reading", kind: "text", required: true },
1084
+ { key: "Source", label: "Source", kind: "text" },
1085
+ { key: "Rung", label: "Rung", kind: "select", options: RUNGS, required: true },
1086
+ { key: "Result", label: "Result", kind: "select", options: RESULT, required: true },
1087
+ {
1088
+ key: "Representativeness",
1089
+ label: "Representativeness",
1090
+ kind: "select",
1091
+ options: QUALITY,
1092
+ coerce: "number"
1093
+ },
1094
+ {
1095
+ key: "Credibility",
1096
+ label: "Credibility",
1097
+ kind: "select",
1098
+ options: QUALITY,
1099
+ coerce: "number"
1100
+ },
1101
+ {
1102
+ key: "magnitudeBand",
1103
+ label: "Magnitude band",
1104
+ kind: "select",
1105
+ options: MAGNITUDE
1106
+ },
1107
+ { key: "Date", label: "Date", kind: "text", placeholder: "YYYY-MM-DD" }
1108
+ ],
1109
+ goals: [
1110
+ { key: "Title", label: "Goal", kind: "text", required: true },
1111
+ { key: "Status", label: "Status", kind: "select", options: GOAL_STATUS },
1112
+ { key: "Outcome", label: "Outcome", kind: "select", options: GOAL_OUTCOME },
1113
+ { key: "Date", label: "Date", kind: "text", placeholder: "YYYY-MM-DD" }
1114
+ ],
1115
+ decisions: [
1116
+ { key: "Title", label: "Decision", kind: "text", required: true },
1117
+ { key: "Status", label: "Status", kind: "select", options: DECISION_STATUS }
1118
+ ],
1119
+ glossary: [
1120
+ { key: "Title", label: "Term", kind: "text", required: true },
1121
+ { key: "Status", label: "Status", kind: "select", options: GLOSSARY_STATUS }
1122
+ ],
1123
+ people: [
1124
+ { key: "Name", label: "Name", kind: "text", required: true },
1125
+ { key: "Role", label: "Role", kind: "text" },
1126
+ { key: "Segment", label: "Segment", kind: "text" }
1127
+ ]
1128
+ };
1129
+ function formFieldsFor(register) {
1130
+ return FIELDS[register];
1131
+ }
1132
+ function emptyDraft(register) {
1133
+ const draft = {};
1134
+ for (const f of formFieldsFor(register)) draft[f.key] = "";
1135
+ return draft;
1136
+ }
1137
+ function missingRequired(register, draft) {
1138
+ return formFieldsFor(register).filter((f) => f.required && !String(draft[f.key] ?? "").trim()).map((f) => f.label);
1139
+ }
1140
+ function toCreatePayload(register, draft) {
1141
+ const out = {};
1142
+ for (const f of formFieldsFor(register)) {
1143
+ const raw = String(draft[f.key] ?? "").trim();
1144
+ if (raw === "") continue;
1145
+ if (f.kind === "number" || f.coerce === "number") {
1146
+ const n = Number(raw);
1147
+ if (!Number.isNaN(n)) out[f.key] = n;
1148
+ } else {
1149
+ out[f.key] = raw;
1150
+ }
1151
+ }
1152
+ return out;
1153
+ }
1154
+
1155
+ // src/field-styles.ts
1156
+ var FIELD_LABEL_CLASS = "block text-xs font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400";
1157
+ var FIELD_CONTROL_CLASS = "mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100";
1158
+
1159
+ // src/use-mutations.ts
1160
+ import { useCallback as useCallback2, useState as useState3 } from "react";
1161
+ async function postJson(url, body) {
1162
+ const res = await fetch(url, {
1163
+ method: "POST",
1164
+ headers: { "content-type": "application/json" },
1165
+ body: JSON.stringify(body)
1166
+ });
1167
+ const payload = await res.json().catch(() => null);
1168
+ if (!res.ok) {
1169
+ const message = payload?.message ?? payload?.error ?? `Request failed (${res.status})`;
1170
+ throw new Error(message);
1171
+ }
1172
+ return payload.data;
1173
+ }
1174
+ function useCreate(register, basePath = "/api") {
1175
+ const [saving, setSaving] = useState3(false);
1176
+ const [error, setError] = useState3(null);
1177
+ const create = useCallback2(
1178
+ async (data) => {
1179
+ setSaving(true);
1180
+ setError(null);
1181
+ try {
1182
+ return await postJson(`${basePath}/${register}`, data);
1183
+ } catch (e) {
1184
+ const message = e instanceof Error ? e.message : "Failed to create";
1185
+ setError(message);
1186
+ throw e;
1187
+ } finally {
1188
+ setSaving(false);
1189
+ }
1190
+ },
1191
+ [register, basePath]
1192
+ );
1193
+ return { create, saving, error };
1194
+ }
1195
+ function useLink(basePath = "/api") {
1196
+ const [linking, setLinking] = useState3(false);
1197
+ const [error, setError] = useState3(null);
1198
+ const link = useCallback2(
1199
+ async (args) => {
1200
+ setLinking(true);
1201
+ setError(null);
1202
+ try {
1203
+ await postJson(`${basePath}/link`, args);
1204
+ } catch (e) {
1205
+ const message = e instanceof Error ? e.message : "Failed to link";
1206
+ setError(message);
1207
+ throw e;
1208
+ } finally {
1209
+ setLinking(false);
1210
+ }
1211
+ },
1212
+ [basePath]
1213
+ );
1214
+ return { link, linking, error };
1215
+ }
1216
+
1217
+ // src/record-form.tsx
1218
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1219
+ function RecordForm({
1220
+ register,
1221
+ basePath,
1222
+ onCreated,
1223
+ onCancel
1224
+ }) {
1225
+ const fields = useMemo(() => formFieldsFor(register), [register]);
1226
+ const [draft, setDraft] = useState4(
1227
+ () => emptyDraft(register)
1228
+ );
1229
+ const { create, saving, error } = useCreate(register, basePath);
1230
+ const missing = missingRequired(register, draft);
1231
+ const set = (key, value) => setDraft((d) => ({ ...d, [key]: value }));
1232
+ const onSubmit = async (e) => {
1233
+ e.preventDefault();
1234
+ if (missing.length > 0 || saving) return;
1235
+ try {
1236
+ const created = await create(toCreatePayload(register, draft));
1237
+ onCreated(created.id);
1238
+ } catch {
1239
+ }
1240
+ };
1241
+ return /* @__PURE__ */ jsxs6("form", { onSubmit, className: "flex h-full flex-col", children: [
1242
+ /* @__PURE__ */ jsxs6("div", { className: "flex-1 space-y-4 overflow-y-auto p-5", children: [
1243
+ fields.map((field) => /* @__PURE__ */ jsx6(
1244
+ Field,
1245
+ {
1246
+ field,
1247
+ value: draft[field.key] ?? "",
1248
+ onChange: (v) => set(field.key, v)
1249
+ },
1250
+ field.key
1251
+ )),
1252
+ error ? /* @__PURE__ */ jsx6("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : null
1253
+ ] }),
1254
+ /* @__PURE__ */ jsxs6("footer", { className: "flex items-center justify-end gap-2 border-t border-neutral-200 p-5 dark:border-neutral-800", children: [
1255
+ /* @__PURE__ */ jsx6(
1256
+ "button",
1257
+ {
1258
+ type: "button",
1259
+ onClick: onCancel,
1260
+ className: "rounded-md px-3 py-2 text-sm text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
1261
+ children: "Cancel"
1262
+ }
1263
+ ),
1264
+ /* @__PURE__ */ jsx6(
1265
+ "button",
1266
+ {
1267
+ type: "submit",
1268
+ disabled: missing.length > 0 || saving,
1269
+ title: missing.length > 0 ? `Fill in: ${missing.join(", ")}` : void 0,
1270
+ className: "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
1271
+ children: saving ? "Creating\u2026" : `Create ${REGISTER_LABEL[register]}`
1272
+ }
1273
+ )
1274
+ ] })
1275
+ ] });
1276
+ }
1277
+ function Field({
1278
+ field,
1279
+ value,
1280
+ onChange
1281
+ }) {
1282
+ const id = `field-${field.key}`;
1283
+ return /* @__PURE__ */ jsxs6("div", { children: [
1284
+ /* @__PURE__ */ jsxs6("label", { htmlFor: id, className: FIELD_LABEL_CLASS, children: [
1285
+ field.label,
1286
+ field.required ? /* @__PURE__ */ jsx6("span", { className: "text-red-500", children: " *" }) : null
1287
+ ] }),
1288
+ field.kind === "textarea" ? /* @__PURE__ */ jsx6(
1289
+ "textarea",
1290
+ {
1291
+ id,
1292
+ value,
1293
+ rows: 3,
1294
+ placeholder: field.placeholder,
1295
+ onChange: (e) => onChange(e.target.value),
1296
+ className: FIELD_CONTROL_CLASS
1297
+ }
1298
+ ) : field.kind === "select" ? /* @__PURE__ */ jsxs6(
1299
+ "select",
1300
+ {
1301
+ id,
1302
+ value,
1303
+ onChange: (e) => onChange(e.target.value),
1304
+ className: FIELD_CONTROL_CLASS,
1305
+ children: [
1306
+ /* @__PURE__ */ jsx6("option", { value: "", children: "\u2014" }),
1307
+ field.options?.map((opt) => /* @__PURE__ */ jsx6("option", { value: opt, children: opt }, opt))
1308
+ ]
1309
+ }
1310
+ ) : /* @__PURE__ */ jsx6(
1311
+ "input",
1312
+ {
1313
+ id,
1314
+ type: field.kind === "number" ? "number" : "text",
1315
+ value,
1316
+ placeholder: field.placeholder,
1317
+ onChange: (e) => onChange(e.target.value),
1318
+ className: FIELD_CONTROL_CLASS
1319
+ }
1320
+ )
1321
+ ] });
1322
+ }
1323
+
1324
+ // src/relation-editor.tsx
1325
+ import { useMemo as useMemo2, useState as useState5 } from "react";
1326
+
1327
+ // src/link-choices.ts
1328
+ import { RELATIONS } from "@validation-os/core";
1329
+ var RELATION_LABEL = {
1330
+ "assumption-reading": "Add reading",
1331
+ "assumption-depends-on": "Depends on",
1332
+ "assumption-contradicts": "Contradicts",
1333
+ "reading-experiment": "From experiment",
1334
+ "reading-goal": "From goal",
1335
+ "decision-based-on": "Based on assumption",
1336
+ "decision-resolves": "Resolves assumption",
1337
+ "goal-based-on": "Based on assumption"
1338
+ };
1339
+ function linkChoicesFrom(register) {
1340
+ return Object.keys(RELATIONS).filter((r) => RELATIONS[r].from.register === register).map((relation) => ({
1341
+ relation,
1342
+ label: RELATION_LABEL[relation],
1343
+ targetRegister: RELATIONS[relation].targetRegister
1344
+ }));
1345
+ }
1346
+
1347
+ // src/relation-editor.tsx
1348
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1349
+ function RelationEditor({
1350
+ register,
1351
+ recordId,
1352
+ basePath,
1353
+ onLinked
1354
+ }) {
1355
+ const choices = useMemo2(() => linkChoicesFrom(register), [register]);
1356
+ const [relation, setRelation] = useState5("");
1357
+ const [targetId, setTargetId] = useState5("");
1358
+ const { link, linking, error } = useLink(basePath);
1359
+ const active = choices.find((c) => c.relation === relation) ?? null;
1360
+ const { records } = useList(
1361
+ active?.targetRegister ?? register,
1362
+ basePath
1363
+ );
1364
+ const targets = (records ?? []).filter(
1365
+ (r) => !(active?.targetRegister === register && r.id === recordId)
1366
+ );
1367
+ if (choices.length === 0) return null;
1368
+ const onLink = async () => {
1369
+ if (!active || !targetId || linking) return;
1370
+ try {
1371
+ await link({
1372
+ relation: active.relation,
1373
+ from: { register, id: recordId },
1374
+ to: { register: active.targetRegister, id: targetId }
1375
+ });
1376
+ setTargetId("");
1377
+ setRelation("");
1378
+ onLinked();
1379
+ } catch {
1380
+ }
1381
+ };
1382
+ return /* @__PURE__ */ jsxs7("section", { className: "border-t border-neutral-200 p-5 dark:border-neutral-800", children: [
1383
+ /* @__PURE__ */ jsx7("h3", { className: "mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400", children: "Link a record" }),
1384
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-3", children: [
1385
+ /* @__PURE__ */ jsxs7(
1386
+ "select",
1387
+ {
1388
+ "aria-label": "Relation",
1389
+ value: relation,
1390
+ onChange: (e) => {
1391
+ setRelation(e.target.value);
1392
+ setTargetId("");
1393
+ },
1394
+ className: FIELD_CONTROL_CLASS,
1395
+ children: [
1396
+ /* @__PURE__ */ jsx7("option", { value: "", children: "Choose a relation\u2026" }),
1397
+ choices.map((c) => /* @__PURE__ */ jsx7("option", { value: c.relation, children: c.label }, c.relation))
1398
+ ]
1399
+ }
1400
+ ),
1401
+ active ? /* @__PURE__ */ jsxs7(
1402
+ "select",
1403
+ {
1404
+ "aria-label": "Target record",
1405
+ value: targetId,
1406
+ onChange: (e) => setTargetId(e.target.value),
1407
+ className: FIELD_CONTROL_CLASS,
1408
+ children: [
1409
+ /* @__PURE__ */ jsx7("option", { value: "", children: "Choose a record\u2026" }),
1410
+ targets.map((r) => /* @__PURE__ */ jsx7("option", { value: r.id, children: primaryLabel(r) }, r.id))
1411
+ ]
1412
+ }
1413
+ ) : null,
1414
+ error ? /* @__PURE__ */ jsx7("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : null,
1415
+ /* @__PURE__ */ jsx7(
1416
+ "button",
1417
+ {
1418
+ type: "button",
1419
+ onClick: onLink,
1420
+ disabled: !active || !targetId || linking,
1421
+ className: "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
1422
+ children: linking ? "Linking\u2026" : "Link"
1423
+ }
1424
+ )
1425
+ ] })
1426
+ ] });
378
1427
  }
379
1428
 
380
1429
  // src/register-browser.tsx
381
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1430
+ import { useState as useState6 } from "react";
1431
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
382
1432
  function RegisterBrowser({ register, basePath }) {
383
- const { records, loading, error } = useList(register, basePath);
384
- const [openId, setOpenId] = useState2(null);
1433
+ const { records, loading, error, refresh: refreshList } = useList(
1434
+ register,
1435
+ basePath
1436
+ );
1437
+ const [openId, setOpenId] = useState6(null);
1438
+ const [creating, setCreating] = useState6(false);
385
1439
  const {
386
1440
  record,
387
1441
  loading: recordLoading,
388
- error: recordError
1442
+ error: recordError,
1443
+ refresh: refreshRecord
389
1444
  } = useRecord(register, openId, basePath);
390
- return /* @__PURE__ */ jsxs4("div", { children: [
391
- loading && !records ? /* @__PURE__ */ jsxs4("p", { className: "text-sm text-neutral-500", children: [
1445
+ return /* @__PURE__ */ jsxs8("div", { children: [
1446
+ /* @__PURE__ */ jsxs8("div", { className: "mb-3 flex items-center justify-between", children: [
1447
+ /* @__PURE__ */ jsx8("h1", { className: "text-lg font-semibold text-neutral-900 dark:text-neutral-50", children: REGISTER_LABEL[register] }),
1448
+ /* @__PURE__ */ jsxs8(
1449
+ "button",
1450
+ {
1451
+ type: "button",
1452
+ onClick: () => setCreating(true),
1453
+ className: "rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-500",
1454
+ children: [
1455
+ "New ",
1456
+ REGISTER_SINGULAR[register]
1457
+ ]
1458
+ }
1459
+ )
1460
+ ] }),
1461
+ loading && !records ? /* @__PURE__ */ jsxs8("p", { className: "text-sm text-neutral-500", children: [
392
1462
  "Loading ",
393
1463
  REGISTER_LABEL[register].toLowerCase(),
394
1464
  "\u2026"
395
- ] }) : error ? /* @__PURE__ */ jsx4("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : /* @__PURE__ */ jsx4(
1465
+ ] }) : error ? /* @__PURE__ */ jsx8("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) : /* @__PURE__ */ jsx8(
396
1466
  RegisterTable,
397
1467
  {
398
1468
  register,
@@ -401,7 +1471,7 @@ function RegisterBrowser({ register, basePath }) {
401
1471
  selectedId: openId
402
1472
  }
403
1473
  ),
404
- /* @__PURE__ */ jsx4(
1474
+ /* @__PURE__ */ jsx8(
405
1475
  RecordDrawer,
406
1476
  {
407
1477
  register,
@@ -409,20 +1479,65 @@ function RegisterBrowser({ register, basePath }) {
409
1479
  loading: recordLoading,
410
1480
  error: recordError,
411
1481
  open: openId !== null,
412
- onClose: () => setOpenId(null)
1482
+ onClose: () => setOpenId(null),
1483
+ basePath,
1484
+ onChanged: () => {
1485
+ refreshRecord();
1486
+ refreshList();
1487
+ },
1488
+ children: openId ? /* @__PURE__ */ jsx8(
1489
+ RelationEditor,
1490
+ {
1491
+ register,
1492
+ recordId: openId,
1493
+ basePath,
1494
+ onLinked: () => {
1495
+ refreshRecord();
1496
+ refreshList();
1497
+ }
1498
+ }
1499
+ ) : null
1500
+ }
1501
+ ),
1502
+ /* @__PURE__ */ jsxs8(
1503
+ DrawerShell,
1504
+ {
1505
+ open: creating,
1506
+ onClose: () => setCreating(false),
1507
+ ariaLabel: `New ${REGISTER_SINGULAR[register]} record`,
1508
+ scroll: false,
1509
+ children: [
1510
+ /* @__PURE__ */ jsxs8("header", { className: "border-b border-neutral-200 p-5 dark:border-neutral-800", children: [
1511
+ /* @__PURE__ */ jsx8("p", { className: "text-xs font-medium uppercase tracking-wide text-neutral-400", children: "New" }),
1512
+ /* @__PURE__ */ jsx8("h2", { className: "mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-50", children: REGISTER_SINGULAR[register] })
1513
+ ] }),
1514
+ /* @__PURE__ */ jsx8(
1515
+ RecordForm,
1516
+ {
1517
+ register,
1518
+ basePath,
1519
+ onCreated: (id) => {
1520
+ setCreating(false);
1521
+ refreshList();
1522
+ setOpenId(id);
1523
+ },
1524
+ onCancel: () => setCreating(false)
1525
+ }
1526
+ )
1527
+ ]
413
1528
  }
414
1529
  )
415
1530
  ] });
416
1531
  }
417
1532
 
418
1533
  // src/use-counts.ts
419
- import { useCallback as useCallback2, useEffect as useEffect3, useState as useState3 } from "react";
1534
+ import { useCallback as useCallback3, useEffect as useEffect4, useState as useState7 } from "react";
420
1535
  function useCounts(basePath = "/api") {
421
- const [counts, setCounts] = useState3(null);
422
- const [loading, setLoading] = useState3(true);
423
- const [error, setError] = useState3(null);
424
- const [tick, setTick] = useState3(0);
425
- useEffect3(() => {
1536
+ const [counts, setCounts] = useState7(null);
1537
+ const [loading, setLoading] = useState7(true);
1538
+ const [error, setError] = useState7(null);
1539
+ const [tick, setTick] = useState7(0);
1540
+ useEffect4(() => {
426
1541
  let live = true;
427
1542
  setLoading(true);
428
1543
  fetch(`${basePath}/counts`).then(async (res) => {
@@ -442,22 +1557,41 @@ function useCounts(basePath = "/api") {
442
1557
  live = false;
443
1558
  };
444
1559
  }, [basePath, tick]);
445
- const refresh = useCallback2(() => setTick((t) => t + 1), []);
1560
+ const refresh = useCallback3(() => setTick((t2) => t2 + 1), []);
446
1561
  return { counts, loading, error, refresh };
447
1562
  }
448
1563
  export {
1564
+ CONFLICT_MESSAGE,
449
1565
  REGISTER_LABEL,
450
1566
  REGISTER_ORDER,
1567
+ REGISTER_SINGULAR,
451
1568
  RecordDrawer,
1569
+ RecordForm,
452
1570
  RegisterBrowser,
453
1571
  RegisterCounts,
454
1572
  RegisterTable,
1573
+ RelationEditor,
1574
+ UnderstandingPanel,
1575
+ buildPatch,
1576
+ buildUnderstanding,
455
1577
  cellValue,
456
1578
  columnsFor,
1579
+ draftFrom,
1580
+ editableFields,
1581
+ emptyDraft,
1582
+ formFieldsFor,
457
1583
  formatValue,
1584
+ hasEdits,
1585
+ interpretSave,
1586
+ linkChoicesFrom,
1587
+ missingRequired,
458
1588
  primaryLabel,
1589
+ toCreatePayload,
459
1590
  useCounts,
1591
+ useCreate,
1592
+ useLink,
460
1593
  useList,
461
- useRecord
1594
+ useRecord,
1595
+ useUpdate
462
1596
  };
463
1597
  //# sourceMappingURL=index.js.map