@coffer-org/plugin-webchat 6.0.0 → 7.0.1

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.
@@ -1,37 +1,52 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { handleIncoming, liveAgentId } from '@coffer-org/server/orchestrator';
3
- import { chatIdFor, recordUser, buildChain, history, readSelectionForTurn } from "./chain-store.js";
3
+ import { beginTurn } from '@coffer-org/server/turn-gate';
4
+ import { HttpError } from '@coffer-org/server/plugin-hooks';
5
+ import { openConversation, recordUser, buildChain, history, readSelectionForTurn, setSelection, userTurnCount, } from "./chain-store.js";
4
6
  import { policy } from "./config.js";
5
7
  import { makeStreamingConnector } from "./connector.js";
8
+ import { broadcast } from "./channel-registry.js";
6
9
  import { webChannelSystem } from "./format.js";
7
10
  import { loadReasoningDisplay } from "./settings.js";
11
+ import { getLogger } from '@coffer-org/sdk/logger';
12
+ const log = getLogger('webchat');
13
+ export const CAPABILITIES = {
14
+ events: ['delta', 'reasoning', 'segment', 'suggestions', 'title'],
15
+ privateChats: true,
16
+ };
8
17
  export function pageContext(ctx) {
9
18
  if (typeof ctx !== 'object' || ctx === null)
10
- return '';
19
+ return [];
11
20
  const c = ctx;
12
21
  const path = typeof c['path'] === 'string' ? c['path'] : '';
13
22
  if (!path)
14
- return '';
23
+ return [];
15
24
  const library = typeof c['library'] === 'string' ? c['library'] : '';
16
- const type = typeof c['type'] === 'string' ? c['type'] : '';
25
+ const shelf = typeof c['type'] === 'string' ? c['type'] : '';
17
26
  const id = typeof c['id'] === 'string' ? c['id'] : '';
18
- const where = library && type && id ? `record ${library}/${type}/${id}` : library && type ? `the ${library}/${type} list` : path;
19
- return `CURRENT PAGE the user is looking at ${where} (SPA path ${path}). When they say "this", "here", or "this record", they most likely mean it. Do not mention this note unless it is relevant.`;
27
+ const title = typeof c['title'] === 'string' ? c['title'] : '';
28
+ if (library && shelf && id)
29
+ return [{ name: 'record', value: title, attrs: { library, shelf, id } }];
30
+ if (library && shelf)
31
+ return [{ name: 'page', value: '', attrs: { library, shelf } }];
32
+ return [{ name: 'page', value: path }];
20
33
  }
21
- export async function sendAction(body, ctx, deps = {}) {
34
+ export async function sendAction(body, caller, deps = {}) {
22
35
  const doHandle = deps.handleIncoming ?? handleIncoming;
23
36
  const convId = typeof body['convId'] === 'string' ? body['convId'] : '';
24
37
  const text = typeof body['text'] === 'string' ? body['text'] : '';
25
- if (!convId) {
26
- ctx.emit('error', { message: 'missing required field: convId' });
27
- return;
38
+ if (!convId)
39
+ throw new HttpError(400, 'missing required field: convId');
40
+ if (!text.trim())
41
+ throw new HttpError(400, 'missing required field: text');
42
+ const chatId = await openConversation(convId, caller.id, 'write');
43
+ if (chatId === null) {
44
+ return { msgId: null, botMsgId: null };
28
45
  }
29
- if (!text.trim()) {
30
- ctx.emit('error', { message: 'missing required field: text' });
31
- return;
32
- }
33
- const chatId = chatIdFor(ctx.caller.id, convId);
34
46
  const selection = await readSelectionForTurn(chatId);
47
+ if (selection.owner === null) {
48
+ await setSelection(chatId, { owner: caller.id });
49
+ }
35
50
  const agentId = liveAgentId(selection.agentId);
36
51
  const msgId = randomUUID();
37
52
  const botMsgId = randomUUID();
@@ -39,27 +54,44 @@ export async function sendAction(body, ctx, deps = {}) {
39
54
  const attachments = parseAttachments(body['attachments']);
40
55
  const explicitReplyTo = typeof body['replyTo'] === 'string' && body['replyTo'] ? body['replyTo'] : null;
41
56
  const replyTo = explicitReplyTo ?? (await history(chatId, 1)).at(-1)?.msgId ?? null;
42
- await recordUser({ chatId, msgId, sender: ctx.caller.id, text, attachments, ts: nowSec, replyToId: replyTo });
57
+ await recordUser({ chatId, msgId, sender: caller.id, text, attachments, ts: nowSec, replyToId: replyTo });
58
+ broadcast(chatId, 'user', { msgId, text, ts: nowSec, ...(attachments?.length ? { attachments } : {}) });
43
59
  const messages = await buildChain(msgId, { chatId });
60
+ const userTurns = await userTurnCount(chatId);
44
61
  const { connector, recorded, suggestions } = makeStreamingConnector({
45
62
  chatId,
46
63
  botMsgId,
47
- emit: ctx.emit,
48
64
  display: await loadReasoningDisplay(),
49
65
  });
50
66
  const turnContext = pageContext(body['context']);
51
- await doHandle(connector, {
52
- connectorId: 'webchat',
53
- channelSystem: await webChannelSystem(),
54
- ...(turnContext ? { turnContext } : {}),
55
- ...(agentId ? { agentId } : {}),
56
- ...(selection.presetId ? { presetId: selection.presetId } : {}),
57
- chatId,
58
- sender: { id: ctx.caller.id },
59
- messages,
60
- supportsSuggestions: true,
61
- }, { policy: policy() });
62
- ctx.emit('done', { msgId: recorded(), parentMsgId: msgId, suggestions: suggestions() });
67
+ const gate = await beginTurn('webchat', chatId, { supersede: true });
68
+ void (async () => {
69
+ try {
70
+ await doHandle(connector, {
71
+ envelope: { connectorId: 'webchat', chatId, turnId: msgId },
72
+ body: {
73
+ systemPrompt: { channel: [await webChannelSystem()] },
74
+ messages,
75
+ capabilities: CAPABILITIES,
76
+ userTurns,
77
+ },
78
+ ...(turnContext.length ? { turnContext } : {}),
79
+ ...(agentId ? { agentId } : {}),
80
+ ...(selection.presetId ? { presetId: selection.presetId } : {}),
81
+ sender: { id: caller.id, idKind: 'coffer-user' },
82
+ signal: gate.signal,
83
+ }, { policy: policy() });
84
+ broadcast(chatId, 'done', { msgId: recorded(), parentMsgId: msgId, suggestions: suggestions() });
85
+ }
86
+ catch (err) {
87
+ log.error(`sendAction: handleIncoming threw for chat ${chatId}: ${err instanceof Error ? err.message : String(err)}`);
88
+ broadcast(chatId, 'error', { message: null, parentMsgId: msgId });
89
+ }
90
+ finally {
91
+ gate.end();
92
+ }
93
+ })();
94
+ return { msgId, botMsgId };
63
95
  }
64
96
  function parseAttachments(value) {
65
97
  if (!Array.isArray(value))
package/dist/schema.js CHANGED
@@ -4368,6 +4368,13 @@ function wrapKey(key, opts, meta) {
4368
4368
  span: opts.span
4369
4369
  }
4370
4370
  };
4371
+ if (opts.faces !== void 0) m = {
4372
+ ...m,
4373
+ hints: {
4374
+ ...m.hints,
4375
+ faces: opts.faces
4376
+ }
4377
+ };
4371
4378
  if (opts.default !== void 0) m = {
4372
4379
  ...m,
4373
4380
  default: opts.default
@@ -4795,6 +4802,7 @@ function normalizeOpts(rawIn) {
4795
4802
  ...v.valueLabel !== void 0 ? { valueLabel: v.valueLabel } : {},
4796
4803
  compareWith: v.compareWith,
4797
4804
  span: v.span,
4805
+ faces: v.faces,
4798
4806
  emphasis: v.emphasis,
4799
4807
  noLabel: v.noLabel,
4800
4808
  role: v.role,
@@ -5107,6 +5115,63 @@ registerPreset("slug", "string", {}, {
5107
5115
  };
5108
5116
  }
5109
5117
  });
5118
+ /**
5119
+ * Identifier — a code printed for a machine and read back by one: a serial number, an order
5120
+ * number, a policy number, an IMEI. What a person actually DOES with one is copy it, which is
5121
+ * why this is a type of its own and not `f.string({ ui: { voice: 'data' } })`: the machine face
5122
+ * is one word on any string now, but the click that copies belongs to the kind.
5123
+ *
5124
+ * Deliberately unvalidated beyond a length: an identifier's format belongs to whoever issued
5125
+ * it, and a pattern here would reject the next manufacturer's.
5126
+ *
5127
+ * @layer preset
5128
+ * @base string
5129
+ * @prim text
5130
+ * @widget identifier
5131
+ * @example f.identifier({ label: 'mod.fields.serialNumber' })
5132
+ */
5133
+ function identifier(o) {
5134
+ return declare("identifier", o);
5135
+ }
5136
+ registerPreset("identifier", "string", {}, {
5137
+ kind: "identifier",
5138
+ widget: "identifier",
5139
+ build() {
5140
+ return {
5141
+ column: "text",
5142
+ zod: string$1(),
5143
+ hints: {}
5144
+ };
5145
+ }
5146
+ });
5147
+ /**
5148
+ * The machine-readable zone of a travel document — the identifier taken to its limit: printed
5149
+ * for a scanner, not for a person. Fixed pitch, chevrons kept as the filler they are, and the
5150
+ * line breaks preserved, because an MRZ's line structure is part of what it encodes.
5151
+ *
5152
+ * Unvalidated on purpose, like `identifier`: the ICAO line formats differ by document type, and
5153
+ * a strip transcribed from a real document is worth storing even when it does not check out.
5154
+ *
5155
+ * @layer preset
5156
+ * @base string
5157
+ * @prim text
5158
+ * @widget mrz
5159
+ * @example f.mrz({ label: 'documents.personal_document.fields.mrz' })
5160
+ */
5161
+ function mrz(o) {
5162
+ return declare("mrz", o);
5163
+ }
5164
+ registerPreset("mrz", "string", {}, {
5165
+ kind: "mrz",
5166
+ widget: "mrz",
5167
+ build() {
5168
+ return {
5169
+ column: "text",
5170
+ zod: string$1(),
5171
+ hints: {}
5172
+ };
5173
+ }
5174
+ });
5110
5175
  var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
5111
5176
  /**
5112
5177
  * Hex color — kind 'color', `#rrggbb` + swatch widget.
@@ -5391,6 +5456,38 @@ registerPreset("tag", "string", {}, {
5391
5456
  }
5392
5457
  });
5393
5458
  /**
5459
+ * A location written the way it is said aloud: workshop › shelf 2 › box. A `string` fixed to
5460
+ * `multiple`, exactly as `f.tags` is, because a place inside a place inside a place IS a
5461
+ * sequence — the plurality is the type, not a modifier someone remembered to add.
5462
+ *
5463
+ * Distinct from `f.tags`, which stores the same shape: tags are an unordered SET, and a path is
5464
+ * an ordered CHAIN where each step is inside the one before it. Drawn as chips, that
5465
+ * containment — the only thing the value actually carries — is lost.
5466
+ *
5467
+ * @layer preset
5468
+ * @base string
5469
+ * @prim text
5470
+ * @widget path
5471
+ * @example f.path({ label: 'things.storage_location.fields.path' })
5472
+ */
5473
+ function path(o) {
5474
+ return declare("path", {
5475
+ ...o,
5476
+ multiple: true
5477
+ });
5478
+ }
5479
+ registerPreset("path", "string", {}, {
5480
+ kind: "path",
5481
+ widget: "path",
5482
+ build() {
5483
+ return {
5484
+ column: "text",
5485
+ zod: string$1(),
5486
+ hints: {}
5487
+ };
5488
+ }
5489
+ });
5490
+ /**
5394
5491
  * Tags — `tag({ multiple: true })`.
5395
5492
  *
5396
5493
  * @layer preset
@@ -5583,6 +5680,56 @@ registerPreset("reminder", "date", { lead: 30 }, {
5583
5680
  };
5584
5681
  }
5585
5682
  });
5683
+ /** Timetable — a set of times of day, read as a schedule rather than as a bag of values.
5684
+ * kind 'timetable', prim 'time', always `multiple`.
5685
+ *
5686
+ * Same shape as `f.reminder`/`f.age` over `date`: a semantic type whose renderer knows what
5687
+ * the values MEAN. Everything it shows beyond the times themselves — when the service starts
5688
+ * and ends, how many runs there are, which one is next — is derived from the values at render
5689
+ * time, never stored: a "next departure" is wrong the moment it is written down, exactly the
5690
+ * argument `f.age` already makes for an age.
5691
+ *
5692
+ * Storage is a plain multiple `time`, so a field can be switched to this from
5693
+ * `f.time({ multiple: true })` and back with no migration.
5694
+ *
5695
+ * @layer preset
5696
+ * @base time
5697
+ * @prim time
5698
+ * @widget timetable
5699
+ * @example f.timetable({ label: 'mod.fields.weekdays' })
5700
+ */
5701
+ function timetable(o) {
5702
+ return declare("timetable", {
5703
+ ...o,
5704
+ multiple: true
5705
+ });
5706
+ }
5707
+ registerPreset("timetable", "time", {}, {
5708
+ kind: "timetable",
5709
+ widget: "timetable"
5710
+ });
5711
+ /** Age — date whose whole-year age (as of today) renders alongside it, e.g. a birth date.
5712
+ * kind 'age', prim 'date'. The age itself is computed at RENDER time, never stored: unlike a
5713
+ * `mutate`-driven computed field, it must be right on every day that passes, not only the day
5714
+ * the record was last written, so this is display metadata over `date`, the same shape as
5715
+ * `f.reminder`'s own relative-to-today note.
5716
+ *
5717
+ * @layer preset
5718
+ * @base date
5719
+ * @prim date
5720
+ * @widget age
5721
+ * @example f.age({ label: 'mod.fields.birthDate' })
5722
+ */
5723
+ function age(o) {
5724
+ return declare("age", o);
5725
+ }
5726
+ registerPreset("age", "date", {}, {
5727
+ kind: "age",
5728
+ widget: "age",
5729
+ build(o, parts) {
5730
+ return typeOf("date").build(o, parts);
5731
+ }
5732
+ });
5586
5733
  /**
5587
5734
  * Percentage 0..100 — real with rules:{min:0,max:100}.
5588
5735
  *
@@ -5775,6 +5922,9 @@ var presets = {
5775
5922
  email,
5776
5923
  tel,
5777
5924
  slug,
5925
+ path,
5926
+ identifier,
5927
+ mrz,
5778
5928
  color,
5779
5929
  colorname,
5780
5930
  title,
@@ -5788,6 +5938,8 @@ var presets = {
5788
5938
  rating,
5789
5939
  duration,
5790
5940
  reminder,
5941
+ age,
5942
+ timetable,
5791
5943
  percent,
5792
5944
  year,
5793
5945
  weight,
@@ -6445,26 +6597,38 @@ function identity(opts) {
6445
6597
  }
6446
6598
  };
6447
6599
  }
6448
- /** A scored value out of an optional max, with an optional verdict.
6600
+ /** One measure out of an optional max, from ONE OR MORE named sources, with an optional verdict.
6601
+ *
6602
+ * `source` takes several entries for the same reason `f.identity`'s `channels` does — it is a
6603
+ * role that holds a LIST, so `view.source` is an ordered list of names rather than one name.
6604
+ * Two ratings of the same film out of ten are one measure read twice, and drawing them apart is
6605
+ * what makes them incomparable: the eye has to carry the scale between two figures instead of
6606
+ * reading them against a shared one. `media/title` had exactly that, two `f.score` blocks of
6607
+ * `max: 10` held apart inside an `f.compare`, until this took the restriction off.
6608
+ *
6609
+ * NOT `multiple`. A multiple field is an anonymous array; these are NAMED sources, and the name
6610
+ * is what says which reading came from where. The two are different shapes and the block wants
6611
+ * this one.
6449
6612
  *
6450
6613
  * @layer block
6451
6614
  * @base group
6452
6615
  * @prim —
6453
6616
  * @widget score
6454
- * @example f.score({ source: { value: f.real({ label: '…' }) }, max: 100, verdict: { verdict: f.string({ label: '…' }) } })
6617
+ * @example f.score({ source: { imdb: f.real({ label: '…' }), tmdb: f.real({ label: '…' }) }, max: 10 })
6455
6618
  */
6456
6619
  function score(opts) {
6457
- const source = slot("score", "source", opts.source);
6620
+ const names = Object.keys(opts.source ?? {});
6621
+ if (names.length === 0) throw new Error(`[field.score] slot 'source' expects at least one entry, got 0`);
6458
6622
  const verdict = opts.verdict !== void 0 ? slot("score", "verdict", opts.verdict) : void 0;
6459
6623
  return {
6460
6624
  ...group({
6461
6625
  label: opts.label,
6462
- fields: merge("score", source, verdict),
6626
+ fields: merge("score", opts.source, verdict),
6463
6627
  ui: { kind: "score" }
6464
6628
  }),
6465
6629
  view: {
6466
6630
  kind: "score",
6467
- source: Object.keys(source)[0],
6631
+ source: names,
6468
6632
  max: opts.max,
6469
6633
  verdict: verdict && Object.keys(verdict)[0]
6470
6634
  }
@@ -6496,11 +6660,20 @@ function status(opts) {
6496
6660
  }
6497
6661
  /** A gauge value between an optional min and max, with an optional "of" total.
6498
6662
  *
6663
+ * `direction` is opt-in: when given, the gauge decides in plain JavaScript whether its own
6664
+ * value crossed the bound that matters (`meterTone`, `blocks/meter.tsx`) and reaches for the
6665
+ * tone palette — the fill, the value text and the `of` companion all move together, never
6666
+ * colour alone (a glyph rides along, see the renderer's own comment). Omit it and nothing
6667
+ * about the gauge changes from today. A `'ceiling'` gauge with no declared `max` (config or
6668
+ * the source field's own hints) resolves it from `of`'s own value instead — a budget's cap is
6669
+ * a per-record field, never a compile-time constant.
6670
+ *
6499
6671
  * @layer block
6500
6672
  * @base group
6501
6673
  * @prim —
6502
6674
  * @widget meter
6503
6675
  * @example f.meter({ source: { used: f.real({ label: '…' }) }, min: 0, max: 100 })
6676
+ * @example f.meter({ source: { spent: f.real({ label: '…' }) }, of: { budget: f.real({ label: '…' }) }, direction: 'ceiling' })
6504
6677
  */
6505
6678
  function meter(opts) {
6506
6679
  const source = slot("meter", "source", opts.source);
@@ -6516,7 +6689,8 @@ function meter(opts) {
6516
6689
  source: Object.keys(source)[0],
6517
6690
  min: opts.min,
6518
6691
  max: opts.max,
6519
- of: of && Object.keys(of)[0]
6692
+ of: of && Object.keys(of)[0],
6693
+ direction: opts.direction
6520
6694
  }
6521
6695
  };
6522
6696
  }
@@ -6576,6 +6750,11 @@ function balance(opts) {
6576
6750
  * @base group
6577
6751
  * @prim —
6578
6752
  * @widget route
6753
+ * A `stub` is the part of the ticket that is TORN OFF and kept — a seat, a gate, a booking
6754
+ * reference. Declaring one makes the block a ticket rather than a line: the two halves are
6755
+ * separated by a perforation, and the fields in the stub sit below it. Without one the block
6756
+ * is exactly what it was, a route from here to there, so no existing call changes.
6757
+ *
6579
6758
  * @example f.route({ from: { from: f.string({ label: '…' }) }, to: { to: f.string({ label: '…' }) } })
6580
6759
  */
6581
6760
  function route(opts) {
@@ -6584,9 +6763,10 @@ function route(opts) {
6584
6763
  const depart = opts.depart !== void 0 ? slot("route", "depart", opts.depart) : void 0;
6585
6764
  const arrive = opts.arrive !== void 0 ? slot("route", "arrive", opts.arrive) : void 0;
6586
6765
  const duration = opts.duration !== void 0 ? slot("route", "duration", opts.duration) : void 0;
6766
+ const stub = opts.stub ?? {};
6587
6767
  return {
6588
6768
  ...group({
6589
- fields: merge("route", from, to, depart, arrive, duration),
6769
+ fields: merge("route", from, to, depart, arrive, duration, stub),
6590
6770
  ui: { kind: "route" }
6591
6771
  }),
6592
6772
  view: {
@@ -6595,7 +6775,8 @@ function route(opts) {
6595
6775
  to: Object.keys(to)[0],
6596
6776
  depart: depart && Object.keys(depart)[0],
6597
6777
  arrive: arrive && Object.keys(arrive)[0],
6598
- duration: duration && Object.keys(duration)[0]
6778
+ duration: duration && Object.keys(duration)[0],
6779
+ stub: Object.keys(stub)
6599
6780
  }
6600
6781
  };
6601
6782
  }
@@ -6773,6 +6954,81 @@ function specimen(opts) {
6773
6954
  };
6774
6955
  }
6775
6956
  /**
6957
+ * Measurements against the range they were supposed to fall in, rendered from a single
6958
+ * collection source — see `stampCollection`. `analyte`, `value`, `unit`, `low` and `high` are
6959
+ * part KEYS inside each collection row, NOT `LayoutEl` positions — the same reasoning as
6960
+ * `manifest`'s `quantity`/`item`.
6961
+ *
6962
+ * The point is the COMPARISON. A `f.table` over the same rows prints the bounds as two more
6963
+ * columns and leaves the reader to do it; here each value sits on its own band with the
6964
+ * reference span marked on it, so "outside the range" is seen rather than worked out. That is
6965
+ * also the only thing on the row worth a colour — it is what the reader has to act on.
6966
+ *
6967
+ * `unit`, `low` and `high` are optional: a measurement with no published range (a culture, a
6968
+ * description) still belongs in the same list and simply gets no band.
6969
+ *
6970
+ * @layer block
6971
+ * @base group
6972
+ * @prim —
6973
+ * @widget assay
6974
+ * @example f.assay({ source: { results: f.group({ scope: 'nest', multiple: true, fields: { analyte: f.string({ label: '…' }), value: f.real({ label: '…' }) } }) }, analyte: 'analyte', value: 'value' })
6975
+ */
6976
+ function assay(opts) {
6977
+ return stampCollection("assay", opts.source, opts.label, {
6978
+ kind: "assay",
6979
+ analyte: opts.analyte,
6980
+ value: opts.value,
6981
+ ...opts.unit ? { unit: opts.unit } : {},
6982
+ ...opts.low ? { low: opts.low } : {},
6983
+ ...opts.high ? { high: opts.high } : {}
6984
+ });
6985
+ }
6986
+ /**
6987
+ * Value moving from one place to another, where the MOVEMENT is the subject — not one more
6988
+ * labelled row among the record's fields. The sum is set large in the machine voice, and the two
6989
+ * ends read as a path beneath it.
6990
+ *
6991
+ * amount — what moved. The one required role, and the reason the block exists
6992
+ * from — where it left. Absent on money that only arrived
6993
+ * to — where it arrived. Absent on money that only left
6994
+ * meta — the record's own remaining fields, under a rule: date, category, reference
6995
+ *
6996
+ * An absent end is not drawn, and that is the whole reading: money that left an account and
6997
+ * arrived nowhere IS an expense. It is read off which ends the record actually has, never
6998
+ * guessed from the shape of a value. What the movement MEANS beyond that — whether this
6999
+ * particular kind of transfer is good news — is carried by the classifier's own option `tone`,
7000
+ * the same declaration every other coloured value in the product uses.
7001
+ *
7002
+ * Distinct from `f.route`, which also has two ends: there the journey is the subject and the
7003
+ * ends are places, so it carries times and a duration; here the ends are accounts and the
7004
+ * subject is the quantity.
7005
+ *
7006
+ * @layer block
7007
+ * @base group
7008
+ * @prim —
7009
+ * @widget flow
7010
+ * @example f.flow({ amount: { amount: f.money({ label: '…' }) }, from: { source: f.relation({ label: '…' }) } })
7011
+ */
7012
+ function flow(opts) {
7013
+ const amount = slot("flow", "amount", opts.amount);
7014
+ const from = opts.from !== void 0 ? slot("flow", "from", opts.from) : void 0;
7015
+ const to = opts.to !== void 0 ? slot("flow", "to", opts.to) : void 0;
7016
+ return {
7017
+ ...group({
7018
+ label: opts.label,
7019
+ fields: merge("flow", amount, from, to, opts.meta),
7020
+ ui: { kind: "flow" }
7021
+ }),
7022
+ view: {
7023
+ kind: "flow",
7024
+ amount: Object.keys(amount)[0],
7025
+ from: from && Object.keys(from)[0],
7026
+ to: to && Object.keys(to)[0],
7027
+ meta: Object.keys(opts.meta ?? {})
7028
+ }
7029
+ };
7030
+ }
7031
+ /**
6776
7032
  * A packing/cargo manifest rendered from a single collection source — see `stampCollection`.
6777
7033
  * `quantity` and `item` are part KEYS inside each collection row, NOT `LayoutEl` positions —
6778
7034
  * the same reasoning as `journal`'s `date`/`text`.
@@ -6819,13 +7075,25 @@ function table(opts) {
6819
7075
  } : {},
6820
7076
  ...opts.groupBy ? { groupBy: opts.groupBy } : {},
6821
7077
  ...opts.totals ? { totals: opts.totals } : {},
7078
+ ...opts.summary ? { summary: opts.summary } : {},
6822
7079
  ...opts.numbered ? { numbered: true } : {}
6823
7080
  });
6824
7081
  }
6825
7082
  /**
6826
- * An ID-card header: an optional overline, a prominent number, and trailing meta fields.
6827
- * `view` names each role (Task 5) see `masthead`'s own doc comment for what `meta` means
6828
- * as an ordered name list.
7083
+ * An identity document, drawn as the card it is: a caption and its number across the top, a
7084
+ * portrait beside the holder's own fields, the issuing details under a rule, and the
7085
+ * machine-readable strip at the foot. `view` names each role (Task 5) — see `masthead`'s own
7086
+ * doc comment for what an ordered name list means.
7087
+ *
7088
+ * overline — the caption at the top left ("PASSPORT", "DRIVING LICENCE")
7089
+ * number — the document's number, set apart at the top right
7090
+ * photo — the portrait. One field, rendered in a portrait frame beside the body
7091
+ * meta — the card's OWN fields, labelled, at each field's declared span
7092
+ * footer — the fields below the rule: issued, authority, record number
7093
+ * mrz — the machine-readable strip, set in monospace at the foot
7094
+ *
7095
+ * Every role is optional but `number`: a bank card has no `mrz`, a library card no `photo`,
7096
+ * and the card simply omits the part it was given nothing for.
6829
7097
  *
6830
7098
  * @layer block
6831
7099
  * @base group
@@ -6836,16 +7104,21 @@ function table(opts) {
6836
7104
  function idcard(opts) {
6837
7105
  const overline = opts.overline !== void 0 ? slot("idcard", "overline", opts.overline) : void 0;
6838
7106
  const number = slot("idcard", "number", opts.number);
7107
+ const photo = opts.photo !== void 0 ? slot("idcard", "photo", opts.photo) : void 0;
7108
+ const mrz = opts.mrz !== void 0 ? slot("idcard", "mrz", opts.mrz) : void 0;
6839
7109
  return {
6840
7110
  ...group({
6841
- fields: merge("idcard", overline, number, opts.meta),
7111
+ fields: merge("idcard", overline, number, photo, opts.meta, opts.footer, mrz),
6842
7112
  ui: { kind: "idcard" }
6843
7113
  }),
6844
7114
  view: {
6845
7115
  kind: "idcard",
6846
7116
  overline: overline && Object.keys(overline)[0],
6847
7117
  number: Object.keys(number)[0],
6848
- meta: Object.keys(opts.meta ?? {})
7118
+ photo: photo && Object.keys(photo)[0],
7119
+ meta: Object.keys(opts.meta ?? {}),
7120
+ footer: Object.keys(opts.footer ?? {}),
7121
+ mrz: mrz && Object.keys(mrz)[0]
6849
7122
  }
6850
7123
  };
6851
7124
  }
@@ -6930,6 +7203,8 @@ var blocks = {
6930
7203
  nutrition,
6931
7204
  specimen,
6932
7205
  manifest,
7206
+ assay,
7207
+ flow,
6933
7208
  table,
6934
7209
  idcard,
6935
7210
  properties,
@@ -7777,6 +8052,11 @@ function group(o) {
7777
8052
  for (const f of fields) if (!("key" in f && f.key !== void 0) && !hasChildren(f)) throw new Error(`[field.group] a 'nest' group's children must be fields or groups, not layout elements`);
7778
8053
  for (const k of r.unique ?? []) if (!fields.some((f) => "key" in f && f.key === k)) throw new Error(`[field.group] unique key '${k}' is not a subfield`);
7779
8054
  } else if (o.value !== void 0) throw new Error(`[field.group] value requires scope 'nest' — a hoist group owns no children to write`);
8055
+ const title = v.title ?? [];
8056
+ if (title.length > 0) {
8057
+ if (!o.multiple) throw new Error(`[field.group] ui.title heads a collection's ROWS — it needs multiple: true`);
8058
+ for (const name of title) if (!fields.some((f) => "key" in f && f.key === name)) throw new Error(`[field.group] ui.title '${name}' is not one of its fields`);
8059
+ }
7780
8060
  return {
7781
8061
  el: "group",
7782
8062
  scope,
@@ -7789,6 +8069,7 @@ function group(o) {
7789
8069
  display: v.display ?? "wrap",
7790
8070
  kind: v.kind,
7791
8071
  fixed: r.fixed,
8072
+ view: title.length > 0 ? { title } : void 0,
7792
8073
  fields
7793
8074
  };
7794
8075
  }
@@ -7937,6 +8218,12 @@ function divider(o) {
7937
8218
  /**
7938
8219
  * Reference markdown block by i18n key.
7939
8220
  *
8221
+ * The key is resolved WITH the variables of the running instance, so the locale string
8222
+ * may name facts that are not constants: `{{origin}}` is this instance's own address
8223
+ * (`https://coffer.example`), which is how a block documents an endpoint without
8224
+ * freezing a developer's `localhost` into every translation. The set is fixed and
8225
+ * ambient — the block declares only its key. See `web-ui/src/render/info-text.ts`.
8226
+ *
7940
8227
  * @layer primitive
7941
8228
  * @prim —
7942
8229
  * @widget info
@@ -7949,6 +8236,31 @@ function info(textKey) {
7949
8236
  };
7950
8237
  }
7951
8238
  /**
8239
+ * What else points AT this record — the inverse of a relation, which is often the more useful
8240
+ * direction on a record page: a box is more usefully "what is in it" than "what it is in".
8241
+ *
8242
+ * Stores NOTHING and has no column: it declares where to look, and the server answers by asking
8243
+ * the pointing shelf. That is why it is a pseudo-element beside `divider` and `info` rather than
8244
+ * a field — there is no value here to validate or save.
8245
+ *
8246
+ * `from` names the pointing side explicitly (which shelf, and WHICH of its fields), never
8247
+ * "everything that happens to point here": a shelf may point at the same target through two
8248
+ * fields — a transaction has a source account and a destination account — and a panel that
8249
+ * merged them would answer a question nobody asked.
8250
+ *
8251
+ * @layer primitive
8252
+ * @prim —
8253
+ * @widget backrefs
8254
+ * @example f.backrefs({ label: 'mod.fields.storedHere', from: { library: 'things', shelf: 'item', field: 'location' } })
8255
+ */
8256
+ function backrefs(o) {
8257
+ return {
8258
+ el: "backrefs",
8259
+ label: o.label,
8260
+ from: o.from
8261
+ };
8262
+ }
8263
+ /**
7952
8264
  * Action button: invokes the handler registered in actionRegistry under the key `value`.
7953
8265
  *
7954
8266
  * @layer primitive
@@ -8273,20 +8585,22 @@ registerType("time", {
8273
8585
  prim: "time",
8274
8586
  widget: "time",
8275
8587
  build(o) {
8276
- const granularity = o.config?.granularity ?? "second";
8588
+ const granularity = o.config?.granularity ?? "minute";
8589
+ const s = string$1(reqErr()).regex({
8590
+ hour: /^([01]\d|2[0-3])$/,
8591
+ minute: /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/,
8592
+ second: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
8593
+ }[granularity], { message: vmsg("time_format") });
8277
8594
  return {
8278
8595
  column: "time",
8279
- zod: string$1(reqErr()).regex({
8280
- hour: /^([01]\d|2[0-3])$/,
8281
- minute: /^([01]\d|2[0-3]):[0-5]\d$/,
8282
- second: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
8283
- }[granularity], { message: vmsg("time_format") }),
8596
+ zod: granularity === "minute" ? s.transform((v) => v.slice(0, 5)) : s,
8284
8597
  hints: { granularity }
8285
8598
  };
8286
8599
  }
8287
8600
  });
8288
8601
  /**
8289
- * Time-of-day value with configurable granularity ('hour' | 'minute' | 'second', default 'second').
8602
+ * Time-of-day value with configurable granularity ('hour' | 'minute' | 'second', default
8603
+ * 'minute' — the same default `f.datetime` has always had).
8290
8604
  *
8291
8605
  * @layer primitive
8292
8606
  * @prim time
@@ -9531,7 +9845,8 @@ var PRIMITIVES = {
9531
9845
  url,
9532
9846
  divider,
9533
9847
  info,
9534
- button
9848
+ button,
9849
+ backrefs
9535
9850
  };
9536
9851
  /** Assembles `f`, guaranteeing no preset/block shadows a primitive. */
9537
9852
  function composeF(presets, blocks) {