tosijs-floorplan 0.4.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/README.md CHANGED
@@ -90,6 +90,7 @@ One flat record per wired element. Producers may add fields beyond these —
90
90
  | `contentEditable` | `boolean` | an editable region — treated as an input field |
91
91
  | `interactive` | `boolean` | the producer's **assertion** that this element can be acted on — for producers that cannot introspect handlers (React delegates at a root; vanilla `addEventListener` is not enumerable from page script). Asserting is truth-telling; fabricating `on` to unlock the styling would be a lie in the payload. A binding framework never needs it |
92
92
  | `editable` | `boolean` | the producer's assertion that text goes in here — the DOM-side counterpart of `contentEditable` / a two-way binding |
93
+ | `secret` | `boolean` | the producer **withheld** facts about this element (a secret-marked region: a token lives in the destination, so neither `label` nor `href` is published). **Fail-closed on the renderer side**: a secret record's label/text/value/placeholder/href/**image** never reach the drawing or the legend even if a producer bug leaves them in the record, and any truthy `secret` scrubs (malformed errs toward withholding) — it draws `<tag> [withheld]` and its legend entry says `redacted: true` (structural records included) — "no destination" and "destination withheld" are different facts |
93
94
  | `on` | `Record<string, string \| string[]>` | handlers by event type — a path when nameable, `ƒ` (or `ƒ name`) when not |
94
95
  | `list` | `{path, idPath?}` | this element renders a collection (drawn as *ground*, not figure) |
95
96
  | `structural` | `boolean` | structure, not affordance (headings, landmarks, containers) |
@@ -120,14 +121,28 @@ content — MUST neutralize both tokens inside data at the source**, as
120
121
  tosijs ≥ 1.8.0 does. This is normative because of an honest residual: a
121
122
  forged arrow in *suffix* position on a bindable field (`"data ⟷ fake.path"`
122
123
  as the entire text) is structurally indistinguishable from a real binding —
123
- renderer-side defense ends where the format's own syntax begins.
124
+ renderer-side defense ends where the format's own syntax begins. And the
125
+ bindable set is **open by design** (bound props ride under their own keys),
126
+ so the never-scanned list is a denylist over an open key set: any key a
127
+ producer invents is bindable, and arrows in it are trusted as structure.
128
+ The defense is narrowed, not closed — producer-side neutralization remains
129
+ the actual perimeter (issue #11). The capability scan (below) likewise
130
+ counts an arrow in **any position** within a bindable field: under the
131
+ format's own last-occurrence parse, "contains an arrow" and "has a
132
+ structural arrow" are equivalent for a lone token, so a position check
133
+ would add no security — only the #11 perimeter does.
124
134
 
125
135
  **Producers that cannot introspect handlers** (React's synthetic delegation,
126
136
  Angular's compiler output, vanilla `addEventListener` — none enumerable from
127
137
  page script) assert the affordance instead: `interactive` / `editable`, per
128
138
  record. When a map draws affordance-shaped boxes but **no** record carries
129
- any evidence at all (no `on`, `href`, `contentEditable`, two-way binding, or
130
- assertion), the result carries a `note` — and the svg's `<desc>` repeats it —
139
+ affordance evidence (no `on`, `href`, `contentEditable`, two-way binding, or
140
+ assertion) **and none carries capability evidence either** — a handler, an
141
+ assertion, or a provenance arrow in a bindable field *anywhere in the map*,
142
+ display-only `⟵` included, proves the producer can see wiring (#10: a
143
+ read-only dashboard from a binding framework is a sighted map of an inert
144
+ page, not a blind map) — the result carries a `note`, and the svg's
145
+ `<desc>` repeats it,
131
146
  because "nothing here is actionable" and "the producer couldn't tell" are
132
147
  different statements, and a consumer must never mistake the second for the
133
148
  first. Two caveats pin the semantics: **partial evidence does not establish
@@ -182,10 +197,22 @@ the text is (the text-only rule exempted exactly the header-row-of-icons
182
197
  case the check was built for; haltija's issue #2 caught it). A producer
183
198
  with DOM access computes the exception *properly* (computed display +
184
199
  parent text nodes) and ships the finding via `flags` — that is the
185
- **intended path** for DOM producers; a producer flag whose `kind` mentions
186
- `target` **supersedes** the built-in audit, so the two never double-mark.
187
- Both rules are exported (`isInteractive`, `targetSizeFinding`) so audits
188
- share this implementation instead of keeping a drifting copy.
200
+ **intended path** for DOM producers. A producer flag whose `kind` is one
201
+ of the **target-claim kinds** (`TARGET_FLAG_KINDS`, case-insensitive:
202
+ `target`, `target-size`, `targetsize`, `target_size`, `smalltarget`)
203
+ **supersedes** the drawn audit, so the two never double-mark — an explicit
204
+ set, because a substring match let `target-ok` stand the audit down (#8);
205
+ new target-claim kinds are added to `TARGET_FLAG_KINDS` via an issue here,
206
+ the same additive path as any other format change, and the set is
207
+ read-only by contract.
208
+ Supersession is a *drawing* concern and therefore **opt-in**:
209
+ `targetSizeFinding` ignores producer flags by default (an audit wants the
210
+ geometry verdict regardless of what got drawn); `schematic()` passes
211
+ `honorProducerFlags: true`. Hidden is not small: zero-size records are
212
+ never undersized (#9). Both rules are exported (`isInteractive`,
213
+ `targetSizeFinding`) so audits share this implementation instead of
214
+ keeping a drifting copy — and as of 0.5.0 they reproduce an audit's
215
+ verdicts without consumer-side normalization (#7/#8/#9/#13).
189
216
 
190
217
  Captions tell the truth in priority order: a held **value** wins (as
191
218
  `label: value` when both are known), an empty control falls back to its
@@ -202,7 +229,8 @@ geometry genuinely runs out.
202
229
  | `schematic(description, options?)` | the renderer's primary form — returns `{ svg, legend, note? }`: the drawing, the metadata it could not legibly carry (cramped/truncated/undersized records, keyed by index/ref), and — when no record carries affordance evidence — the note saying so. **Pair every raster with its legend.** |
203
230
  | `schematicSVG(description, options?)` | `schematic().svg` — the string-only form; each `<g>` carries `data-record="<i>"` linking back to `description.wiring[i]` (the image as index) |
204
231
  | `isInteractive(record)` | "can I act here?" — the single implementation (handlers, `href`, `contentEditable`, a structural two-way binding, or the producer's assertion; ground never). Exported so audits consume it instead of keeping a drifting copy |
205
- | `targetSizeFinding(record, targetSize?)` | the WCAG 2.5.8 rule with the settled exemptions (toggles, text-sized links, producer-flag supersession) — the measured legend fact, or `null` |
232
+ | `targetSizeFinding(record, targetSize?, {honorProducerFlags?})` | the WCAG 2.5.8 rule with the settled exemptions (toggles, text-sized links, zero-size; producer-flag supersession only when honoured — the renderer's setting, not an audit's) — the measured legend fact, or `null` |
233
+ | `TARGET_FLAG_KINDS` | the flag kinds that claim to *be* a target-size finding and may supersede the drawn audit |
206
234
  | `rasterizeSVG(svg, {scale})` | SVG → PNG Blob for vision encoders (browser canvas; under bun/node use `@resvg/resvg-js` — rasterize at 2× so labels OCR cleanly) |
207
235
  | `boundsOf(element)` | an element's page-coordinate bounds — the natural `within` argument |
208
236
  | `BOUND_TO_DOM`, `BOUND_TWO_WAY` | the provenance tokens |
package/dist/index.d.ts CHANGED
@@ -93,6 +93,13 @@ export interface SchematicRecord {
93
93
  /** the producer's assertion that text goes in here — the DOM-side
94
94
  * counterpart of contentEditable/two-way bindings (issue #3) */
95
95
  editable?: boolean;
96
+ /** the producer WITHHELD facts about this element (tosijs 1.11.0's
97
+ * secret regions: a magic-link token lives in the href, so neither
98
+ * label nor href is published). Drawn with a `[withheld]` caption when
99
+ * nothing else names it, and the legend says redacted — "this link has
100
+ * no destination" and "its destination was withheld" are different
101
+ * facts (issue #15) */
102
+ secret?: boolean;
96
103
  [boundProp: string]: unknown;
97
104
  }
98
105
  /** the map: only `wiring` is read. The named optional fields are the
@@ -188,6 +195,9 @@ export interface SchematicLegendEntry {
188
195
  /** interactive element below the target-size floor, e.g.
189
196
  * "18×13 — below 24×24 (WCAG 2.5.8)" */
190
197
  undersized?: string;
198
+ /** the producer withheld facts about this record (`secret: true`) —
199
+ * a missing href here means "withheld", not "no destination" */
200
+ redacted?: boolean;
191
201
  }
192
202
  export interface SchematicResult {
193
203
  svg: string;
@@ -234,7 +244,16 @@ export declare const TARGET_SIZE_DEFAULT = 24;
234
244
  * finding via `flags`; that is the INTENDED path for DOM producers, and
235
245
  * the built-in never double-marks over it.
236
246
  */
237
- export declare const targetSizeFinding: (w: SchematicRecord, targetSize?: number) => string | null;
247
+ /** flag kinds that claim to BE a target-size finding, and therefore
248
+ * supersede the built-in audit when the renderer honours producer flags.
249
+ * An explicit set, not a substring match: `includes('target')` let
250
+ * 'target-ok' — or any kind merely mentioning the word — silently stand
251
+ * the audit down (#8). Covers both producers' kinds in the wild
252
+ * (haltija: 'target', 'smallTarget'; tosijs auditFlags: 'target-size'). */
253
+ export declare const TARGET_FLAG_KINDS: ReadonlySet<string>;
254
+ export declare const targetSizeFinding: (w: SchematicRecord, targetSize?: number, { honorProducerFlags }?: {
255
+ honorProducerFlags?: boolean | undefined;
256
+ }) => string | null;
238
257
  export declare const schematic: (description: SchematicDescription, options?: SchematicOptions) => SchematicResult;
239
258
  /** the string-only form — schematic().svg, kept for drop-in compatibility */
240
259
  export declare const schematicSVG: (description: SchematicDescription, options?: SchematicOptions) => string;
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ var NEVER_BOUND = new Set([
30
30
  var hasTwoWayBinding = (w) => Object.entries(w).some(([key, v]) => !NEVER_BOUND.has(key) && boundTwoWay(v));
31
31
  var hasActEvidence = (w) => w.on != null || w.interactive === true || typeof w.href === "string" && w.href !== "";
32
32
  var hasEditEvidence = (w) => w.editable === true || w.contentEditable === true || hasTwoWayBinding(w);
33
+ var hasCapabilityEvidence = (w) => w.on != null || w.interactive === true || w.editable === true || Object.entries(w).some(([key, v]) => !NEVER_BOUND.has(key) && typeof v === "string" && (v.includes(BOUND_TWO_WAY) || v.includes(BOUND_TO_DOM)));
33
34
  var boundsOf = (element) => {
34
35
  const rect = element.getBoundingClientRect();
35
36
  return {
@@ -39,21 +40,30 @@ var boundsOf = (element) => {
39
40
  height: Math.round(rect.height)
40
41
  };
41
42
  };
42
- var isGround = (w) => w.structural === true || w.list != null && w.on == null;
43
+ var isGround = (w) => w.structural === true || w.list != null && !hasActEvidence(w) && !hasEditEvidence(w);
43
44
  var isInteractive = (w) => !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w));
44
45
  var TARGET_SIZE_DEFAULT = 24;
45
- var targetSizeFinding = (w, targetSize = TARGET_SIZE_DEFAULT) => {
46
+ var TARGET_FLAG_KINDS = new Set([
47
+ "target",
48
+ "target-size",
49
+ "targetsize",
50
+ "target_size",
51
+ "smalltarget"
52
+ ]);
53
+ var targetSizeFinding = (w, targetSize = TARGET_SIZE_DEFAULT, { honorProducerFlags = false } = {}) => {
46
54
  if (targetSize <= 0 || w.bounds == null || !isInteractive(w))
47
55
  return null;
56
+ const { width, height } = w.bounds;
57
+ if (width <= 0 || height <= 0)
58
+ return null;
48
59
  if (w.type === "checkbox" || w.type === "radio")
49
60
  return null;
50
- if (w.tag === "a" && typeof w.text === "string" && w.text !== "" && w.bounds.width > w.bounds.height) {
61
+ if (w.tag === "a" && typeof w.text === "string" && w.text !== "" && width > height) {
51
62
  return null;
52
63
  }
53
- if (Array.isArray(w.flags) && w.flags.some((f) => f.kind.toLowerCase().includes("target"))) {
64
+ if (honorProducerFlags && Array.isArray(w.flags) && w.flags.some((f) => typeof f?.kind === "string" && TARGET_FLAG_KINDS.has(f.kind.toLowerCase()))) {
54
65
  return null;
55
66
  }
56
- const { width, height } = w.bounds;
57
67
  return width < targetSize || height < targetSize ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : null;
58
68
  };
59
69
  var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
@@ -123,8 +133,8 @@ var schematic = (description, options = {}) => {
123
133
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
124
134
  ];
125
135
  const drawOrder = [...boxes].sort((a, b) => Number(isGround(b)) - Number(isGround(a)));
126
- const blind = boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive);
127
- const note = blind ? "no record carries affordance evidence (on, href, contentEditable, " + "a two-way binding, or an interactive/editable assertion) — " + '"nothing here is actionable" is NOT established; a producer that ' + "cannot introspect handlers should assert `interactive`/`editable` " + "per record (see README)" : undefined;
136
+ const blind = boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive) && !description.wiring.some(hasCapabilityEvidence);
137
+ const note = blind ? "no record carries affordance evidence (on, href, contentEditable, " + "a two-way binding, or an interactive/editable assertion), and none " + "shows the producer can see wiring at all (no handler, assertion, " + 'or provenance arrow anywhere) — "nothing here is actionable" is ' + "NOT established; a producer that cannot introspect handlers " + "should assert `interactive`/`editable` per record (see README)" : undefined;
128
138
  for (const w of drawOrder) {
129
139
  const index = description.wiring.indexOf(w);
130
140
  const pinOffsetX = w.viewportFixed === true ? minX + pad : 0;
@@ -134,9 +144,12 @@ var schematic = (description, options = {}) => {
134
144
  const { width, height } = w.bounds;
135
145
  const isContainer = w.viewportFixed !== true && boxes.some((other) => other !== w && other.viewportFixed !== true && contains(w.bounds, other.bounds));
136
146
  const toggle = w.type === "checkbox" || w.type === "radio";
147
+ const secret = Boolean(w.secret);
137
148
  let caption;
138
149
  let hint = false;
139
- if (isContainer) {
150
+ if (secret) {
151
+ caption = `<${w.tag}> [withheld]`;
152
+ } else if (isContainer) {
140
153
  caption = String(w.label ?? "");
141
154
  } else if (toggle) {
142
155
  caption = String(w.label ?? "");
@@ -160,9 +173,11 @@ var schematic = (description, options = {}) => {
160
173
  const fill = structural ? "none" : w.style != null ? w.style.background : "transparent";
161
174
  const stroke = !structural && w.style != null && w.style.borderColor !== TRANSPARENT ? w.style.borderColor : "currentColor";
162
175
  const color = w.style != null ? w.style.color : "currentColor";
163
- const drawImage = !structural && typeof w.image === "string" && w.image.startsWith("data:");
176
+ const drawImage = !structural && !secret && typeof w.image === "string" && w.image.startsWith("data:");
164
177
  const cramped = !structural && (height < minLabelHeight || width < fontSize * 3);
165
- const undersized = targetSizeFinding(w, targetSize);
178
+ const undersized = targetSizeFinding(w, targetSize, {
179
+ honorProducerFlags: true
180
+ });
166
181
  const emphasis = structural ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"' : w.disabled === true ? ' opacity="0.4"' : actable ? ' stroke-width="2"' : "";
167
182
  parts.push(`<g data-record="${index}"${w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ""}>`);
168
183
  if (w.type === "radio") {
@@ -185,10 +200,10 @@ var schematic = (description, options = {}) => {
185
200
  }
186
201
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
187
202
  w.flags.forEach((flag, at) => {
188
- parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${flagColor(flag.severity)}" data-flag="${esc(flag.kind)}"/>`);
203
+ parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${flagColor(flag?.severity)}" ` + `data-flag="${esc(typeof flag?.kind === "string" ? flag.kind : "")}"/>`);
189
204
  });
190
205
  const first = w.flags[0];
191
- if (first.label && height >= minLabelHeight) {
206
+ if (typeof first?.label === "string" && first.label !== "" && height >= minLabelHeight) {
192
207
  parts.push(`<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` + `width="${first.label.length * 4.5 + 2}" height="8" ` + `fill="white" opacity="0.85"/>`, `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` + `font-size="7" font-family="monospace" ` + `fill="${flagColor(first.severity)}">` + `${esc(neutralizeArrows(first.label))}</text>`);
193
208
  }
194
209
  }
@@ -228,13 +243,13 @@ var schematic = (description, options = {}) => {
228
243
  const elided = { index, tag: w.tag };
229
244
  if (w.ref != null)
230
245
  elided.ref = String(w.ref);
231
- if (typeof w.href === "string" && w.href !== "" && !structural) {
246
+ if (typeof w.href === "string" && w.href !== "" && !structural && !secret) {
232
247
  elided.href = w.href;
233
248
  }
234
249
  if (cramped || truncated) {
235
250
  if (shownCaption !== "" && !toggle)
236
251
  elided.caption = caption;
237
- const heldValue = shownValue(w.value);
252
+ const heldValue = secret ? undefined : shownValue(w.value);
238
253
  if (heldValue)
239
254
  elided.value = heldValue;
240
255
  if (cramped) {
@@ -253,7 +268,9 @@ var schematic = (description, options = {}) => {
253
268
  elided.disabled = true;
254
269
  if (undersized != null)
255
270
  elided.undersized = undersized;
256
- const inLegend = elided.caption != null || elided.href != null || elided.value != null || elided.editable != null || elided.required != null || elided.flags != null || elided.invalid != null || elided.disabled != null || elided.undersized != null;
271
+ if (secret)
272
+ elided.redacted = true;
273
+ const inLegend = elided.redacted != null || elided.caption != null || elided.href != null || elided.value != null || elided.editable != null || elided.required != null || elided.flags != null || elided.invalid != null || elided.disabled != null || elided.undersized != null;
257
274
  if (inLegend)
258
275
  legend.push(elided);
259
276
  if (showIndex || w.ref != null || inLegend) {
@@ -334,6 +351,7 @@ var rasterizeSVG = (svg, options = {}) => {
334
351
  export {
335
352
  BOUND_TO_DOM,
336
353
  BOUND_TWO_WAY,
354
+ TARGET_FLAG_KINDS,
337
355
  TARGET_SIZE_DEFAULT,
338
356
  boundsOf,
339
357
  isInteractive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tosijs-floorplan",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Render an agent-surface map (plain records) as a floorplan SVG — the affordance grammar as a pure, dependency-free function. No DOM, no framework. Formerly tosijs-schematic.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/index.ts CHANGED
@@ -79,6 +79,13 @@ export interface SchematicRecord {
79
79
  /** the producer's assertion that text goes in here — the DOM-side
80
80
  * counterpart of contentEditable/two-way bindings (issue #3) */
81
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
82
89
  [boundProp: string]: unknown
83
90
  }
84
91
 
@@ -175,6 +182,9 @@ export interface SchematicLegendEntry {
175
182
  /** interactive element below the target-size floor, e.g.
176
183
  * "18×13 — below 24×24 (WCAG 2.5.8)" */
177
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
178
188
  }
179
189
 
180
190
  export interface SchematicResult {
@@ -246,6 +256,24 @@ const hasActEvidence = (w: SchematicRecord): boolean =>
246
256
  const hasEditEvidence = (w: SchematicRecord): boolean =>
247
257
  w.editable === true || w.contentEditable === true || hasTwoWayBinding(w)
248
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
+
249
277
  /**
250
278
  * An element's page-coordinate bounds (the same space describe() records) —
251
279
  * the natural `within` argument for a region-scoped schematic.
@@ -261,9 +289,14 @@ export const boundsOf = (element: Element): SchematicBounds => {
261
289
  }
262
290
 
263
291
  // structure behind affordances — a LIST CONTAINER is ground too: it's
264
- // wired (the collection binds here), but its items are the affordances
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.
265
297
  const isGround = (w: SchematicRecord): boolean =>
266
- w.structural === true || (w.list != null && w.on == null)
298
+ w.structural === true ||
299
+ (w.list != null && !hasActEvidence(w) && !hasEditEvidence(w))
267
300
 
268
301
  /**
269
302
  * "Can I act here?" — the single implementation of the interactivity
@@ -298,27 +331,52 @@ export const TARGET_SIZE_DEFAULT = 24
298
331
  * finding via `flags`; that is the INTENDED path for DOM producers, and
299
332
  * the built-in never double-marks over it.
300
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
+
301
348
  export const targetSizeFinding = (
302
349
  w: SchematicRecord,
303
- targetSize = TARGET_SIZE_DEFAULT
350
+ targetSize = TARGET_SIZE_DEFAULT,
351
+ { honorProducerFlags = false } = {}
304
352
  ): string | null => {
305
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
306
359
  if (w.type === 'checkbox' || w.type === 'radio') return null
307
360
  if (
308
361
  w.tag === 'a' &&
309
362
  typeof w.text === 'string' &&
310
363
  w.text !== '' &&
311
- w.bounds.width > w.bounds.height
364
+ width > height
312
365
  ) {
313
366
  return null
314
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
315
371
  if (
372
+ honorProducerFlags &&
316
373
  Array.isArray(w.flags) &&
317
- w.flags.some((f) => f.kind.toLowerCase().includes('target'))
374
+ w.flags.some(
375
+ (f) => typeof f?.kind === 'string' && TARGET_FLAG_KINDS.has(f.kind.toLowerCase())
376
+ )
318
377
  ) {
319
378
  return null
320
379
  }
321
- const { width, height } = w.bounds
322
380
  return width < targetSize || height < targetSize
323
381
  ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
324
382
  : null
@@ -475,15 +533,21 @@ export const schematic = (
475
533
  // result says so instead of letting silence claim the first.
476
534
  // evidence is judged over the WHOLE wiring, not the drawn subset: a
477
535
  // `within` crop of a map whose evidence lies outside the region is not a
478
- // blind map, it's a blind REGION of a sighted one (review follow-up)
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.
479
540
  const blind =
480
- boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive)
541
+ boxes.some((w) => !isGround(w)) &&
542
+ !description.wiring.some(isInteractive) &&
543
+ !description.wiring.some(hasCapabilityEvidence)
481
544
  const note = blind
482
545
  ? 'no record carries affordance evidence (on, href, contentEditable, ' +
483
- 'a two-way binding, or an interactive/editable assertion) — ' +
484
- '"nothing here is actionable" is NOT established; a producer that ' +
485
- 'cannot introspect handlers should assert `interactive`/`editable` ' +
486
- 'per record (see README)'
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)'
487
551
  : undefined
488
552
  for (const w of drawOrder) {
489
553
  const index = description.wiring.indexOf(w)
@@ -512,9 +576,24 @@ export const schematic = (
512
576
  // back to its placeholder in italics (a hint must not read as content)
513
577
  // - everything else: label, then text, then value
514
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)
515
583
  let caption: string
516
584
  let hint = false
517
- 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) {
518
597
  caption = String(w.label ?? '')
519
598
  } else if (toggle) {
520
599
  caption = String(w.label ?? '')
@@ -572,7 +651,10 @@ export const schematic = (
572
651
  // embedded media first: pixels the producer captured, drawn in place —
573
652
  // everything else (state geometry, captions, badges) reads over it
574
653
  const drawImage =
575
- !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:')
576
658
  // CRAMPED: the box can't legibly carry its dress — draw it bare (shape,
577
659
  // state geometry, emphasis, focus) with an auto stamp pointing into the
578
660
  // legend, where the metadata actually lives. Toggles are exempt from
@@ -585,7 +667,9 @@ export const schematic = (
585
667
  // the toggle and inline-link exemptions, producer-flag supersession —
586
668
  // lives in the exported targetSizeFinding (issue #4: one
587
669
  // implementation, shared with tosijs's audit).
588
- const undersized = targetSizeFinding(w, targetSize)
670
+ const undersized = targetSizeFinding(w, targetSize, {
671
+ honorProducerFlags: true,
672
+ })
589
673
  const emphasis = structural
590
674
  ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"'
591
675
  : w.disabled === true
@@ -642,13 +726,17 @@ export const schematic = (
642
726
  // the LEFT edge — the unclaimed slot — plus the first flag's label
643
727
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
644
728
  w.flags.forEach((flag, at) => {
729
+ // kind is producer JSON too (#12) — same defence severity gets
645
730
  parts.push(
646
731
  `<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` +
647
- `fill="${flagColor(flag.severity)}" data-flag="${esc(flag.kind)}"/>`
732
+ `fill="${flagColor(flag?.severity)}" ` +
733
+ `data-flag="${esc(typeof flag?.kind === 'string' ? flag.kind : '')}"/>`
648
734
  )
649
735
  })
650
736
  const first = w.flags[0]
651
- if (first.label && height >= minLabelHeight) {
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) {
652
740
  parts.push(
653
741
  `<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` +
654
742
  `width="${first.label.length * 4.5 + 2}" height="8" ` +
@@ -764,12 +852,17 @@ export const schematic = (
764
852
  // a destination is always legend-worthy: it never fits a caption
765
853
  // legibly, and it's the fact an agent acts on ("goes to Y", not
766
854
  // "says X")
767
- 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
+ ) {
768
861
  elided.href = w.href
769
862
  }
770
863
  if (cramped || truncated) {
771
864
  if (shownCaption !== '' && !toggle) elided.caption = caption
772
- const heldValue = shownValue(w.value)
865
+ const heldValue = secret ? undefined : shownValue(w.value)
773
866
  if (heldValue) elided.value = heldValue
774
867
  if (cramped) {
775
868
  if (editable) elided.editable = true
@@ -782,7 +875,12 @@ export const schematic = (
782
875
  if (w.invalid === true && cramped) elided.invalid = true
783
876
  if (w.disabled === true && cramped) elided.disabled = true
784
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
785
882
  const inLegend =
883
+ elided.redacted != null ||
786
884
  elided.caption != null ||
787
885
  elided.href != null ||
788
886
  elided.value != null ||
@@ -556,6 +556,164 @@ describe('decorate — the plugin seam', () => {
556
556
  })
557
557
  })
558
558
 
559
+ describe('the 1.11.0 adoption feedback (0.5.0 — #7/#8/#9/#10/#12/#15)', () => {
560
+ const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
561
+
562
+ test('evidence beats container role: a list-bound <select> that IS the control is an affordance (#7)', () => {
563
+ const select = {
564
+ tag: 'select',
565
+ list: { path: 'app.options', idPath: 'id' },
566
+ value: 'b ⟷ app.choice',
567
+ bounds: { x: 10, y: 10, width: 20, height: 20 },
568
+ }
569
+ expect(isInteractive(select)).toBe(true)
570
+ expect(targetSizeFinding(select)).toBe('20×20 — below 24×24 (WCAG 2.5.8)')
571
+ const { svg } = schematic({ wiring: [select] })
572
+ expect(svg).not.toContain('stroke-dasharray') // control, not ground
573
+ // …while a plain list container without evidence of its own stays ground
574
+ expect(isInteractive({ tag: 'ul', list: { path: 'a.items' } })).toBe(false)
575
+ })
576
+
577
+ test('supersession cannot be bought with a kind that merely mentions target (#8)', () => {
578
+ const tiny = { tag: 'button', on: { click: 'ƒ' }, bounds: { x: 0, y: 0, width: 10, height: 10 } }
579
+ const finding = '10×10 — below 24×24 (WCAG 2.5.8)'
580
+ // claims to BE a target-size finding: supersedes (when honoured)
581
+ for (const kind of ['target', 'target-size', 'smallTarget', 'TARGET_SIZE']) {
582
+ expect(targetSizeFinding({ ...tiny, flags: [{ kind, label: 'x' }] }, 24, { honorProducerFlags: true })).toBeNull()
583
+ }
584
+ // merely mentions the word: does not
585
+ for (const kind of ['target-ok', 'TARGETS ARE FINE', 'retargeting']) {
586
+ expect(targetSizeFinding({ ...tiny, flags: [{ kind, label: 'x' }] }, 24, { honorProducerFlags: true })).toBe(finding)
587
+ }
588
+ })
589
+
590
+ test('hidden is not small: zero-size records are not undersized (#9)', () => {
591
+ expect(targetSizeFinding({ tag: 'button', on: { click: 'ƒ' }, bounds: { x: 0, y: 0, width: 0, height: 0 } })).toBeNull()
592
+ })
593
+
594
+ test('capability evidence suppresses the blind-map note: a read-only dashboard is not a blind map (#10)', () => {
595
+ const dashboard = schematic({
596
+ wiring: [
597
+ { tag: 'span', text: '21°C ⟵ dash.temp', bounds: at(10, 10) },
598
+ { tag: 'span', text: '40% ⟵ dash.humidity', bounds: at(10, 40) },
599
+ ],
600
+ })
601
+ // display bindings prove the producer can see wiring — nothing here is
602
+ // actionable, and that IS established
603
+ expect(dashboard.note).toBeUndefined()
604
+ })
605
+
606
+ test('a flags entry without kind neither throws nor colors outside the lines (#12)', () => {
607
+ const record = {
608
+ tag: 'button',
609
+ on: { click: 'ƒ' },
610
+ label: 'Go',
611
+ flags: [{ label: 'x' }] as any,
612
+ bounds: { x: 0, y: 0, width: 10, height: 10 },
613
+ }
614
+ expect(targetSizeFinding(record, 24, { honorProducerFlags: true })).toBe('10×10 — below 24×24 (WCAG 2.5.8)')
615
+ const { svg } = schematic({ wiring: [{ ...record, bounds: at(10, 10) }] })
616
+ expect(svg).toContain('data-flag=""')
617
+ })
618
+
619
+ test('redaction is FAIL-CLOSED: facts a producer bug left in a secret record never reach drawing or legend (review G1)', () => {
620
+ const { svg, legend } = schematic({
621
+ wiring: [
622
+ {
623
+ tag: 'a',
624
+ secret: true,
625
+ // the failure this guards: marked secret, but the withholding
626
+ // upstream didn't happen — the renderer must not republish
627
+ href: 'https://app.example/magic?token=SECRET123',
628
+ label: 'reset for ada@example.com',
629
+ text: 'leaky text',
630
+ value: 'SECRET123',
631
+ on: { click: 'ƒ' },
632
+ bounds: at(10, 10),
633
+ },
634
+ ],
635
+ })
636
+ const everything = svg + JSON.stringify(legend)
637
+ expect(everything).not.toContain('SECRET123')
638
+ expect(everything).not.toContain('ada@example.com')
639
+ expect(everything).not.toContain('leaky text')
640
+ expect(svg).toContain('&lt;a&gt; [withheld]')
641
+ expect(legend[0].redacted).toBe(true)
642
+ expect(legend[0].href).toBeUndefined()
643
+ expect(legend[0].value).toBeUndefined()
644
+ })
645
+
646
+ test('withheld PIXELS never draw: image joins the fail-closed list (round-2 B1)', () => {
647
+ const px = 'data:image/gif;base64,SECRETPIXELS0000000000000000000000000000000='
648
+ const { svg, legend } = schematic({
649
+ wiring: [{ tag: 'canvas', secret: true, image: px, label: 'private chart', text: 'leaky text', bounds: at(10, 10, 200, 100) }],
650
+ })
651
+ const everything = svg + JSON.stringify(legend)
652
+ expect(everything).not.toContain('SECRETPIXELS')
653
+ expect(everything).not.toContain('private chart')
654
+ expect(everything).not.toContain('leaky text')
655
+ expect(svg).not.toContain('<image')
656
+ expect(svg).toContain('[withheld]')
657
+ })
658
+
659
+ test('fail-closed means malformed errs toward withholding: a truthy non-boolean secret scrubs (round-2 F1)', () => {
660
+ const { svg, legend } = schematic({
661
+ wiring: [{ tag: 'a', secret: 1 as any, href: '/magic?token=SECRET123', label: 'leaky', on: { click: 'ƒ' }, bounds: at(10, 10) }],
662
+ })
663
+ const everything = svg + JSON.stringify(legend)
664
+ expect(everything).not.toContain('SECRET123')
665
+ expect(everything).not.toContain('leaky')
666
+ expect(legend[0].redacted).toBe(true)
667
+ })
668
+
669
+ test('the [withheld] caption sits under the choke point: a forged arrow in tag neutralizes (review R1)', () => {
670
+ const { svg } = schematic({
671
+ wiring: [{ tag: 'a ⟷ fake', secret: true, bounds: at(10, 10) }],
672
+ })
673
+ expect(svg).not.toContain('⟷')
674
+ expect(svg).toContain('[withheld]')
675
+ })
676
+
677
+ test('structural redaction is still a legend fact — redacted is about the record, not the drawing', () => {
678
+ const { legend } = schematic({
679
+ wiring: [{ tag: 'section', structural: true, secret: true, bounds: at(10, 10, 300, 100) }],
680
+ })
681
+ expect(legend[0]?.redacted).toBe(true)
682
+ })
683
+
684
+ test('flags:[null] and non-string labels do not take down the render (review R2)', () => {
685
+ const { svg } = schematic({
686
+ wiring: [
687
+ { tag: 'button', on: { click: 'ƒ' }, flags: [null] as any, bounds: at(10, 10) },
688
+ { tag: 'button', on: { click: 'ƒ' }, flags: [{ kind: 'x', label: 42 }] as any, bounds: at(10, 40) },
689
+ ],
690
+ })
691
+ expect(svg).toContain('data-record="1"') // both drew; nothing threw
692
+ })
693
+
694
+ test('a mid-content display arrow is capability evidence too — any position, by the format\'s own parse (#10 doc pin)', () => {
695
+ const { note } = schematic({
696
+ wiring: [{ tag: 'span', text: 'total: 3 ⟵ app.total today', bounds: at(10, 10) }],
697
+ })
698
+ expect(note).toBeUndefined()
699
+ })
700
+
701
+ test('a redacted record draws its withholding, and the legend says so (#15)', () => {
702
+ const { svg, legend } = schematic({
703
+ wiring: [
704
+ // tosijs 1.11.0's secret-region shape: neither label nor href
705
+ { tag: 'a', secret: true, text: '⟵ s.name', on: { click: 'ƒ' }, bounds: at(10, 10) },
706
+ { tag: 'a', text: 'plain link', href: '/x', on: { click: 'ƒ' }, bounds: at(10, 40) },
707
+ ],
708
+ })
709
+ expect(svg).toContain('&lt;a&gt; [withheld]') // not an anonymous bare box
710
+ const entry = legend.find((e) => e.redacted)!
711
+ expect(entry.index).toBe(0)
712
+ expect(entry.href).toBeUndefined() // withheld, and the legend SAYS withheld
713
+ expect(legend.find((e) => e.index === 1)!.redacted).toBeUndefined()
714
+ })
715
+ })
716
+
559
717
  describe('producer compatibility — interface-typed maps assign without casts', () => {
560
718
  test("a describe()-shaped interface flows straight in", () => {
561
719
  // mirrors tosijs's AgentDescription: an INTERFACE (no implicit index
@@ -949,7 +1107,10 @@ describe('producer-asserted affordance and defensive parsing (0.4.0 — #2/#3/#4
949
1107
  expect(targetSizeFinding(icon)).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
950
1108
  expect(targetSizeFinding(icon, 0)).toBeNull() // 0 disables
951
1109
  expect(targetSizeFinding({ ...icon, text: 'terms', bounds: { x: 0, y: 0, width: 34, height: 16 } })).toBeNull() // text-sized
952
- expect(targetSizeFinding({ ...icon, flags: [{ kind: 'target', label: '16x16' }] })).toBeNull() // producer supersedes
1110
+ // supersession is opt-in as of 0.5.0 (#8): the bare rule reports
1111
+ // geometry truth; only the renderer (no double bars) passes true
1112
+ expect(targetSizeFinding({ ...icon, flags: [{ kind: 'target', label: '16x16' }] })).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
1113
+ expect(targetSizeFinding({ ...icon, flags: [{ kind: 'target', label: '16x16' }] }, 24, { honorProducerFlags: true })).toBeNull()
953
1114
  expect(targetSizeFinding({ tag: 'input', type: 'checkbox', value: 'x ⟷ a.on', bounds: { x: 0, y: 0, width: 13, height: 13 } })).toBeNull() // toggles exempt
954
1115
  })
955
1116
  })