@coffer-org/plugin-transit 7.0.0 → 7.1.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.
@@ -15,9 +15,9 @@ export default defineShelf({
15
15
  from: field.string({ label: 'transit.bus_route.fields.from', rules: { max: 100 }, ui: { span: 3 } }),
16
16
  to: field.string({ label: 'transit.bus_route.fields.to', rules: { max: 100 }, ui: { span: 3 } }),
17
17
  stop: field.string({ label: 'transit.bus_route.fields.stop', rules: { max: 200 }, ui: { span: 3 } }),
18
- weekday_times: field.time({ label: 'transit.bus_route.fields.weekday_times', multiple: true, ui: { span: 12 } }),
19
- saturday_times: field.time({ label: 'transit.bus_route.fields.saturday_times', multiple: true, ui: { span: 6 } }),
20
- sunday_times: field.time({ label: 'transit.bus_route.fields.sunday_times', multiple: true, ui: { span: 6 } }),
18
+ weekday_times: field.timetable({ label: 'transit.bus_route.fields.weekday_times', ui: { span: 12 } }),
19
+ saturday_times: field.timetable({ label: 'transit.bus_route.fields.saturday_times', ui: { span: 6 } }),
20
+ sunday_times: field.timetable({ label: 'transit.bus_route.fields.sunday_times', ui: { span: 6 } }),
21
21
  tags: field.tags({ label: 'transit.bus_route.fields.tags' }),
22
22
  notes: field.text({ label: 'transit.bus_route.fields.notes', rules: { max: 500 } }),
23
23
  },
package/dist/index.js CHANGED
@@ -13,7 +13,11 @@ export default definePlugin({
13
13
  id: 'transit',
14
14
  label: 'transit.library.label',
15
15
  icon: 'lucide:bus',
16
- agent: 'Local bus routes and their departure schedules (bus_route).',
16
+ agent: {
17
+ description: 'Local bus routes and their departure schedules (bus_route).',
18
+ instructions: 'bus_route: weekday_times/saturday_times/sunday_times — departure schedule.' +
19
+ ' When the schedule changes, update *_times via update_record.',
20
+ },
17
21
  starterHint: 'the next departures on the saved routes',
18
22
  }),
19
23
  shelves: [bus_route],
@@ -11,8 +11,4 @@ export const serverHooks = {
11
11
  },
12
12
  },
13
13
  ],
14
- agent: {
15
- instructions: 'bus_route: weekday_times/saturday_times/sunday_times — departure schedule.' +
16
- ' When the schedule changes, update *_times via update_record.',
17
- },
18
14
  };
package/dist/schema.js CHANGED
@@ -4701,6 +4701,13 @@ function wrapKey(key, opts, meta) {
4701
4701
  span: opts.span
4702
4702
  }
4703
4703
  };
4704
+ if (opts.faces !== void 0) m = {
4705
+ ...m,
4706
+ hints: {
4707
+ ...m.hints,
4708
+ faces: opts.faces
4709
+ }
4710
+ };
4704
4711
  if (opts.default !== void 0) m = {
4705
4712
  ...m,
4706
4713
  default: opts.default
@@ -4800,6 +4807,7 @@ function normalizeOpts(rawIn) {
4800
4807
  ...v.valueLabel !== void 0 ? { valueLabel: v.valueLabel } : {},
4801
4808
  compareWith: v.compareWith,
4802
4809
  span: v.span,
4810
+ faces: v.faces,
4803
4811
  emphasis: v.emphasis,
4804
4812
  noLabel: v.noLabel,
4805
4813
  role: v.role,
@@ -5708,6 +5716,63 @@ registerPreset("slug", "string", {}, {
5708
5716
  };
5709
5717
  }
5710
5718
  });
5719
+ /**
5720
+ * Identifier — a code printed for a machine and read back by one: a serial number, an order
5721
+ * number, a policy number, an IMEI. What a person actually DOES with one is copy it, which is
5722
+ * why this is a type of its own and not `f.string({ ui: { voice: 'data' } })`: the machine face
5723
+ * is one word on any string now, but the click that copies belongs to the kind.
5724
+ *
5725
+ * Deliberately unvalidated beyond a length: an identifier's format belongs to whoever issued
5726
+ * it, and a pattern here would reject the next manufacturer's.
5727
+ *
5728
+ * @layer preset
5729
+ * @base string
5730
+ * @prim text
5731
+ * @widget identifier
5732
+ * @example f.identifier({ label: 'mod.fields.serialNumber' })
5733
+ */
5734
+ function identifier(o) {
5735
+ return declare("identifier", o);
5736
+ }
5737
+ registerPreset("identifier", "string", {}, {
5738
+ kind: "identifier",
5739
+ widget: "identifier",
5740
+ build() {
5741
+ return {
5742
+ column: "text",
5743
+ zod: string$1(),
5744
+ hints: {}
5745
+ };
5746
+ }
5747
+ });
5748
+ /**
5749
+ * The machine-readable zone of a travel document — the identifier taken to its limit: printed
5750
+ * for a scanner, not for a person. Fixed pitch, chevrons kept as the filler they are, and the
5751
+ * line breaks preserved, because an MRZ's line structure is part of what it encodes.
5752
+ *
5753
+ * Unvalidated on purpose, like `identifier`: the ICAO line formats differ by document type, and
5754
+ * a strip transcribed from a real document is worth storing even when it does not check out.
5755
+ *
5756
+ * @layer preset
5757
+ * @base string
5758
+ * @prim text
5759
+ * @widget mrz
5760
+ * @example f.mrz({ label: 'documents.personal_document.fields.mrz' })
5761
+ */
5762
+ function mrz(o) {
5763
+ return declare("mrz", o);
5764
+ }
5765
+ registerPreset("mrz", "string", {}, {
5766
+ kind: "mrz",
5767
+ widget: "mrz",
5768
+ build() {
5769
+ return {
5770
+ column: "text",
5771
+ zod: string$1(),
5772
+ hints: {}
5773
+ };
5774
+ }
5775
+ });
5711
5776
  var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
5712
5777
  /**
5713
5778
  * Hex color — kind 'color', `#rrggbb` + swatch widget.
@@ -5992,6 +6057,38 @@ registerPreset("tag", "string", {}, {
5992
6057
  }
5993
6058
  });
5994
6059
  /**
6060
+ * A location written the way it is said aloud: workshop › shelf 2 › box. A `string` fixed to
6061
+ * `multiple`, exactly as `f.tags` is, because a place inside a place inside a place IS a
6062
+ * sequence — the plurality is the type, not a modifier someone remembered to add.
6063
+ *
6064
+ * Distinct from `f.tags`, which stores the same shape: tags are an unordered SET, and a path is
6065
+ * an ordered CHAIN where each step is inside the one before it. Drawn as chips, that
6066
+ * containment — the only thing the value actually carries — is lost.
6067
+ *
6068
+ * @layer preset
6069
+ * @base string
6070
+ * @prim text
6071
+ * @widget path
6072
+ * @example f.path({ label: 'things.storage_location.fields.path' })
6073
+ */
6074
+ function path(o) {
6075
+ return declare("path", {
6076
+ ...o,
6077
+ multiple: true
6078
+ });
6079
+ }
6080
+ registerPreset("path", "string", {}, {
6081
+ kind: "path",
6082
+ widget: "path",
6083
+ build() {
6084
+ return {
6085
+ column: "text",
6086
+ zod: string$1(),
6087
+ hints: {}
6088
+ };
6089
+ }
6090
+ });
6091
+ /**
5995
6092
  * Tags — `tag({ multiple: true })`.
5996
6093
  *
5997
6094
  * @layer preset
@@ -6184,6 +6281,56 @@ registerPreset("reminder", "date", { lead: 30 }, {
6184
6281
  };
6185
6282
  }
6186
6283
  });
6284
+ /** Timetable — a set of times of day, read as a schedule rather than as a bag of values.
6285
+ * kind 'timetable', prim 'time', always `multiple`.
6286
+ *
6287
+ * Same shape as `f.reminder`/`f.age` over `date`: a semantic type whose renderer knows what
6288
+ * the values MEAN. Everything it shows beyond the times themselves — when the service starts
6289
+ * and ends, how many runs there are, which one is next — is derived from the values at render
6290
+ * time, never stored: a "next departure" is wrong the moment it is written down, exactly the
6291
+ * argument `f.age` already makes for an age.
6292
+ *
6293
+ * Storage is a plain multiple `time`, so a field can be switched to this from
6294
+ * `f.time({ multiple: true })` and back with no migration.
6295
+ *
6296
+ * @layer preset
6297
+ * @base time
6298
+ * @prim time
6299
+ * @widget timetable
6300
+ * @example f.timetable({ label: 'mod.fields.weekdays' })
6301
+ */
6302
+ function timetable(o) {
6303
+ return declare("timetable", {
6304
+ ...o,
6305
+ multiple: true
6306
+ });
6307
+ }
6308
+ registerPreset("timetable", "time", {}, {
6309
+ kind: "timetable",
6310
+ widget: "timetable"
6311
+ });
6312
+ /** Age — date whose whole-year age (as of today) renders alongside it, e.g. a birth date.
6313
+ * kind 'age', prim 'date'. The age itself is computed at RENDER time, never stored: unlike a
6314
+ * `mutate`-driven computed field, it must be right on every day that passes, not only the day
6315
+ * the record was last written, so this is display metadata over `date`, the same shape as
6316
+ * `f.reminder`'s own relative-to-today note.
6317
+ *
6318
+ * @layer preset
6319
+ * @base date
6320
+ * @prim date
6321
+ * @widget age
6322
+ * @example f.age({ label: 'mod.fields.birthDate' })
6323
+ */
6324
+ function age(o) {
6325
+ return declare("age", o);
6326
+ }
6327
+ registerPreset("age", "date", {}, {
6328
+ kind: "age",
6329
+ widget: "age",
6330
+ build(o, parts) {
6331
+ return typeOf("date").build(o, parts);
6332
+ }
6333
+ });
6187
6334
  /**
6188
6335
  * Percentage 0..100 — real with rules:{min:0,max:100}.
6189
6336
  *
@@ -6376,6 +6523,9 @@ var presets = {
6376
6523
  email,
6377
6524
  tel,
6378
6525
  slug,
6526
+ path,
6527
+ identifier,
6528
+ mrz,
6379
6529
  color,
6380
6530
  colorname,
6381
6531
  title,
@@ -6389,6 +6539,8 @@ var presets = {
6389
6539
  rating,
6390
6540
  duration,
6391
6541
  reminder,
6542
+ age,
6543
+ timetable,
6392
6544
  percent,
6393
6545
  year,
6394
6546
  weight,
@@ -7046,26 +7198,38 @@ function identity(opts) {
7046
7198
  }
7047
7199
  };
7048
7200
  }
7049
- /** A scored value out of an optional max, with an optional verdict.
7201
+ /** One measure out of an optional max, from ONE OR MORE named sources, with an optional verdict.
7202
+ *
7203
+ * `source` takes several entries for the same reason `f.identity`'s `channels` does — it is a
7204
+ * role that holds a LIST, so `view.source` is an ordered list of names rather than one name.
7205
+ * Two ratings of the same film out of ten are one measure read twice, and drawing them apart is
7206
+ * what makes them incomparable: the eye has to carry the scale between two figures instead of
7207
+ * reading them against a shared one. `media/title` had exactly that, two `f.score` blocks of
7208
+ * `max: 10` held apart inside an `f.compare`, until this took the restriction off.
7209
+ *
7210
+ * NOT `multiple`. A multiple field is an anonymous array; these are NAMED sources, and the name
7211
+ * is what says which reading came from where. The two are different shapes and the block wants
7212
+ * this one.
7050
7213
  *
7051
7214
  * @layer block
7052
7215
  * @base group
7053
7216
  * @prim —
7054
7217
  * @widget score
7055
- * @example f.score({ source: { value: f.real({ label: '…' }) }, max: 100, verdict: { verdict: f.string({ label: '…' }) } })
7218
+ * @example f.score({ source: { imdb: f.real({ label: '…' }), tmdb: f.real({ label: '…' }) }, max: 10 })
7056
7219
  */
7057
7220
  function score(opts) {
7058
- const source = slot("score", "source", opts.source);
7221
+ const names = Object.keys(opts.source ?? {});
7222
+ if (names.length === 0) throw new Error(`[field.score] slot 'source' expects at least one entry, got 0`);
7059
7223
  const verdict = opts.verdict !== void 0 ? slot("score", "verdict", opts.verdict) : void 0;
7060
7224
  return {
7061
7225
  ...group({
7062
7226
  label: opts.label,
7063
- fields: merge("score", source, verdict),
7227
+ fields: merge("score", opts.source, verdict),
7064
7228
  ui: { kind: "score" }
7065
7229
  }),
7066
7230
  view: {
7067
7231
  kind: "score",
7068
- source: Object.keys(source)[0],
7232
+ source: names,
7069
7233
  max: opts.max,
7070
7234
  verdict: verdict && Object.keys(verdict)[0]
7071
7235
  }
@@ -7097,11 +7261,20 @@ function status(opts) {
7097
7261
  }
7098
7262
  /** A gauge value between an optional min and max, with an optional "of" total.
7099
7263
  *
7264
+ * `direction` is opt-in: when given, the gauge decides in plain JavaScript whether its own
7265
+ * value crossed the bound that matters (`meterTone`, `blocks/meter.tsx`) and reaches for the
7266
+ * tone palette — the fill, the value text and the `of` companion all move together, never
7267
+ * colour alone (a glyph rides along, see the renderer's own comment). Omit it and nothing
7268
+ * about the gauge changes from today. A `'ceiling'` gauge with no declared `max` (config or
7269
+ * the source field's own hints) resolves it from `of`'s own value instead — a budget's cap is
7270
+ * a per-record field, never a compile-time constant.
7271
+ *
7100
7272
  * @layer block
7101
7273
  * @base group
7102
7274
  * @prim —
7103
7275
  * @widget meter
7104
7276
  * @example f.meter({ source: { used: f.real({ label: '…' }) }, min: 0, max: 100 })
7277
+ * @example f.meter({ source: { spent: f.real({ label: '…' }) }, of: { budget: f.real({ label: '…' }) }, direction: 'ceiling' })
7105
7278
  */
7106
7279
  function meter(opts) {
7107
7280
  const source = slot("meter", "source", opts.source);
@@ -7117,7 +7290,8 @@ function meter(opts) {
7117
7290
  source: Object.keys(source)[0],
7118
7291
  min: opts.min,
7119
7292
  max: opts.max,
7120
- of: of && Object.keys(of)[0]
7293
+ of: of && Object.keys(of)[0],
7294
+ direction: opts.direction
7121
7295
  }
7122
7296
  };
7123
7297
  }
@@ -7177,6 +7351,11 @@ function balance(opts) {
7177
7351
  * @base group
7178
7352
  * @prim —
7179
7353
  * @widget route
7354
+ * A `stub` is the part of the ticket that is TORN OFF and kept — a seat, a gate, a booking
7355
+ * reference. Declaring one makes the block a ticket rather than a line: the two halves are
7356
+ * separated by a perforation, and the fields in the stub sit below it. Without one the block
7357
+ * is exactly what it was, a route from here to there, so no existing call changes.
7358
+ *
7180
7359
  * @example f.route({ from: { from: f.string({ label: '…' }) }, to: { to: f.string({ label: '…' }) } })
7181
7360
  */
7182
7361
  function route(opts) {
@@ -7185,9 +7364,10 @@ function route(opts) {
7185
7364
  const depart = opts.depart !== void 0 ? slot("route", "depart", opts.depart) : void 0;
7186
7365
  const arrive = opts.arrive !== void 0 ? slot("route", "arrive", opts.arrive) : void 0;
7187
7366
  const duration = opts.duration !== void 0 ? slot("route", "duration", opts.duration) : void 0;
7367
+ const stub = opts.stub ?? {};
7188
7368
  return {
7189
7369
  ...group({
7190
- fields: merge("route", from, to, depart, arrive, duration),
7370
+ fields: merge("route", from, to, depart, arrive, duration, stub),
7191
7371
  ui: { kind: "route" }
7192
7372
  }),
7193
7373
  view: {
@@ -7196,7 +7376,8 @@ function route(opts) {
7196
7376
  to: Object.keys(to)[0],
7197
7377
  depart: depart && Object.keys(depart)[0],
7198
7378
  arrive: arrive && Object.keys(arrive)[0],
7199
- duration: duration && Object.keys(duration)[0]
7379
+ duration: duration && Object.keys(duration)[0],
7380
+ stub: Object.keys(stub)
7200
7381
  }
7201
7382
  };
7202
7383
  }
@@ -7374,6 +7555,81 @@ function specimen(opts) {
7374
7555
  };
7375
7556
  }
7376
7557
  /**
7558
+ * Measurements against the range they were supposed to fall in, rendered from a single
7559
+ * collection source — see `stampCollection`. `analyte`, `value`, `unit`, `low` and `high` are
7560
+ * part KEYS inside each collection row, NOT `LayoutEl` positions — the same reasoning as
7561
+ * `manifest`'s `quantity`/`item`.
7562
+ *
7563
+ * The point is the COMPARISON. A `f.table` over the same rows prints the bounds as two more
7564
+ * columns and leaves the reader to do it; here each value sits on its own band with the
7565
+ * reference span marked on it, so "outside the range" is seen rather than worked out. That is
7566
+ * also the only thing on the row worth a colour — it is what the reader has to act on.
7567
+ *
7568
+ * `unit`, `low` and `high` are optional: a measurement with no published range (a culture, a
7569
+ * description) still belongs in the same list and simply gets no band.
7570
+ *
7571
+ * @layer block
7572
+ * @base group
7573
+ * @prim —
7574
+ * @widget assay
7575
+ * @example f.assay({ source: { results: f.group({ scope: 'nest', multiple: true, fields: { analyte: f.string({ label: '…' }), value: f.real({ label: '…' }) } }) }, analyte: 'analyte', value: 'value' })
7576
+ */
7577
+ function assay(opts) {
7578
+ return stampCollection("assay", opts.source, opts.label, {
7579
+ kind: "assay",
7580
+ analyte: opts.analyte,
7581
+ value: opts.value,
7582
+ ...opts.unit ? { unit: opts.unit } : {},
7583
+ ...opts.low ? { low: opts.low } : {},
7584
+ ...opts.high ? { high: opts.high } : {}
7585
+ });
7586
+ }
7587
+ /**
7588
+ * Value moving from one place to another, where the MOVEMENT is the subject — not one more
7589
+ * labelled row among the record's fields. The sum is set large in the machine voice, and the two
7590
+ * ends read as a path beneath it.
7591
+ *
7592
+ * amount — what moved. The one required role, and the reason the block exists
7593
+ * from — where it left. Absent on money that only arrived
7594
+ * to — where it arrived. Absent on money that only left
7595
+ * meta — the record's own remaining fields, under a rule: date, category, reference
7596
+ *
7597
+ * An absent end is not drawn, and that is the whole reading: money that left an account and
7598
+ * arrived nowhere IS an expense. It is read off which ends the record actually has, never
7599
+ * guessed from the shape of a value. What the movement MEANS beyond that — whether this
7600
+ * particular kind of transfer is good news — is carried by the classifier's own option `tone`,
7601
+ * the same declaration every other coloured value in the product uses.
7602
+ *
7603
+ * Distinct from `f.route`, which also has two ends: there the journey is the subject and the
7604
+ * ends are places, so it carries times and a duration; here the ends are accounts and the
7605
+ * subject is the quantity.
7606
+ *
7607
+ * @layer block
7608
+ * @base group
7609
+ * @prim —
7610
+ * @widget flow
7611
+ * @example f.flow({ amount: { amount: f.money({ label: '…' }) }, from: { source: f.relation({ label: '…' }) } })
7612
+ */
7613
+ function flow(opts) {
7614
+ const amount = slot("flow", "amount", opts.amount);
7615
+ const from = opts.from !== void 0 ? slot("flow", "from", opts.from) : void 0;
7616
+ const to = opts.to !== void 0 ? slot("flow", "to", opts.to) : void 0;
7617
+ return {
7618
+ ...group({
7619
+ label: opts.label,
7620
+ fields: merge("flow", amount, from, to, opts.meta),
7621
+ ui: { kind: "flow" }
7622
+ }),
7623
+ view: {
7624
+ kind: "flow",
7625
+ amount: Object.keys(amount)[0],
7626
+ from: from && Object.keys(from)[0],
7627
+ to: to && Object.keys(to)[0],
7628
+ meta: Object.keys(opts.meta ?? {})
7629
+ }
7630
+ };
7631
+ }
7632
+ /**
7377
7633
  * A packing/cargo manifest rendered from a single collection source — see `stampCollection`.
7378
7634
  * `quantity` and `item` are part KEYS inside each collection row, NOT `LayoutEl` positions —
7379
7635
  * the same reasoning as `journal`'s `date`/`text`.
@@ -7420,13 +7676,25 @@ function table(opts) {
7420
7676
  } : {},
7421
7677
  ...opts.groupBy ? { groupBy: opts.groupBy } : {},
7422
7678
  ...opts.totals ? { totals: opts.totals } : {},
7679
+ ...opts.summary ? { summary: opts.summary } : {},
7423
7680
  ...opts.numbered ? { numbered: true } : {}
7424
7681
  });
7425
7682
  }
7426
7683
  /**
7427
- * An ID-card header: an optional overline, a prominent number, and trailing meta fields.
7428
- * `view` names each role (Task 5) see `masthead`'s own doc comment for what `meta` means
7429
- * as an ordered name list.
7684
+ * An identity document, drawn as the card it is: a caption and its number across the top, a
7685
+ * portrait beside the holder's own fields, the issuing details under a rule, and the
7686
+ * machine-readable strip at the foot. `view` names each role (Task 5) — see `masthead`'s own
7687
+ * doc comment for what an ordered name list means.
7688
+ *
7689
+ * overline — the caption at the top left ("PASSPORT", "DRIVING LICENCE")
7690
+ * number — the document's number, set apart at the top right
7691
+ * photo — the portrait. One field, rendered in a portrait frame beside the body
7692
+ * meta — the card's OWN fields, labelled, at each field's declared span
7693
+ * footer — the fields below the rule: issued, authority, record number
7694
+ * mrz — the machine-readable strip, set in monospace at the foot
7695
+ *
7696
+ * Every role is optional but `number`: a bank card has no `mrz`, a library card no `photo`,
7697
+ * and the card simply omits the part it was given nothing for.
7430
7698
  *
7431
7699
  * @layer block
7432
7700
  * @base group
@@ -7437,16 +7705,21 @@ function table(opts) {
7437
7705
  function idcard(opts) {
7438
7706
  const overline = opts.overline !== void 0 ? slot("idcard", "overline", opts.overline) : void 0;
7439
7707
  const number = slot("idcard", "number", opts.number);
7708
+ const photo = opts.photo !== void 0 ? slot("idcard", "photo", opts.photo) : void 0;
7709
+ const mrz = opts.mrz !== void 0 ? slot("idcard", "mrz", opts.mrz) : void 0;
7440
7710
  return {
7441
7711
  ...group({
7442
- fields: merge("idcard", overline, number, opts.meta),
7712
+ fields: merge("idcard", overline, number, photo, opts.meta, opts.footer, mrz),
7443
7713
  ui: { kind: "idcard" }
7444
7714
  }),
7445
7715
  view: {
7446
7716
  kind: "idcard",
7447
7717
  overline: overline && Object.keys(overline)[0],
7448
7718
  number: Object.keys(number)[0],
7449
- meta: Object.keys(opts.meta ?? {})
7719
+ photo: photo && Object.keys(photo)[0],
7720
+ meta: Object.keys(opts.meta ?? {}),
7721
+ footer: Object.keys(opts.footer ?? {}),
7722
+ mrz: mrz && Object.keys(mrz)[0]
7450
7723
  }
7451
7724
  };
7452
7725
  }
@@ -7531,6 +7804,8 @@ var blocks = {
7531
7804
  nutrition,
7532
7805
  specimen,
7533
7806
  manifest,
7807
+ assay,
7808
+ flow,
7534
7809
  table,
7535
7810
  idcard,
7536
7811
  properties,
@@ -8393,6 +8668,11 @@ function group(o) {
8393
8668
  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`);
8394
8669
  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`);
8395
8670
  } else if (o.value !== void 0) throw new Error(`[field.group] value requires scope 'nest' — a hoist group owns no children to write`);
8671
+ const title = v.title ?? [];
8672
+ if (title.length > 0) {
8673
+ if (!o.multiple) throw new Error(`[field.group] ui.title heads a collection's ROWS — it needs multiple: true`);
8674
+ 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`);
8675
+ }
8396
8676
  return {
8397
8677
  el: "group",
8398
8678
  scope,
@@ -8405,6 +8685,7 @@ function group(o) {
8405
8685
  display: v.display ?? "wrap",
8406
8686
  kind: v.kind,
8407
8687
  fixed: r.fixed,
8688
+ view: title.length > 0 ? { title } : void 0,
8408
8689
  fields
8409
8690
  };
8410
8691
  }
@@ -8574,6 +8855,31 @@ function info(textKey) {
8574
8855
  };
8575
8856
  }
8576
8857
  /**
8858
+ * What else points AT this record — the inverse of a relation, which is often the more useful
8859
+ * direction on a record page: a box is more usefully "what is in it" than "what it is in".
8860
+ *
8861
+ * Stores NOTHING and has no column: it declares where to look, and the server answers by asking
8862
+ * the pointing shelf. That is why it is a pseudo-element beside `divider` and `info` rather than
8863
+ * a field — there is no value here to validate or save.
8864
+ *
8865
+ * `from` names the pointing side explicitly (which shelf, and WHICH of its fields), never
8866
+ * "everything that happens to point here": a shelf may point at the same target through two
8867
+ * fields — a transaction has a source account and a destination account — and a panel that
8868
+ * merged them would answer a question nobody asked.
8869
+ *
8870
+ * @layer primitive
8871
+ * @prim —
8872
+ * @widget backrefs
8873
+ * @example f.backrefs({ label: 'mod.fields.storedHere', from: { library: 'things', shelf: 'item', field: 'location' } })
8874
+ */
8875
+ function backrefs(o) {
8876
+ return {
8877
+ el: "backrefs",
8878
+ label: o.label,
8879
+ from: o.from
8880
+ };
8881
+ }
8882
+ /**
8577
8883
  * Action button: invokes the handler registered in actionRegistry under the key `value`.
8578
8884
  *
8579
8885
  * @layer primitive
@@ -8898,20 +9204,22 @@ registerType("time", {
8898
9204
  prim: "time",
8899
9205
  widget: "time",
8900
9206
  build(o) {
8901
- const granularity = o.config?.granularity ?? "second";
9207
+ const granularity = o.config?.granularity ?? "minute";
9208
+ const s = string$1(reqErr()).regex({
9209
+ hour: /^([01]\d|2[0-3])$/,
9210
+ minute: /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/,
9211
+ second: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
9212
+ }[granularity], { message: vmsg("time_format") });
8902
9213
  return {
8903
9214
  column: "time",
8904
- zod: string$1(reqErr()).regex({
8905
- hour: /^([01]\d|2[0-3])$/,
8906
- minute: /^([01]\d|2[0-3]):[0-5]\d$/,
8907
- second: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
8908
- }[granularity], { message: vmsg("time_format") }),
9215
+ zod: granularity === "minute" ? s.transform((v) => v.slice(0, 5)) : s,
8909
9216
  hints: { granularity }
8910
9217
  };
8911
9218
  }
8912
9219
  });
8913
9220
  /**
8914
- * Time-of-day value with configurable granularity ('hour' | 'minute' | 'second', default 'second').
9221
+ * Time-of-day value with configurable granularity ('hour' | 'minute' | 'second', default
9222
+ * 'minute' — the same default `f.datetime` has always had).
8915
9223
  *
8916
9224
  * @layer primitive
8917
9225
  * @prim time
@@ -10156,7 +10464,8 @@ var PRIMITIVES = {
10156
10464
  url,
10157
10465
  divider,
10158
10466
  info,
10159
- button
10467
+ button,
10468
+ backrefs
10160
10469
  };
10161
10470
  /** Assembles `f`, guaranteeing no preset/block shadows a primitive. */
10162
10471
  function composeF(presets, blocks) {
@@ -11058,19 +11367,16 @@ var bus_route_default = defineShelf({
11058
11367
  rules: { max: 200 },
11059
11368
  ui: { span: 3 }
11060
11369
  }),
11061
- weekday_times: field.time({
11370
+ weekday_times: field.timetable({
11062
11371
  label: "transit.bus_route.fields.weekday_times",
11063
- multiple: true,
11064
11372
  ui: { span: 12 }
11065
11373
  }),
11066
- saturday_times: field.time({
11374
+ saturday_times: field.timetable({
11067
11375
  label: "transit.bus_route.fields.saturday_times",
11068
- multiple: true,
11069
11376
  ui: { span: 6 }
11070
11377
  }),
11071
- sunday_times: field.time({
11378
+ sunday_times: field.timetable({
11072
11379
  label: "transit.bus_route.fields.sunday_times",
11073
- multiple: true,
11074
11380
  ui: { span: 6 }
11075
11381
  }),
11076
11382
  tags: field.tags({ label: "transit.bus_route.fields.tags" }),
@@ -11091,7 +11397,10 @@ var src_default = definePlugin({
11091
11397
  id: "transit",
11092
11398
  label: "transit.library.label",
11093
11399
  icon: "lucide:bus",
11094
- agent: "Local bus routes and their departure schedules (bus_route).",
11400
+ agent: {
11401
+ description: "Local bus routes and their departure schedules (bus_route).",
11402
+ instructions: "bus_route: weekday_times/saturday_times/sunday_times — departure schedule. When the schedule changes, update *_times via update_record."
11403
+ },
11095
11404
  starterHint: "the next departures on the saved routes"
11096
11405
  }),
11097
11406
  shelves: [bus_route_default]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-transit",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -26,9 +26,9 @@
26
26
  "test": "node --import tsx/esm --test 'src/runtime/*.test.ts'"
27
27
  },
28
28
  "dependencies": {
29
- "@coffer-org/sdk": "^7.0.0",
30
- "@coffer-org/server": "^7.0.0",
31
- "@coffer-org/helper-dav": "^7.0.0"
29
+ "@coffer-org/sdk": "^7.3.0",
30
+ "@coffer-org/server": "^7.3.0",
31
+ "@coffer-org/helper-dav": "^7.1.0"
32
32
  },
33
33
  "coffer": {
34
34
  "runtime": "node",