tosijs-floorplan 0.3.0 → 0.5.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/src/index.ts CHANGED
@@ -69,6 +69,23 @@ export interface SchematicRecord {
69
69
  * ("3") or bound ("3 ⟷ app.qty"). tosijs emits it as a bound prop; the
70
70
  * declared field gives plain-DOM producers the same home */
71
71
  value?: string
72
+ /** the producer's ASSERTION that this element can be acted on — for
73
+ * producers that cannot introspect handlers (React delegates at a root;
74
+ * vanilla addEventListener is not enumerable from page script). A binding
75
+ * framework never needs it: `on` and two-way bindings already say so.
76
+ * Asserting is truth-telling; fabricating `on` to unlock the styling
77
+ * would be a lie in the payload. (issue #3, haltija) */
78
+ interactive?: boolean
79
+ /** the producer's assertion that text goes in here — the DOM-side
80
+ * counterpart of contentEditable/two-way bindings (issue #3) */
81
+ editable?: boolean
82
+ /** the producer WITHHELD facts about this element (tosijs 1.11.0's
83
+ * secret regions: a magic-link token lives in the href, so neither
84
+ * label nor href is published). Drawn with a `[withheld]` caption when
85
+ * nothing else names it, and the legend says redacted — "this link has
86
+ * no destination" and "its destination was withheld" are different
87
+ * facts (issue #15) */
88
+ secret?: boolean
72
89
  [boundProp: string]: unknown
73
90
  }
74
91
 
@@ -165,25 +182,98 @@ export interface SchematicLegendEntry {
165
182
  /** interactive element below the target-size floor, e.g.
166
183
  * "18×13 — below 24×24 (WCAG 2.5.8)" */
167
184
  undersized?: string
185
+ /** the producer withheld facts about this record (`secret: true`) —
186
+ * a missing href here means "withheld", not "no destination" */
187
+ redacted?: boolean
168
188
  }
169
189
 
170
190
  export interface SchematicResult {
171
191
  svg: string
172
192
  legend: SchematicLegendEntry[]
193
+ /** set when the map draws affordance-shaped boxes but NO record carries
194
+ * any affordance evidence: "nothing here is actionable" and "the
195
+ * producer couldn't tell" are different statements, and a consumer
196
+ * acting on the first when the truth is the second is the
197
+ * confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
198
+ note?: string
173
199
  }
174
200
 
175
201
  // strip provenance from a bound-value string: "shown ⟷ path" → "shown"
176
202
  // (empty when the binding holds no value yet); a plain string (no arrow)
177
- // is a live-but-unbound value and passes through whole
203
+ // is a live-but-unbound value and passes through whole.
204
+ // The STRUCTURAL arrow is the LAST one in the string — the surface appends
205
+ // it, so everything before it is data, and data can carry arrow tokens
206
+ // (forged, or from a producer older than tosijs 1.8.0, which neutralizes
207
+ // them at the source). A renderer consumes maps it did not generate, so it
208
+ // parses defensively: split at the last arrow, and neutralize any arrow
209
+ // left INSIDE the shown value (geometry over glyphs — a rare glyph must
210
+ // never ride a caption run, and a fake arrow must never read as structure).
178
211
  const shownValue = (v: unknown): string | undefined => {
179
212
  if (typeof v !== 'string') return undefined
180
- for (const arrow of [BOUND_TWO_WAY, BOUND_TO_DOM]) {
181
- const at = v.indexOf(arrow)
182
- if (at >= 0) return v.slice(0, at).trim()
183
- }
184
- return v
213
+ const at = Math.max(v.lastIndexOf(BOUND_TWO_WAY), v.lastIndexOf(BOUND_TO_DOM))
214
+ return neutralizeArrows(at >= 0 ? v.slice(0, at).trim() : v)
215
+ }
216
+
217
+ // arrow tokens inside record data must neither ride a caption run
218
+ // (geometry over glyphs) nor read as structure — neutralized the same way
219
+ // tosijs ≥1.8.0 does at the source
220
+ const neutralizeArrows = (s: string): string =>
221
+ s.replaceAll(BOUND_TWO_WAY, '<->').replaceAll(BOUND_TO_DOM, '<-')
222
+
223
+ // is this string a live two-way binding? Only the arrow in STRUCTURAL
224
+ // position (last) counts — a ⟷ buried inside the data must not confer an
225
+ // affordance (a drawing that lies about what the page can do is worse than
226
+ // no drawing).
227
+ const boundTwoWay = (v: unknown): boolean => {
228
+ if (typeof v !== 'string') return false
229
+ const at = v.lastIndexOf(BOUND_TWO_WAY)
230
+ return at >= 0 && at > v.lastIndexOf(BOUND_TO_DOM)
185
231
  }
186
232
 
233
+ // fields the surface NEVER appends a binding arrow to: identity, naming,
234
+ // hints, destinations. In these, any arrow is data (or forgery) — the
235
+ // last-occurrence rule only protects fields that actually receive an
236
+ // appended binding, so these are excluded from the binding scan entirely
237
+ // (0.4.0 review B1: a lone forged arrow in a never-bindable field is
238
+ // always in "last = structural" position).
239
+ const NEVER_BOUND = new Set([
240
+ 'tag', 'id', 'part', 'role', 'label', 'placeholder', 'type',
241
+ 'description', 'href', 'ref', 'image',
242
+ ])
243
+ const hasTwoWayBinding = (w: SchematicRecord): boolean =>
244
+ Object.entries(w).some(
245
+ ([key, v]) => !NEVER_BOUND.has(key) && boundTwoWay(v)
246
+ )
247
+
248
+ // the two kinds of affordance evidence, split once and shared by the
249
+ // renderer AND the exported predicate — three independently edited copies
250
+ // of this logic is how the renderer and tosijs's audit drifted into
251
+ // contradicting each other (issue #4); parity is now by construction
252
+ const hasActEvidence = (w: SchematicRecord): boolean =>
253
+ w.on != null ||
254
+ w.interactive === true ||
255
+ (typeof w.href === 'string' && w.href !== '')
256
+ const hasEditEvidence = (w: SchematicRecord): boolean =>
257
+ w.editable === true || w.contentEditable === true || hasTwoWayBinding(w)
258
+
259
+ // can this producer SEE wiring at all? Any handler, any assertion, any
260
+ // provenance arrow — including a display-only ⟵ — proves it can (#10: a
261
+ // read-only dashboard from a binding framework is not a blind map, it's a
262
+ // sighted map of a page with nothing actionable on it)
263
+ const hasCapabilityEvidence = (w: SchematicRecord): boolean =>
264
+ w.on != null ||
265
+ w.interactive === true ||
266
+ w.editable === true ||
267
+ // arrows count only in bindable fields — an arrow in a never-bindable
268
+ // identity/name field is page content (possibly forged), and must not
269
+ // fabricate capability any more than it fabricates a binding
270
+ Object.entries(w).some(
271
+ ([key, v]) =>
272
+ !NEVER_BOUND.has(key) &&
273
+ typeof v === 'string' &&
274
+ (v.includes(BOUND_TWO_WAY) || v.includes(BOUND_TO_DOM))
275
+ )
276
+
187
277
  /**
188
278
  * An element's page-coordinate bounds (the same space describe() records) —
189
279
  * the natural `within` argument for a region-scoped schematic.
@@ -198,6 +288,100 @@ export const boundsOf = (element: Element): SchematicBounds => {
198
288
  }
199
289
  }
200
290
 
291
+ // structure behind affordances — a LIST CONTAINER is ground too: it's
292
+ // wired (the collection binds here), but its items are the affordances.
293
+ // EVIDENCE BEATS CONTAINER ROLE (#7): an element that is both container
294
+ // and control (a list-bound <select> carrying its own two-way value, a
295
+ // list div with handlers) is an affordance wearing a list, not ground —
296
+ // classifying it structural silenced error-severity audit findings.
297
+ const isGround = (w: SchematicRecord): boolean =>
298
+ w.structural === true ||
299
+ (w.list != null && !hasActEvidence(w) && !hasEditEvidence(w))
300
+
301
+ /**
302
+ * "Can I act here?" — the single implementation of the interactivity
303
+ * predicate, exported so audits (tosijs's auditAccessibility) consume THIS
304
+ * rather than keeping a drifting copy (issue #4: the two had already
305
+ * reached contradictory verdicts on the same element). Evidence, any of:
306
+ * handlers (`on`), a link destination (`href` — a link IS an affordance),
307
+ * `contentEditable`, a two-way binding in structural position, or the
308
+ * producer's own `interactive`/`editable` assertion (issue #3). Ground
309
+ * (structure, list containers) is never interactive.
310
+ */
311
+ export const isInteractive = (w: SchematicRecord): boolean =>
312
+ !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w))
313
+
314
+ /** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
315
+ * the option default and the exported rule cannot drift */
316
+ export const TARGET_SIZE_DEFAULT = 24
317
+
318
+ /**
319
+ * The WCAG 2.5.8 target-size rule, exported for the same reason as
320
+ * `isInteractive` (issue #4): one implementation, geometry judged where the
321
+ * geometry lives. Returns the measured finding string (the legend fact) or
322
+ * null. Embodies the settled exemptions: toggles (user-agent-sized); links
323
+ * plausibly sized by VISIBLE text — the WCAG inline exception as far as
324
+ * pure geometry can honour it, which is: has text, and the box is WIDER
325
+ * than tall, the shape text layout produces (issue #2 caught the earlier
326
+ * text-only rule exempting 16×16 icon links that happened to carry a
327
+ * label; an accessible name alone never sizes a box, and a square box was
328
+ * not sized by its text); and supersession by any producer-supplied flag
329
+ * whose kind mentions `target` — a producer with DOM access (computed
330
+ * display, parent text nodes) computes the real exception and ships the
331
+ * finding via `flags`; that is the INTENDED path for DOM producers, and
332
+ * the built-in never double-marks over it.
333
+ */
334
+ /** flag kinds that claim to BE a target-size finding, and therefore
335
+ * supersede the built-in audit when the renderer honours producer flags.
336
+ * An explicit set, not a substring match: `includes('target')` let
337
+ * 'target-ok' — or any kind merely mentioning the word — silently stand
338
+ * the audit down (#8). Covers both producers' kinds in the wild
339
+ * (haltija: 'target', 'smallTarget'; tosijs auditFlags: 'target-size'). */
340
+ export const TARGET_FLAG_KINDS: ReadonlySet<string> = new Set([
341
+ 'target',
342
+ 'target-size',
343
+ 'targetsize',
344
+ 'target_size',
345
+ 'smalltarget',
346
+ ])
347
+
348
+ export const targetSizeFinding = (
349
+ w: SchematicRecord,
350
+ targetSize = TARGET_SIZE_DEFAULT,
351
+ { honorProducerFlags = false } = {}
352
+ ): string | null => {
353
+ if (targetSize <= 0 || w.bounds == null || !isInteractive(w)) return null
354
+ const { width, height } = w.bounds
355
+ // hidden is not small (#9): a 0×0 (or unlaid-out) element is not a
356
+ // target too small to hit — the guard lives here so callers passing raw
357
+ // wiring don't each grow their own copy
358
+ if (width <= 0 || height <= 0) return null
359
+ if (w.type === 'checkbox' || w.type === 'radio') return null
360
+ if (
361
+ w.tag === 'a' &&
362
+ typeof w.text === 'string' &&
363
+ w.text !== '' &&
364
+ width > height
365
+ ) {
366
+ return null
367
+ }
368
+ // supersession is a DRAWING concern — no double bars for one finding —
369
+ // so it is opt-in (#8): schematic() passes true; an audit consuming this
370
+ // rule wants the geometry verdict regardless of what the producer drew
371
+ if (
372
+ honorProducerFlags &&
373
+ Array.isArray(w.flags) &&
374
+ w.flags.some(
375
+ (f) => typeof f?.kind === 'string' && TARGET_FLAG_KINDS.has(f.kind.toLowerCase())
376
+ )
377
+ ) {
378
+ return null
379
+ }
380
+ return width < targetSize || height < targetSize
381
+ ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
382
+ : null
383
+ }
384
+
201
385
  const intersects = (a: SchematicBounds, b: SchematicBounds): boolean =>
202
386
  a.x < b.x + b.width &&
203
387
  b.x < a.x + a.width &&
@@ -227,6 +411,14 @@ const FLAG_COLORS: Record<string, string> = {
227
411
  info: '#888888',
228
412
  }
229
413
 
414
+ // severity comes from producer JSON, so it can be anything — including
415
+ // 'constructor', which a bare index would resolve up the prototype chain
416
+ // into a function serialized straight into a fill attribute
417
+ const flagColor = (severity: unknown): string =>
418
+ typeof severity === 'string' && Object.hasOwn(FLAG_COLORS, severity)
419
+ ? FLAG_COLORS[severity]
420
+ : FLAG_COLORS.warn
421
+
230
422
  // greedy word-wrap: captions should USE vertical room, not truncate with
231
423
  // space to spare (a <p> that wraps on the real page has the same height
232
424
  // here). Returns at most maxLines lines, each at most maxChars long;
@@ -271,7 +463,7 @@ export const schematic = (
271
463
  fontSize = 11,
272
464
  within,
273
465
  index: showIndex = false,
274
- targetSize = 24,
466
+ targetSize = TARGET_SIZE_DEFAULT,
275
467
  legendNote = true,
276
468
  decorate,
277
469
  } = options
@@ -328,14 +520,35 @@ export const schematic = (
328
520
  } ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`,
329
521
  ]
330
522
  // structure behind affordances: dotted outlines the eye (and the raster)
331
- // reads as grouping, not controls. A LIST CONTAINER is ground too — it's
332
- // wired (the collection binds here, and the JSON record says so), but its
333
- // items are the affordances; drawing it solid would read as actionable.
334
- const ground = (w: (typeof boxes)[number]): boolean =>
335
- w.structural === true || (w.list != null && w.on == null)
523
+ // reads as grouping, not controls (isGround, module level — the exported
524
+ // predicates share it).
336
525
  const drawOrder = [...boxes].sort(
337
- (a, b) => Number(ground(b)) - Number(ground(a))
526
+ (a, b) => Number(isGround(b)) - Number(isGround(a))
338
527
  )
528
+ // NO AFFORDANCE EVIDENCE ANYWHERE: for a producer that cannot introspect
529
+ // handlers (issue #3), every record answers "can I act here?" with no —
530
+ // silently, which is the confident-wrong-answer failure. "Nothing here is
531
+ // actionable" and "I couldn't tell" are different statements; when the
532
+ // map draws non-ground boxes but not one record carries any evidence, the
533
+ // result says so instead of letting silence claim the first.
534
+ // evidence is judged over the WHOLE wiring, not the drawn subset: a
535
+ // `within` crop of a map whose evidence lies outside the region is not a
536
+ // blind map, it's a blind REGION of a sighted one (review follow-up).
537
+ // And CAPABILITY evidence counts (#10): a producer that emits handlers
538
+ // or provenance arrows anywhere demonstrably can see wiring — absence of
539
+ // affordance is then a fact about the page, not the producer's eyes.
540
+ const blind =
541
+ boxes.some((w) => !isGround(w)) &&
542
+ !description.wiring.some(isInteractive) &&
543
+ !description.wiring.some(hasCapabilityEvidence)
544
+ const note = blind
545
+ ? 'no record carries affordance evidence (on, href, contentEditable, ' +
546
+ 'a two-way binding, or an interactive/editable assertion), and none ' +
547
+ 'shows the producer can see wiring at all (no handler, assertion, ' +
548
+ 'or provenance arrow anywhere) — "nothing here is actionable" is ' +
549
+ 'NOT established; a producer that cannot introspect handlers ' +
550
+ 'should assert `interactive`/`editable` per record (see README)'
551
+ : undefined
339
552
  for (const w of drawOrder) {
340
553
  const index = description.wiring.indexOf(w)
341
554
  const pinOffsetX = w.viewportFixed === true ? minX + pad : 0
@@ -363,9 +576,24 @@ export const schematic = (
363
576
  // back to its placeholder in italics (a hint must not read as content)
364
577
  // - everything else: label, then text, then value
365
578
  const toggle = w.type === 'checkbox' || w.type === 'radio'
579
+ // fail-closed means malformed errs toward WITHHOLDING (review F1): a
580
+ // truthy non-boolean secret (secret: 1 from mangled producer JSON)
581
+ // must scrub, not leak — every redaction gate shares this coercion
582
+ const secret = Boolean(w.secret)
366
583
  let caption: string
367
584
  let hint = false
368
- if (isContainer) {
585
+ if (secret) {
586
+ // FAIL-CLOSED redaction (0.5.0 review G1 + round-2 B1): a secret
587
+ // record's withholdable facts — label, text, value, placeholder,
588
+ // href, and image (the captured pixels are the highest-bandwidth
589
+ // fact of all) — never reach the drawing, even when a producer bug
590
+ // left them in
591
+ // the record. `redacted` must never co-occur with the facts it
592
+ // claims were withheld; the marker is all a secret record says.
593
+ // (Assigned BEFORE the choke point so a forged arrow in `tag`
594
+ // still neutralizes — review R1.)
595
+ caption = `<${w.tag}> [withheld]`
596
+ } else if (isContainer) {
369
597
  caption = String(w.label ?? '')
370
598
  } else if (toggle) {
371
599
  caption = String(w.label ?? '')
@@ -398,18 +626,18 @@ export const schematic = (
398
626
  `<${w.tag}>`
399
627
  )
400
628
  }
401
- const structural = ground(w)
402
- // the affordance grammar, explicit: BOLD outline = wired to act (has
403
- // handlers); a trailing ⟷ on the caption = editable here (two-way
404
- // binding), added when the caption is a label that would otherwise
405
- // hide it. Solid = affordance, dotted = structure.
406
- const actable = !structural && w.on != null
407
- const editable =
408
- !structural &&
409
- (w.contentEditable === true ||
410
- Object.values(w).some(
411
- (v) => typeof v === 'string' && v.includes(BOUND_TWO_WAY)
412
- ))
629
+ // EVERY caption source (label, placeholder, href, tag fallback — not
630
+ // just the text/value paths shownValue serves) is neutralized here, at
631
+ // one choke point: the 0.4.0 review's B1 found the arrow defense
632
+ // bypassed by exactly the sources this line now covers
633
+ caption = neutralizeArrows(caption)
634
+ const structural = isGround(w)
635
+ // the affordance grammar, explicit: BOLD outline = wired to act —
636
+ // handlers, a link destination (href: a link IS an affordance, 0.4.0),
637
+ // or the producer's `interactive` word. The ↔ badge = editable here.
638
+ // Solid = affordance, dotted = structure.
639
+ const actable = !structural && hasActEvidence(w)
640
+ const editable = !structural && hasEditEvidence(w)
413
641
  const fill = structural
414
642
  ? 'none'
415
643
  : w.style != null
@@ -423,7 +651,10 @@ export const schematic = (
423
651
  // embedded media first: pixels the producer captured, drawn in place —
424
652
  // everything else (state geometry, captions, badges) reads over it
425
653
  const drawImage =
426
- !structural && typeof w.image === 'string' && w.image.startsWith('data:')
654
+ !structural &&
655
+ !secret && // B1: withheld pixels never draw
656
+ typeof w.image === 'string' &&
657
+ w.image.startsWith('data:')
427
658
  // CRAMPED: the box can't legibly carry its dress — draw it bare (shape,
428
659
  // state geometry, emphasis, focus) with an auto stamp pointing into the
429
660
  // legend, where the metadata actually lives. Toggles are exempt from
@@ -432,35 +663,13 @@ export const schematic = (
432
663
  !structural && (height < minLabelHeight || width < fontSize * 3)
433
664
  // UNDERSIZED: an interactive element below the target-size floor is a
434
665
  // usability defect in its own right (WCAG 2.5.8: 24×24 AA; 44/48 is the
435
- // platform touch bar) — toggles exempt as user-agent-sized controls
436
- const interactive =
437
- !structural &&
438
- (w.on != null || w.contentEditable === true ||
439
- Object.values(w).some(
440
- (v) => typeof v === 'string' && v.includes(BOUND_TWO_WAY)
441
- ))
442
- // WCAG 2.5.8 exempts inline targets sized by their text — flagging
443
- // prose links fires on every paragraph, and a check that cries wolf
444
- // gets ignored, taking the real findings with it. A pure renderer
445
- // can't see computed display, so: a link WITH text is presumed
446
- // text-sized and exempt (icon links — an <a> wrapping an <svg>, no
447
- // text — stay flagged). Producers with DOM access compute this
448
- // properly and ship it via `flags`, which also SUPERSEDES the built-in
449
- // audit here: no double amber bars for the same finding.
450
- const producerTargetFlag =
451
- Array.isArray(w.flags) &&
452
- w.flags.some((f) => f.kind.toLowerCase().includes('target'))
453
- const textSizedLink =
454
- w.tag === 'a' && typeof w.text === 'string' && w.text !== ''
455
- const undersized =
456
- targetSize > 0 &&
457
- interactive &&
458
- !producerTargetFlag &&
459
- !textSizedLink &&
460
- !(w.type === 'checkbox' || w.type === 'radio') &&
461
- (width < targetSize || height < targetSize)
462
- ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
463
- : undefined
666
+ // platform touch bar). The whole rule — the interactivity predicate,
667
+ // the toggle and inline-link exemptions, producer-flag supersession —
668
+ // lives in the exported targetSizeFinding (issue #4: one
669
+ // implementation, shared with tosijs's audit).
670
+ const undersized = targetSizeFinding(w, targetSize, {
671
+ honorProducerFlags: true,
672
+ })
464
673
  const emphasis = structural
465
674
  ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"'
466
675
  : w.disabled === true
@@ -517,22 +726,27 @@ export const schematic = (
517
726
  // the LEFT edge — the unclaimed slot — plus the first flag's label
518
727
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
519
728
  w.flags.forEach((flag, at) => {
520
- const color = FLAG_COLORS[flag.severity ?? 'warn'] ?? FLAG_COLORS.warn
729
+ // kind is producer JSON too (#12) — same defence severity gets
521
730
  parts.push(
522
731
  `<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` +
523
- `fill="${color}" data-flag="${esc(flag.kind)}"/>`
732
+ `fill="${flagColor(flag?.severity)}" ` +
733
+ `data-flag="${esc(typeof flag?.kind === 'string' ? flag.kind : '')}"/>`
524
734
  )
525
735
  })
526
736
  const first = w.flags[0]
527
- if (first.label && height >= minLabelHeight) {
528
- const flagColor = FLAG_COLORS[first.severity ?? 'warn'] ?? FLAG_COLORS.warn
737
+ // producer JSON to the last line (R2): flags:[null] and non-string
738
+ // labels must not take down the render — same defence the forEach got
739
+ if (typeof first?.label === 'string' && first.label !== '' && height >= minLabelHeight) {
529
740
  parts.push(
530
741
  `<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` +
531
742
  `width="${first.label.length * 4.5 + 2}" height="8" ` +
532
743
  `fill="white" opacity="0.85"/>`,
533
744
  `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` +
534
- `font-size="7" font-family="monospace" fill="${flagColor}">` +
535
- `${esc(first.label)}</text>`
745
+ `font-size="7" font-family="monospace" ` +
746
+ `fill="${flagColor(first.severity)}">` +
747
+ // the drawn label is a text RUN (neutralize: rare glyphs tofu);
748
+ // the legend's copy of flags stays verbatim, per the spec
749
+ `${esc(neutralizeArrows(first.label))}</text>`
536
750
  )
537
751
  }
538
752
  }
@@ -638,12 +852,17 @@ export const schematic = (
638
852
  // a destination is always legend-worthy: it never fits a caption
639
853
  // legibly, and it's the fact an agent acts on ("goes to Y", not
640
854
  // "says X")
641
- if (typeof w.href === 'string' && w.href !== '' && !structural) {
855
+ if (
856
+ typeof w.href === 'string' &&
857
+ w.href !== '' &&
858
+ !structural &&
859
+ !secret // G1: a withheld destination never reaches the legend
860
+ ) {
642
861
  elided.href = w.href
643
862
  }
644
863
  if (cramped || truncated) {
645
864
  if (shownCaption !== '' && !toggle) elided.caption = caption
646
- const heldValue = shownValue(w.value)
865
+ const heldValue = secret ? undefined : shownValue(w.value)
647
866
  if (heldValue) elided.value = heldValue
648
867
  if (cramped) {
649
868
  if (editable) elided.editable = true
@@ -656,7 +875,12 @@ export const schematic = (
656
875
  if (w.invalid === true && cramped) elided.invalid = true
657
876
  if (w.disabled === true && cramped) elided.disabled = true
658
877
  if (undersized != null) elided.undersized = undersized
878
+ // redaction ALWAYS rides the legend, structural included: redacted is
879
+ // a fact about the record, not a drawing concern — the consumer
880
+ // reading "no href" must be able to tell withheld from absent (#15)
881
+ if (secret) elided.redacted = true
659
882
  const inLegend =
883
+ elided.redacted != null ||
660
884
  elided.caption != null ||
661
885
  elided.href != null ||
662
886
  elided.value != null ||
@@ -711,20 +935,27 @@ export const schematic = (
711
935
  `details in legend — match by stamped number</text>`
712
936
  )
713
937
  }
938
+ const descBits: string[] = []
939
+ if (legend.length > 0) {
940
+ descBits.push(
941
+ `${description.wiring.length} records; ${legend.length} ` +
942
+ 'legend entries carry metadata the drawing could not — pair this ' +
943
+ 'image with its legend JSON (schematic().legend), matched by the ' +
944
+ 'stamped number / data-record index.'
945
+ )
946
+ }
947
+ if (note != null) descBits.push(note)
714
948
  parts[0] =
715
949
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${
716
950
  maxX - minX
717
951
  } ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${
718
952
  maxY - minY + footerExtra
719
953
  }">` +
720
- (legend.length > 0
721
- ? `<desc>${description.wiring.length} records; ${legend.length} ` +
722
- 'legend entries carry metadata the drawing could not — pair this ' +
723
- 'image with its legend JSON (schematic().legend), matched by the ' +
724
- 'stamped number / data-record index.</desc>'
725
- : '')
954
+ (descBits.length > 0 ? `<desc>${esc(descBits.join(' '))}</desc>` : '')
726
955
  parts.push('</svg>')
727
- return { svg: parts.join(''), legend }
956
+ const result: SchematicResult = { svg: parts.join(''), legend }
957
+ if (note != null) result.note = note
958
+ return result
728
959
  }
729
960
 
730
961
  /** the string-only form — schematic().svg, kept for drop-in compatibility */