tosijs-floorplan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,8 @@ One flat record per wired element. Producers may add fields beyond these —
88
88
  | `required` | `boolean` | the field is required |
89
89
  | `disabled` | `boolean` | disabled right now |
90
90
  | `contentEditable` | `boolean` | an editable region — treated as an input field |
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
+ | `editable` | `boolean` | the producer's assertion that text goes in here — the DOM-side counterpart of `contentEditable` / a two-way binding |
91
93
  | `on` | `Record<string, string \| string[]>` | handlers by event type — a path when nameable, `ƒ` (or `ƒ name`) when not |
92
94
  | `list` | `{path, idPath?}` | this element renders a collection (drawn as *ground*, not figure) |
93
95
  | `structural` | `boolean` | structure, not affordance (headings, landmarks, containers) |
@@ -98,7 +100,44 @@ One flat record per wired element. Producers may add fields beyond these —
98
100
  **Provenance tokens** (exported as `BOUND_TO_DOM` / `BOUND_TWO_WAY`): a bound
99
101
  value reads `"<shown> <arrow> <path>"` — `⟵` means state flows to the DOM
100
102
  (display), `⟷` means two-way (a user-writable affordance). A plain string
101
- with no arrow is a live-but-unbound value.
103
+ with no arrow is a live-but-unbound value. The **structural arrow is the
104
+ LAST one in the string** — the surface appends it, so consumers must split
105
+ at the last occurrence, and an arrow token buried inside the data confers
106
+ nothing (the renderer parses defensively: it neutralizes interior arrows to
107
+ `<->` / `<-` in every drawn text run and in the legend's *display* fields —
108
+ `caption` and `value` are always neutralized — and never scans
109
+ identity/name fields (`tag`, `id`, `part`, `role`, `label`, `placeholder`,
110
+ `type`, `description`, `href`, `ref`, `image`) for bindings at all, since
111
+ the surface never appends an arrow to those). Two legend fields are
112
+ **verbatim, deliberately**: `href` is an opaque destination — rewriting
113
+ bytes inside a URL corrupts the one fact an agent acts on — and `flags`
114
+ are copied as the producer computed them. Consumers must never parse
115
+ provenance from either (they are in the never-scanned set; an arrow there
116
+ is data), and must not forward them into a caption-style text run without
117
+ neutralizing first. **Producers whose record
118
+ content derives from untrusted sources — any DOM extractor reading page
119
+ content — MUST neutralize both tokens inside data at the source**, as
120
+ tosijs ≥ 1.8.0 does. This is normative because of an honest residual: a
121
+ forged arrow in *suffix* position on a bindable field (`"data ⟷ fake.path"`
122
+ as the entire text) is structurally indistinguishable from a real binding —
123
+ renderer-side defense ends where the format's own syntax begins.
124
+
125
+ **Producers that cannot introspect handlers** (React's synthetic delegation,
126
+ Angular's compiler output, vanilla `addEventListener` — none enumerable from
127
+ page script) assert the affordance instead: `interactive` / `editable`, per
128
+ 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 —
131
+ because "nothing here is actionable" and "the producer couldn't tell" are
132
+ different statements, and a consumer must never mistake the second for the
133
+ first. Two caveats pin the semantics: **partial evidence does not establish
134
+ the rest** — on a map where some records carry evidence, a record without
135
+ any still means *unknown*, not *inert* (the note only marks the total-blindness
136
+ case; non-introspecting producers should assert per actable record, not rely
137
+ on the note); and **only `interactive: true` / `editable: true` are signal** —
138
+ `false` is indistinguishable from absent and cannot veto evidence the record
139
+ itself carries (`on`, `href`, a binding). "Introspected and found nothing"
140
+ currently has no encoding; propose one via issue before relying on it.
102
141
 
103
142
  **The picture is not the whole payload.** The renderer is *allowed to omit*:
104
143
  captions and badges below legibility thresholds move to the legend, keyed by
@@ -115,7 +154,7 @@ checkbox sizes.
115
154
 
116
155
  | you see | it means |
117
156
  | --- | --- |
118
- | **bold outline** | wired to act (has handlers) |
157
+ | **bold outline** | wired to act — handlers, a destination (`href`: a link IS an affordance), or the producer's `interactive` assertion |
119
158
  | `↔` badge, bottom-right | editable here (two-way binding, or contenteditable) |
120
159
  | caption ending `*` | required |
121
160
  | **red corner flag**, top-left | invalid *right now* — live ValidityState, the same truth `:invalid` styles |
@@ -133,14 +172,20 @@ checkbox sizes.
133
172
  | footer strip: "N elements with details in legend" | the image's confession that it isn't the whole map — fetch `schematic().legend` (a machine-readable `<desc>` says the same) |
134
173
 
135
174
  The target-size audit honours WCAG 2.5.8's **inline exception** as far as
136
- pure geometry can: a link *with text* is presumed sized by its text and is
137
- exempt (flagging prose links would fire on every paragraph a check that
138
- cries wolf gets ignored, taking the real findings with it). An icon link
139
- an `<a>` wrapping an `<svg>`, no text — stays flagged. A producer with DOM
140
- access can compute the exception *properly* (computed display + parent text
141
- nodes) and ship the finding via `flags`; a producer flag whose `kind`
142
- mentions `target` **supersedes** the built-in audit, so the two never
143
- double-mark.
175
+ pure geometry can: a link is exempt when it has text **and its box is wider
176
+ than tall** the shape text layout produces (flagging prose links would
177
+ fire on every paragraph a check that cries wolf gets ignored, taking the
178
+ real findings with it). Icon links stay flagged: an `<a>` wrapping an
179
+ `<svg>` with no text, and equally a **square** icon link that happens to
180
+ carry a label or a glyph a 16×16 box was not sized by its text, whatever
181
+ the text is (the text-only rule exempted exactly the header-row-of-icons
182
+ case the check was built for; haltija's issue #2 caught it). A producer
183
+ with DOM access computes the exception *properly* (computed display +
184
+ 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.
144
189
 
145
190
  Captions tell the truth in priority order: a held **value** wins (as
146
191
  `label: value` when both are known), an empty control falls back to its
@@ -154,8 +199,10 @@ geometry genuinely runs out.
154
199
 
155
200
  | export | what |
156
201
  | --- | --- |
157
- | `schematic(description, options?)` | the renderer's primary form — returns `{ svg, legend }`: the drawing plus the metadata it could not legibly carry (cramped/truncated/undersized records), keyed by index/ref. **Pair every raster with its legend.** |
202
+ | `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.** |
158
203
  | `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
+ | `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` |
159
206
  | `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) |
160
207
  | `boundsOf(element)` | an element's page-coordinate bounds — the natural `within` argument |
161
208
  | `BOUND_TO_DOM`, `BOUND_TWO_WAY` | the provenance tokens |
File without changes
package/dist/index.d.ts CHANGED
@@ -83,6 +83,16 @@ export interface SchematicRecord {
83
83
  * ("3") or bound ("3 ⟷ app.qty"). tosijs emits it as a bound prop; the
84
84
  * declared field gives plain-DOM producers the same home */
85
85
  value?: string;
86
+ /** the producer's ASSERTION that this element can be acted on — for
87
+ * producers that cannot introspect handlers (React delegates at a root;
88
+ * vanilla addEventListener is not enumerable from page script). A binding
89
+ * framework never needs it: `on` and two-way bindings already say so.
90
+ * Asserting is truth-telling; fabricating `on` to unlock the styling
91
+ * would be a lie in the payload. (issue #3, haltija) */
92
+ interactive?: boolean;
93
+ /** the producer's assertion that text goes in here — the DOM-side
94
+ * counterpart of contentEditable/two-way bindings (issue #3) */
95
+ editable?: boolean;
86
96
  [boundProp: string]: unknown;
87
97
  }
88
98
  /** the map: only `wiring` is read. The named optional fields are the
@@ -182,12 +192,49 @@ export interface SchematicLegendEntry {
182
192
  export interface SchematicResult {
183
193
  svg: string;
184
194
  legend: SchematicLegendEntry[];
195
+ /** set when the map draws affordance-shaped boxes but NO record carries
196
+ * any affordance evidence: "nothing here is actionable" and "the
197
+ * producer couldn't tell" are different statements, and a consumer
198
+ * acting on the first when the truth is the second is the
199
+ * confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
200
+ note?: string;
185
201
  }
186
202
  /**
187
203
  * An element's page-coordinate bounds (the same space describe() records) —
188
204
  * the natural `within` argument for a region-scoped schematic.
189
205
  */
190
206
  export declare const boundsOf: (element: Element) => SchematicBounds;
207
+ /**
208
+ * "Can I act here?" — the single implementation of the interactivity
209
+ * predicate, exported so audits (tosijs's auditAccessibility) consume THIS
210
+ * rather than keeping a drifting copy (issue #4: the two had already
211
+ * reached contradictory verdicts on the same element). Evidence, any of:
212
+ * handlers (`on`), a link destination (`href` — a link IS an affordance),
213
+ * `contentEditable`, a two-way binding in structural position, or the
214
+ * producer's own `interactive`/`editable` assertion (issue #3). Ground
215
+ * (structure, list containers) is never interactive.
216
+ */
217
+ export declare const isInteractive: (w: SchematicRecord) => boolean;
218
+ /** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
219
+ * the option default and the exported rule cannot drift */
220
+ export declare const TARGET_SIZE_DEFAULT = 24;
221
+ /**
222
+ * The WCAG 2.5.8 target-size rule, exported for the same reason as
223
+ * `isInteractive` (issue #4): one implementation, geometry judged where the
224
+ * geometry lives. Returns the measured finding string (the legend fact) or
225
+ * null. Embodies the settled exemptions: toggles (user-agent-sized); links
226
+ * plausibly sized by VISIBLE text — the WCAG inline exception as far as
227
+ * pure geometry can honour it, which is: has text, and the box is WIDER
228
+ * than tall, the shape text layout produces (issue #2 caught the earlier
229
+ * text-only rule exempting 16×16 icon links that happened to carry a
230
+ * label; an accessible name alone never sizes a box, and a square box was
231
+ * not sized by its text); and supersession by any producer-supplied flag
232
+ * whose kind mentions `target` — a producer with DOM access (computed
233
+ * display, parent text nodes) computes the real exception and ships the
234
+ * finding via `flags`; that is the INTENDED path for DOM producers, and
235
+ * the built-in never double-marks over it.
236
+ */
237
+ export declare const targetSizeFinding: (w: SchematicRecord, targetSize?: number) => string | null;
191
238
  export declare const schematic: (description: SchematicDescription, options?: SchematicOptions) => SchematicResult;
192
239
  /** the string-only form — schematic().svg, kept for drop-in compatibility */
193
240
  export declare const schematicSVG: (description: SchematicDescription, options?: SchematicOptions) => string;
package/dist/index.js CHANGED
@@ -4,13 +4,32 @@ var BOUND_TWO_WAY = "⟷";
4
4
  var shownValue = (v) => {
5
5
  if (typeof v !== "string")
6
6
  return;
7
- for (const arrow of [BOUND_TWO_WAY, BOUND_TO_DOM]) {
8
- const at = v.indexOf(arrow);
9
- if (at >= 0)
10
- return v.slice(0, at).trim();
11
- }
12
- return v;
7
+ const at = Math.max(v.lastIndexOf(BOUND_TWO_WAY), v.lastIndexOf(BOUND_TO_DOM));
8
+ return neutralizeArrows(at >= 0 ? v.slice(0, at).trim() : v);
9
+ };
10
+ var neutralizeArrows = (s) => s.replaceAll(BOUND_TWO_WAY, "<->").replaceAll(BOUND_TO_DOM, "<-");
11
+ var boundTwoWay = (v) => {
12
+ if (typeof v !== "string")
13
+ return false;
14
+ const at = v.lastIndexOf(BOUND_TWO_WAY);
15
+ return at >= 0 && at > v.lastIndexOf(BOUND_TO_DOM);
13
16
  };
17
+ var NEVER_BOUND = new Set([
18
+ "tag",
19
+ "id",
20
+ "part",
21
+ "role",
22
+ "label",
23
+ "placeholder",
24
+ "type",
25
+ "description",
26
+ "href",
27
+ "ref",
28
+ "image"
29
+ ]);
30
+ var hasTwoWayBinding = (w) => Object.entries(w).some(([key, v]) => !NEVER_BOUND.has(key) && boundTwoWay(v));
31
+ var hasActEvidence = (w) => w.on != null || w.interactive === true || typeof w.href === "string" && w.href !== "";
32
+ var hasEditEvidence = (w) => w.editable === true || w.contentEditable === true || hasTwoWayBinding(w);
14
33
  var boundsOf = (element) => {
15
34
  const rect = element.getBoundingClientRect();
16
35
  return {
@@ -20,6 +39,23 @@ var boundsOf = (element) => {
20
39
  height: Math.round(rect.height)
21
40
  };
22
41
  };
42
+ var isGround = (w) => w.structural === true || w.list != null && w.on == null;
43
+ var isInteractive = (w) => !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w));
44
+ var TARGET_SIZE_DEFAULT = 24;
45
+ var targetSizeFinding = (w, targetSize = TARGET_SIZE_DEFAULT) => {
46
+ if (targetSize <= 0 || w.bounds == null || !isInteractive(w))
47
+ return null;
48
+ if (w.type === "checkbox" || w.type === "radio")
49
+ return null;
50
+ if (w.tag === "a" && typeof w.text === "string" && w.text !== "" && w.bounds.width > w.bounds.height) {
51
+ return null;
52
+ }
53
+ if (Array.isArray(w.flags) && w.flags.some((f) => f.kind.toLowerCase().includes("target"))) {
54
+ return null;
55
+ }
56
+ const { width, height } = w.bounds;
57
+ return width < targetSize || height < targetSize ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : null;
58
+ };
23
59
  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;
24
60
  var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
25
61
  var esc = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -29,6 +65,7 @@ var FLAG_COLORS = {
29
65
  warn: "#e6a700",
30
66
  info: "#888888"
31
67
  };
68
+ var flagColor = (severity) => typeof severity === "string" && Object.hasOwn(FLAG_COLORS, severity) ? FLAG_COLORS[severity] : FLAG_COLORS.warn;
32
69
  var wrapCaption = (caption, maxChars, maxLines) => {
33
70
  if (maxLines <= 1 || caption.length <= maxChars) {
34
71
  return [caption.slice(0, maxChars + 2)];
@@ -64,7 +101,7 @@ var schematic = (description, options = {}) => {
64
101
  fontSize = 11,
65
102
  within,
66
103
  index: showIndex = false,
67
- targetSize = 24,
104
+ targetSize = TARGET_SIZE_DEFAULT,
68
105
  legendNote = true,
69
106
  decorate
70
107
  } = options;
@@ -85,8 +122,9 @@ var schematic = (description, options = {}) => {
85
122
  const parts = [
86
123
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
87
124
  ];
88
- const ground = (w) => w.structural === true || w.list != null && w.on == null;
89
- const drawOrder = [...boxes].sort((a, b) => Number(ground(b)) - Number(ground(a)));
125
+ 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;
90
128
  for (const w of drawOrder) {
91
129
  const index = description.wiring.indexOf(w);
92
130
  const pinOffsetX = w.viewportFixed === true ? minX + pad : 0;
@@ -115,18 +153,16 @@ var schematic = (description, options = {}) => {
115
153
  } else {
116
154
  caption = String(w.label ?? shownValue(w.text) ?? shownValue(w.value) ?? w.href ?? `<${w.tag}>`);
117
155
  }
118
- const structural = ground(w);
119
- const actable = !structural && w.on != null;
120
- const editable = !structural && (w.contentEditable === true || Object.values(w).some((v) => typeof v === "string" && v.includes(BOUND_TWO_WAY)));
156
+ caption = neutralizeArrows(caption);
157
+ const structural = isGround(w);
158
+ const actable = !structural && hasActEvidence(w);
159
+ const editable = !structural && hasEditEvidence(w);
121
160
  const fill = structural ? "none" : w.style != null ? w.style.background : "transparent";
122
161
  const stroke = !structural && w.style != null && w.style.borderColor !== TRANSPARENT ? w.style.borderColor : "currentColor";
123
162
  const color = w.style != null ? w.style.color : "currentColor";
124
163
  const drawImage = !structural && typeof w.image === "string" && w.image.startsWith("data:");
125
164
  const cramped = !structural && (height < minLabelHeight || width < fontSize * 3);
126
- const interactive = !structural && (w.on != null || w.contentEditable === true || Object.values(w).some((v) => typeof v === "string" && v.includes(BOUND_TWO_WAY)));
127
- const producerTargetFlag = Array.isArray(w.flags) && w.flags.some((f) => f.kind.toLowerCase().includes("target"));
128
- const textSizedLink = w.tag === "a" && typeof w.text === "string" && w.text !== "";
129
- const undersized = targetSize > 0 && interactive && !producerTargetFlag && !textSizedLink && !(w.type === "checkbox" || w.type === "radio") && (width < targetSize || height < targetSize) ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : undefined;
165
+ const undersized = targetSizeFinding(w, targetSize);
130
166
  const emphasis = structural ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"' : w.disabled === true ? ' opacity="0.4"' : actable ? ' stroke-width="2"' : "";
131
167
  parts.push(`<g data-record="${index}"${w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ""}>`);
132
168
  if (w.type === "radio") {
@@ -149,13 +185,11 @@ var schematic = (description, options = {}) => {
149
185
  }
150
186
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
151
187
  w.flags.forEach((flag, at) => {
152
- const color2 = FLAG_COLORS[flag.severity ?? "warn"] ?? FLAG_COLORS.warn;
153
- parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${color2}" data-flag="${esc(flag.kind)}"/>`);
188
+ parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${flagColor(flag.severity)}" data-flag="${esc(flag.kind)}"/>`);
154
189
  });
155
190
  const first = w.flags[0];
156
191
  if (first.label && height >= minLabelHeight) {
157
- const flagColor = FLAG_COLORS[first.severity ?? "warn"] ?? FLAG_COLORS.warn;
158
- 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}">` + `${esc(first.label)}</text>`);
192
+ 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>`);
159
193
  }
160
194
  }
161
195
  if (undersized != null) {
@@ -245,9 +279,18 @@ var schematic = (description, options = {}) => {
245
279
  if (footerExtra > 0) {
246
280
  parts.push(`<text x="${minX + pad}" y="${maxY + 10}" font-size="8" ` + `font-family="monospace" fill="currentColor" opacity="0.75">` + `${legend.length} element${legend.length === 1 ? "" : "s"} with ` + `details in legend — match by stamped number</text>`);
247
281
  }
248
- parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${maxY - minY + footerExtra}">` + (legend.length > 0 ? `<desc>${description.wiring.length} records; ${legend.length} ` + "legend entries carry metadata the drawing could not — pair this " + "image with its legend JSON (schematic().legend), matched by the " + "stamped number / data-record index.</desc>" : "");
282
+ const descBits = [];
283
+ if (legend.length > 0) {
284
+ descBits.push(`${description.wiring.length} records; ${legend.length} ` + "legend entries carry metadata the drawing could not — pair this " + "image with its legend JSON (schematic().legend), matched by the " + "stamped number / data-record index.");
285
+ }
286
+ if (note != null)
287
+ descBits.push(note);
288
+ parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${maxY - minY + footerExtra}">` + (descBits.length > 0 ? `<desc>${esc(descBits.join(" "))}</desc>` : "");
249
289
  parts.push("</svg>");
250
- return { svg: parts.join(""), legend };
290
+ const result = { svg: parts.join(""), legend };
291
+ if (note != null)
292
+ result.note = note;
293
+ return result;
251
294
  };
252
295
  var schematicSVG = (description, options = {}) => schematic(description, options).svg;
253
296
  var rasterizeSVG = (svg, options = {}) => {
@@ -289,10 +332,13 @@ var rasterizeSVG = (svg, options = {}) => {
289
332
  });
290
333
  };
291
334
  export {
292
- schematicSVG,
293
- schematic,
294
- rasterizeSVG,
295
- boundsOf,
335
+ BOUND_TO_DOM,
296
336
  BOUND_TWO_WAY,
297
- BOUND_TO_DOM
337
+ TARGET_SIZE_DEFAULT,
338
+ boundsOf,
339
+ isInteractive,
340
+ rasterizeSVG,
341
+ schematic,
342
+ schematicSVG,
343
+ targetSizeFinding
298
344
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tosijs-floorplan",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -27,6 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "bun build src/index.ts --outdir dist --format esm && tsc -p tsconfig.json",
29
29
  "test": "bun test",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit --emitDeclarationOnly false",
31
+ "stability": "bun tools/byte-stability.ts",
30
32
  "prepublishOnly": "bun test && bun run build"
31
33
  },
32
34
  "devDependencies": {
package/src/index.ts CHANGED
@@ -69,6 +69,16 @@ 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
72
82
  [boundProp: string]: unknown
73
83
  }
74
84
 
@@ -170,20 +180,72 @@ export interface SchematicLegendEntry {
170
180
  export interface SchematicResult {
171
181
  svg: string
172
182
  legend: SchematicLegendEntry[]
183
+ /** set when the map draws affordance-shaped boxes but NO record carries
184
+ * any affordance evidence: "nothing here is actionable" and "the
185
+ * producer couldn't tell" are different statements, and a consumer
186
+ * acting on the first when the truth is the second is the
187
+ * confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
188
+ note?: string
173
189
  }
174
190
 
175
191
  // strip provenance from a bound-value string: "shown ⟷ path" → "shown"
176
192
  // (empty when the binding holds no value yet); a plain string (no arrow)
177
- // is a live-but-unbound value and passes through whole
193
+ // is a live-but-unbound value and passes through whole.
194
+ // The STRUCTURAL arrow is the LAST one in the string — the surface appends
195
+ // it, so everything before it is data, and data can carry arrow tokens
196
+ // (forged, or from a producer older than tosijs 1.8.0, which neutralizes
197
+ // them at the source). A renderer consumes maps it did not generate, so it
198
+ // parses defensively: split at the last arrow, and neutralize any arrow
199
+ // left INSIDE the shown value (geometry over glyphs — a rare glyph must
200
+ // never ride a caption run, and a fake arrow must never read as structure).
178
201
  const shownValue = (v: unknown): string | undefined => {
179
202
  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
203
+ const at = Math.max(v.lastIndexOf(BOUND_TWO_WAY), v.lastIndexOf(BOUND_TO_DOM))
204
+ return neutralizeArrows(at >= 0 ? v.slice(0, at).trim() : v)
185
205
  }
186
206
 
207
+ // arrow tokens inside record data must neither ride a caption run
208
+ // (geometry over glyphs) nor read as structure — neutralized the same way
209
+ // tosijs ≥1.8.0 does at the source
210
+ const neutralizeArrows = (s: string): string =>
211
+ s.replaceAll(BOUND_TWO_WAY, '<->').replaceAll(BOUND_TO_DOM, '<-')
212
+
213
+ // is this string a live two-way binding? Only the arrow in STRUCTURAL
214
+ // position (last) counts — a ⟷ buried inside the data must not confer an
215
+ // affordance (a drawing that lies about what the page can do is worse than
216
+ // no drawing).
217
+ const boundTwoWay = (v: unknown): boolean => {
218
+ if (typeof v !== 'string') return false
219
+ const at = v.lastIndexOf(BOUND_TWO_WAY)
220
+ return at >= 0 && at > v.lastIndexOf(BOUND_TO_DOM)
221
+ }
222
+
223
+ // fields the surface NEVER appends a binding arrow to: identity, naming,
224
+ // hints, destinations. In these, any arrow is data (or forgery) — the
225
+ // last-occurrence rule only protects fields that actually receive an
226
+ // appended binding, so these are excluded from the binding scan entirely
227
+ // (0.4.0 review B1: a lone forged arrow in a never-bindable field is
228
+ // always in "last = structural" position).
229
+ const NEVER_BOUND = new Set([
230
+ 'tag', 'id', 'part', 'role', 'label', 'placeholder', 'type',
231
+ 'description', 'href', 'ref', 'image',
232
+ ])
233
+ const hasTwoWayBinding = (w: SchematicRecord): boolean =>
234
+ Object.entries(w).some(
235
+ ([key, v]) => !NEVER_BOUND.has(key) && boundTwoWay(v)
236
+ )
237
+
238
+ // the two kinds of affordance evidence, split once and shared by the
239
+ // renderer AND the exported predicate — three independently edited copies
240
+ // of this logic is how the renderer and tosijs's audit drifted into
241
+ // contradicting each other (issue #4); parity is now by construction
242
+ const hasActEvidence = (w: SchematicRecord): boolean =>
243
+ w.on != null ||
244
+ w.interactive === true ||
245
+ (typeof w.href === 'string' && w.href !== '')
246
+ const hasEditEvidence = (w: SchematicRecord): boolean =>
247
+ w.editable === true || w.contentEditable === true || hasTwoWayBinding(w)
248
+
187
249
  /**
188
250
  * An element's page-coordinate bounds (the same space describe() records) —
189
251
  * the natural `within` argument for a region-scoped schematic.
@@ -198,6 +260,70 @@ export const boundsOf = (element: Element): SchematicBounds => {
198
260
  }
199
261
  }
200
262
 
263
+ // structure behind affordances — a LIST CONTAINER is ground too: it's
264
+ // wired (the collection binds here), but its items are the affordances
265
+ const isGround = (w: SchematicRecord): boolean =>
266
+ w.structural === true || (w.list != null && w.on == null)
267
+
268
+ /**
269
+ * "Can I act here?" — the single implementation of the interactivity
270
+ * predicate, exported so audits (tosijs's auditAccessibility) consume THIS
271
+ * rather than keeping a drifting copy (issue #4: the two had already
272
+ * reached contradictory verdicts on the same element). Evidence, any of:
273
+ * handlers (`on`), a link destination (`href` — a link IS an affordance),
274
+ * `contentEditable`, a two-way binding in structural position, or the
275
+ * producer's own `interactive`/`editable` assertion (issue #3). Ground
276
+ * (structure, list containers) is never interactive.
277
+ */
278
+ export const isInteractive = (w: SchematicRecord): boolean =>
279
+ !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w))
280
+
281
+ /** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
282
+ * the option default and the exported rule cannot drift */
283
+ export const TARGET_SIZE_DEFAULT = 24
284
+
285
+ /**
286
+ * The WCAG 2.5.8 target-size rule, exported for the same reason as
287
+ * `isInteractive` (issue #4): one implementation, geometry judged where the
288
+ * geometry lives. Returns the measured finding string (the legend fact) or
289
+ * null. Embodies the settled exemptions: toggles (user-agent-sized); links
290
+ * plausibly sized by VISIBLE text — the WCAG inline exception as far as
291
+ * pure geometry can honour it, which is: has text, and the box is WIDER
292
+ * than tall, the shape text layout produces (issue #2 caught the earlier
293
+ * text-only rule exempting 16×16 icon links that happened to carry a
294
+ * label; an accessible name alone never sizes a box, and a square box was
295
+ * not sized by its text); and supersession by any producer-supplied flag
296
+ * whose kind mentions `target` — a producer with DOM access (computed
297
+ * display, parent text nodes) computes the real exception and ships the
298
+ * finding via `flags`; that is the INTENDED path for DOM producers, and
299
+ * the built-in never double-marks over it.
300
+ */
301
+ export const targetSizeFinding = (
302
+ w: SchematicRecord,
303
+ targetSize = TARGET_SIZE_DEFAULT
304
+ ): string | null => {
305
+ if (targetSize <= 0 || w.bounds == null || !isInteractive(w)) return null
306
+ if (w.type === 'checkbox' || w.type === 'radio') return null
307
+ if (
308
+ w.tag === 'a' &&
309
+ typeof w.text === 'string' &&
310
+ w.text !== '' &&
311
+ w.bounds.width > w.bounds.height
312
+ ) {
313
+ return null
314
+ }
315
+ if (
316
+ Array.isArray(w.flags) &&
317
+ w.flags.some((f) => f.kind.toLowerCase().includes('target'))
318
+ ) {
319
+ return null
320
+ }
321
+ const { width, height } = w.bounds
322
+ return width < targetSize || height < targetSize
323
+ ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
324
+ : null
325
+ }
326
+
201
327
  const intersects = (a: SchematicBounds, b: SchematicBounds): boolean =>
202
328
  a.x < b.x + b.width &&
203
329
  b.x < a.x + a.width &&
@@ -227,6 +353,14 @@ const FLAG_COLORS: Record<string, string> = {
227
353
  info: '#888888',
228
354
  }
229
355
 
356
+ // severity comes from producer JSON, so it can be anything — including
357
+ // 'constructor', which a bare index would resolve up the prototype chain
358
+ // into a function serialized straight into a fill attribute
359
+ const flagColor = (severity: unknown): string =>
360
+ typeof severity === 'string' && Object.hasOwn(FLAG_COLORS, severity)
361
+ ? FLAG_COLORS[severity]
362
+ : FLAG_COLORS.warn
363
+
230
364
  // greedy word-wrap: captions should USE vertical room, not truncate with
231
365
  // space to spare (a <p> that wraps on the real page has the same height
232
366
  // here). Returns at most maxLines lines, each at most maxChars long;
@@ -271,7 +405,7 @@ export const schematic = (
271
405
  fontSize = 11,
272
406
  within,
273
407
  index: showIndex = false,
274
- targetSize = 24,
408
+ targetSize = TARGET_SIZE_DEFAULT,
275
409
  legendNote = true,
276
410
  decorate,
277
411
  } = options
@@ -328,14 +462,29 @@ export const schematic = (
328
462
  } ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`,
329
463
  ]
330
464
  // 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)
465
+ // reads as grouping, not controls (isGround, module level the exported
466
+ // predicates share it).
336
467
  const drawOrder = [...boxes].sort(
337
- (a, b) => Number(ground(b)) - Number(ground(a))
468
+ (a, b) => Number(isGround(b)) - Number(isGround(a))
338
469
  )
470
+ // NO AFFORDANCE EVIDENCE ANYWHERE: for a producer that cannot introspect
471
+ // handlers (issue #3), every record answers "can I act here?" with no —
472
+ // silently, which is the confident-wrong-answer failure. "Nothing here is
473
+ // actionable" and "I couldn't tell" are different statements; when the
474
+ // map draws non-ground boxes but not one record carries any evidence, the
475
+ // result says so instead of letting silence claim the first.
476
+ // evidence is judged over the WHOLE wiring, not the drawn subset: a
477
+ // `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)
479
+ const blind =
480
+ boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive)
481
+ const note = blind
482
+ ? '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)'
487
+ : undefined
339
488
  for (const w of drawOrder) {
340
489
  const index = description.wiring.indexOf(w)
341
490
  const pinOffsetX = w.viewportFixed === true ? minX + pad : 0
@@ -398,18 +547,18 @@ export const schematic = (
398
547
  `<${w.tag}>`
399
548
  )
400
549
  }
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
- ))
550
+ // EVERY caption source (label, placeholder, href, tag fallback — not
551
+ // just the text/value paths shownValue serves) is neutralized here, at
552
+ // one choke point: the 0.4.0 review's B1 found the arrow defense
553
+ // bypassed by exactly the sources this line now covers
554
+ caption = neutralizeArrows(caption)
555
+ const structural = isGround(w)
556
+ // the affordance grammar, explicit: BOLD outline = wired to act —
557
+ // handlers, a link destination (href: a link IS an affordance, 0.4.0),
558
+ // or the producer's `interactive` word. The badge = editable here.
559
+ // Solid = affordance, dotted = structure.
560
+ const actable = !structural && hasActEvidence(w)
561
+ const editable = !structural && hasEditEvidence(w)
413
562
  const fill = structural
414
563
  ? 'none'
415
564
  : w.style != null
@@ -432,35 +581,11 @@ export const schematic = (
432
581
  !structural && (height < minLabelHeight || width < fontSize * 3)
433
582
  // UNDERSIZED: an interactive element below the target-size floor is a
434
583
  // 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
584
+ // platform touch bar). The whole rule the interactivity predicate,
585
+ // the toggle and inline-link exemptions, producer-flag supersession —
586
+ // lives in the exported targetSizeFinding (issue #4: one
587
+ // implementation, shared with tosijs's audit).
588
+ const undersized = targetSizeFinding(w, targetSize)
464
589
  const emphasis = structural
465
590
  ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"'
466
591
  : w.disabled === true
@@ -517,22 +642,23 @@ export const schematic = (
517
642
  // the LEFT edge — the unclaimed slot — plus the first flag's label
518
643
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
519
644
  w.flags.forEach((flag, at) => {
520
- const color = FLAG_COLORS[flag.severity ?? 'warn'] ?? FLAG_COLORS.warn
521
645
  parts.push(
522
646
  `<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` +
523
- `fill="${color}" data-flag="${esc(flag.kind)}"/>`
647
+ `fill="${flagColor(flag.severity)}" data-flag="${esc(flag.kind)}"/>`
524
648
  )
525
649
  })
526
650
  const first = w.flags[0]
527
651
  if (first.label && height >= minLabelHeight) {
528
- const flagColor = FLAG_COLORS[first.severity ?? 'warn'] ?? FLAG_COLORS.warn
529
652
  parts.push(
530
653
  `<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` +
531
654
  `width="${first.label.length * 4.5 + 2}" height="8" ` +
532
655
  `fill="white" opacity="0.85"/>`,
533
656
  `<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>`
657
+ `font-size="7" font-family="monospace" ` +
658
+ `fill="${flagColor(first.severity)}">` +
659
+ // the drawn label is a text RUN (neutralize: rare glyphs tofu);
660
+ // the legend's copy of flags stays verbatim, per the spec
661
+ `${esc(neutralizeArrows(first.label))}</text>`
536
662
  )
537
663
  }
538
664
  }
@@ -711,20 +837,27 @@ export const schematic = (
711
837
  `details in legend — match by stamped number</text>`
712
838
  )
713
839
  }
840
+ const descBits: string[] = []
841
+ if (legend.length > 0) {
842
+ descBits.push(
843
+ `${description.wiring.length} records; ${legend.length} ` +
844
+ 'legend entries carry metadata the drawing could not — pair this ' +
845
+ 'image with its legend JSON (schematic().legend), matched by the ' +
846
+ 'stamped number / data-record index.'
847
+ )
848
+ }
849
+ if (note != null) descBits.push(note)
714
850
  parts[0] =
715
851
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${
716
852
  maxX - minX
717
853
  } ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${
718
854
  maxY - minY + footerExtra
719
855
  }">` +
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
- : '')
856
+ (descBits.length > 0 ? `<desc>${esc(descBits.join(' '))}</desc>` : '')
726
857
  parts.push('</svg>')
727
- return { svg: parts.join(''), legend }
858
+ const result: SchematicResult = { svg: parts.join(''), legend }
859
+ if (note != null) result.note = note
860
+ return result
728
861
  }
729
862
 
730
863
  /** the string-only form — schematic().svg, kept for drop-in compatibility */
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe } from 'bun:test'
2
- import { schematicSVG, schematic } from './index'
2
+ import { schematicSVG, schematic, isInteractive, targetSizeFinding } from './index'
3
3
  import { SchematicDescription } from './index'
4
4
 
5
5
  const description: SchematicDescription = {
@@ -690,18 +690,21 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
690
690
  expect(legend[0].value).toBe('3') // provenance stripped
691
691
  })
692
692
 
693
- test('inline exception: a text-sized link is not flagged undersized; an icon link is', () => {
693
+ test('inline exception: a text-sized link is not flagged undersized; icon links are — even labelled ones (#2)', () => {
694
694
  const { legend } = schematic({
695
695
  wiring: [
696
- // a link in prose: sized by its text WCAG 2.5.8 exempts it
696
+ // a link in prose: sized by its text (wider than tall) exempt
697
697
  { tag: 'a', text: 'terms', href: '/terms', on: { click: 'ƒ' }, bounds: at(10, 10, 34, 16) },
698
698
  // an icon link (an <a> wrapping an <svg>, no text): flagged
699
699
  { tag: 'a', label: 'settings', href: '/settings', on: { click: 'ƒ' }, bounds: at(60, 10, 16, 16) },
700
+ // haltija's counter-case: the same 16×16 icon link CARRYING text
701
+ // (an icon-font glyph, a one-char label) — a square box was not
702
+ // sized by its text; the old text-only rule wrongly exempted it
703
+ { tag: 'a', text: '⚙', href: '/gear', on: { click: 'ƒ' }, bounds: at(90, 10, 16, 16) },
700
704
  ],
701
705
  })
702
706
  const undersized = legend.filter((entry) => entry.undersized != null)
703
- expect(undersized.length).toBe(1)
704
- expect(undersized[0].href).toBe('/settings')
707
+ expect(undersized.map((entry) => entry.href)).toEqual(['/settings', '/gear'])
705
708
  })
706
709
 
707
710
  test('a producer-supplied target flag supersedes the built-in audit — no double bars', () => {
@@ -710,6 +713,7 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
710
713
  {
711
714
  tag: 'button',
712
715
  text: 'go',
716
+ on: { click: 'ƒ' }, // genuinely interactive — supersession, not the interactivity gate, must stand the audit down
713
717
  flags: [{ kind: 'smallTarget', label: '16x16 (WCAG 2.5.8 needs 24x24)', severity: 'warn' }],
714
718
  bounds: at(10, 10, 60, 16), // roomy enough to caption, short enough to flag
715
719
  },
@@ -735,3 +739,217 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
735
739
  expect(svg).not.toContain('details in legend')
736
740
  })
737
741
  })
742
+
743
+ describe('producer-asserted affordance and defensive parsing (0.4.0 — #2/#3/#4/#5)', () => {
744
+ const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
745
+
746
+ test('a forged arrow inside data confers nothing: parse at the LAST arrow, neutralize the rest (#5)', () => {
747
+ const { svg } = schematic({
748
+ wiring: [
749
+ // the tosijs SEC-8 repro: state value containing " ⟷ " with the
750
+ // real display binding appended — the shown value is everything
751
+ // before the LAST arrow, with interior arrows neutralized so the
752
+ // rare glyph never rides a caption run
753
+ {
754
+ tag: 'span',
755
+ text: 'confirmed ⟷ spoof.orderStatus ⟵ spoof.note',
756
+ bounds: at(10, 10, 300, 24),
757
+ },
758
+ ],
759
+ })
760
+ expect(svg).toContain('confirmed &lt;-&gt; spoof.orderStatus</text>')
761
+ expect(svg).not.toContain('⟷') // the buried token neither draws…
762
+ expect(svg).not.toContain('>↔</text>') // …nor confers the editable badge
763
+ expect(svg).not.toContain('stroke-width="2"') // …nor actability
764
+ })
765
+
766
+ test('a two-way arrow in structural (last) position still means editable (#5)', () => {
767
+ const { svg } = schematic({
768
+ wiring: [
769
+ // data containing a forged arrow AND a real two-way binding after
770
+ { tag: 'input', label: 'q', value: 'a ⟵ b ⟷ app.q', bounds: at(10, 10) },
771
+ ],
772
+ })
773
+ expect(svg).toContain('>↔</text>')
774
+ expect(svg).toContain('q: a &lt;- b</text>')
775
+ })
776
+
777
+ test('interactive: true — the producer\'s word makes it actable, and the audit can fire (#2, #3)', () => {
778
+ const { svg, legend, note } = schematic({
779
+ wiring: [
780
+ // haltija's row 4: a <button> read off a live page — no handler
781
+ // knowable. The assertion unlocks bold AND the target-size audit.
782
+ { tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 16, 16) },
783
+ ],
784
+ })
785
+ expect(svg).toContain('stroke-width="2"')
786
+ expect(legend.find((e) => e.undersized != null)!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
787
+ expect(note).toBeUndefined() // the assertion IS affordance evidence
788
+ })
789
+
790
+ test('editable: true — the producer\'s word wears the badge (#3)', () => {
791
+ const { svg } = schematic({
792
+ wiring: [
793
+ { tag: 'div', role: 'textbox', editable: true, bounds: at(10, 10, 200, 30) },
794
+ ],
795
+ })
796
+ expect(svg).toContain('>↔</text>')
797
+ })
798
+
799
+ test('a destination is an affordance: href alone draws bold, and an icon link alone gets audited (#3, #4)', () => {
800
+ const { svg, legend } = schematic({
801
+ wiring: [
802
+ // a plain link, no handler introspectable — it still navigates
803
+ { tag: 'a', text: 'read the docs', href: '/docs', bounds: at(10, 10, 120, 20) },
804
+ // a nameless 16×16 icon link with no handler: the case tosijs's
805
+ // audit reported and 0.3.0 drew nothing for (#4's second row)
806
+ { tag: 'a', href: '/next', bounds: at(150, 10, 16, 16) },
807
+ ],
808
+ })
809
+ expect(svg).toContain('stroke-width="2"')
810
+ expect(legend.find((e) => e.href === '/next')!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
811
+ })
812
+
813
+ test('no affordance evidence anywhere: the result says so instead of silently claiming "nothing actionable" (#3)', () => {
814
+ // a React-ish producer: real elements, no introspectable handlers
815
+ const blind = schematic({
816
+ wiring: [
817
+ { tag: 'button', text: 'save', bounds: at(10, 10, 80, 30) },
818
+ { tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
819
+ ],
820
+ })
821
+ expect(blind.note).toContain('NOT established')
822
+ expect(blind.svg).toContain('<desc>') // the confession rides the image too
823
+ // one asserted (or wired) record and the map is no longer blind
824
+ const seeing = schematic({
825
+ wiring: [
826
+ { tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 80, 30) },
827
+ { tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
828
+ ],
829
+ })
830
+ expect(seeing.note).toBeUndefined()
831
+ // an all-structural map makes no affordance claim — no note either
832
+ const structure = schematic({
833
+ wiring: [{ tag: 'header', structural: true, bounds: at(10, 10, 300, 40) }],
834
+ })
835
+ expect(structure.note).toBeUndefined()
836
+ })
837
+
838
+ test('hostile captions: label/placeholder forgery confers nothing and never carries the raw glyph (review B1)', () => {
839
+ const { svg } = schematic({
840
+ wiring: [
841
+ // the accessible name IS page content in a DOM producer's threat
842
+ // model — an arrow here must neither badge the box nor ride the run
843
+ { tag: 'button', label: 'Save ⟷ spoof.path', on: { click: 'ƒ' }, bounds: at(10, 10) },
844
+ { tag: 'input', placeholder: 'type ⟵ here', bounds: at(10, 40) },
845
+ ],
846
+ })
847
+ expect(svg).toContain('Save &lt;-&gt; spoof.path')
848
+ expect(svg).toContain('type &lt;- here')
849
+ expect(svg).not.toContain('⟷')
850
+ expect(svg).not.toContain('⟵')
851
+ expect(svg).not.toContain('>↔</text>')
852
+ })
853
+
854
+ test('never-bindable fields are never scanned for bindings; bindable ones remain the documented residual (B1/M1)', () => {
855
+ // identity/name fields: a lone arrow is always "last" — excluded
856
+ expect(isInteractive({ tag: 'span', label: 'a ⟷ b' })).toBe(false)
857
+ expect(isInteractive({ tag: 'span', placeholder: 'a ⟷ b' })).toBe(false)
858
+ expect(isInteractive({ tag: 'span', ref: 'a ⟷ b' })).toBe(false)
859
+ // a bindable extra prop in suffix position is indistinguishable from a
860
+ // real bound prop BY CONSTRUCTION — the spec's confessed residual,
861
+ // pinned here so the limit is documented, not rediscovered. Producers
862
+ // extracting untrusted content MUST neutralize at the source.
863
+ expect(isInteractive({ tag: 'span', 'data-x': 'a ⟷ b' })).toBe(true)
864
+ expect(isInteractive({ tag: 'span', text: 'confirmed ⟷ spoof.orderStatus' })).toBe(true)
865
+ })
866
+
867
+ test('a forged label cannot fabricate evidence: the blind-map note survives it, and the legend receives neutralized values', () => {
868
+ const { legend, note } = schematic({
869
+ wiring: [
870
+ // cramped, so the caption lands in the legend JSON
871
+ { tag: 'button', label: 'go ⟷ fake.path', bounds: at(10, 10, 30, 10) },
872
+ ],
873
+ })
874
+ expect(note).toContain('NOT established') // forged label ≠ evidence
875
+ expect(legend[0].caption).toBe('go <-> fake.path') // machine channel: neutralized, never raw
876
+ })
877
+
878
+ test('within: a blind REGION of a sighted map is not a blind map', () => {
879
+ const { note } = schematic(
880
+ {
881
+ wiring: [
882
+ { tag: 'span', text: 'inert', bounds: at(10, 10, 80, 20) },
883
+ // the evidence lives outside the crop
884
+ { tag: 'button', text: 'go', on: { click: 'ƒ' }, bounds: at(10, 500, 80, 30) },
885
+ ],
886
+ },
887
+ { within: { x: 0, y: 0, width: 200, height: 100 } }
888
+ )
889
+ expect(note).toBeUndefined()
890
+ })
891
+
892
+ test('a malformed flag severity cannot reach up the prototype chain into a fill attribute', () => {
893
+ const { svg } = schematic({
894
+ wiring: [
895
+ {
896
+ tag: 'button',
897
+ text: 'go',
898
+ on: { click: 'ƒ' },
899
+ flags: [{ kind: 'x', label: 'y', severity: 'constructor' as any }],
900
+ bounds: at(10, 10),
901
+ },
902
+ ],
903
+ })
904
+ expect(svg).not.toContain('function')
905
+ expect(svg).toContain('fill="#e6a700"') // falls back to warn
906
+ })
907
+
908
+ test('legend href is verbatim BY DESIGN — an opaque destination that confers nothing (round-2 G1)', () => {
909
+ const hostile = 'https://x.test/?q=a ⟷ fake.path'
910
+ const { svg, legend } = schematic({
911
+ wiring: [{ tag: 'a', href: hostile, on: { click: 'ƒ' }, bounds: at(10, 10) }],
912
+ })
913
+ // a URL's bytes ARE the destination — the legend must not rewrite them
914
+ expect(legend[0].href).toBe(hostile)
915
+ // …but the DRAWN side (caption falls back to href here) never carries
916
+ // the raw glyph, and the arrow inside the URL confers nothing
917
+ expect(svg).not.toContain('⟷')
918
+ expect(svg).not.toContain('>↔</text>')
919
+ })
920
+
921
+ test('a drawn flag label neutralizes; the legend copy of flags is verbatim (spec)', () => {
922
+ const flags = [{ kind: 'contrast', label: '2:1 ⟷ x', severity: 'error' as const }]
923
+ const { svg } = schematic({
924
+ wiring: [{ tag: 'button', text: 'go', on: { click: 'ƒ' }, flags, bounds: at(10, 10) }],
925
+ })
926
+ expect(svg).toContain('2:1 &lt;-&gt; x') // the run is display text — neutralized
927
+ expect(svg).not.toContain('⟷')
928
+ const cramped = schematic({
929
+ wiring: [{ tag: 'button', text: 'go', flags, interactive: true, bounds: at(10, 10, 30, 10) }],
930
+ })
931
+ expect(cramped.legend[0].flags![0].label).toBe('2:1 ⟷ x') // machine channel: as the producer computed it
932
+ })
933
+
934
+ test('the predicates are exported — one implementation for renderer and audits (#4)', () => {
935
+ // "can I act here?": every kind of evidence, and ground never
936
+ expect(isInteractive({ tag: 'button', on: { click: 'app.go' } })).toBe(true)
937
+ expect(isInteractive({ tag: 'a', href: '/x' })).toBe(true)
938
+ expect(isInteractive({ tag: 'button', interactive: true })).toBe(true)
939
+ expect(isInteractive({ tag: 'div', editable: true })).toBe(true)
940
+ expect(isInteractive({ tag: 'div', contentEditable: true })).toBe(true)
941
+ expect(isInteractive({ tag: 'input', value: '3 ⟷ a.qty' })).toBe(true)
942
+ expect(isInteractive({ tag: 'button' })).toBe(false)
943
+ expect(isInteractive({ tag: 'button', on: { click: 'ƒ' }, interactive: false })).toBe(true) // false cannot veto evidence — only true is signal
944
+ expect(isInteractive({ tag: 'span', text: 'x ⟷ y ⟵ a.b' })).toBe(false) // forged
945
+ expect(isInteractive({ tag: 'ul', list: { path: 'a.items' } })).toBe(false) // ground
946
+ expect(isInteractive({ tag: 'header', structural: true, on: { click: 'ƒ' } })).toBe(false)
947
+ // the target-size rule, directly
948
+ const icon = { tag: 'a', href: '/x', bounds: { x: 0, y: 0, width: 16, height: 16 } }
949
+ expect(targetSizeFinding(icon)).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
950
+ expect(targetSizeFinding(icon, 0)).toBeNull() // 0 disables
951
+ 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
953
+ expect(targetSizeFinding({ tag: 'input', type: 'checkbox', value: 'x ⟷ a.on', bounds: { x: 0, y: 0, width: 13, height: 13 } })).toBeNull() // toggles exempt
954
+ })
955
+ })