tosijs-floorplan 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,9 @@ 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 |
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 |
91
94
  | `on` | `Record<string, string \| string[]>` | handlers by event type — a path when nameable, `ƒ` (or `ƒ name`) when not |
92
95
  | `list` | `{path, idPath?}` | this element renders a collection (drawn as *ground*, not figure) |
93
96
  | `structural` | `boolean` | structure, not affordance (headings, landmarks, containers) |
@@ -98,7 +101,58 @@ One flat record per wired element. Producers may add fields beyond these —
98
101
  **Provenance tokens** (exported as `BOUND_TO_DOM` / `BOUND_TWO_WAY`): a bound
99
102
  value reads `"<shown> <arrow> <path>"` — `⟵` means state flows to the DOM
100
103
  (display), `⟷` means two-way (a user-writable affordance). A plain string
101
- with no arrow is a live-but-unbound value.
104
+ with no arrow is a live-but-unbound value. The **structural arrow is the
105
+ LAST one in the string** — the surface appends it, so consumers must split
106
+ at the last occurrence, and an arrow token buried inside the data confers
107
+ nothing (the renderer parses defensively: it neutralizes interior arrows to
108
+ `<->` / `<-` in every drawn text run and in the legend's *display* fields —
109
+ `caption` and `value` are always neutralized — and never scans
110
+ identity/name fields (`tag`, `id`, `part`, `role`, `label`, `placeholder`,
111
+ `type`, `description`, `href`, `ref`, `image`) for bindings at all, since
112
+ the surface never appends an arrow to those). Two legend fields are
113
+ **verbatim, deliberately**: `href` is an opaque destination — rewriting
114
+ bytes inside a URL corrupts the one fact an agent acts on — and `flags`
115
+ are copied as the producer computed them. Consumers must never parse
116
+ provenance from either (they are in the never-scanned set; an arrow there
117
+ is data), and must not forward them into a caption-style text run without
118
+ neutralizing first. **Producers whose record
119
+ content derives from untrusted sources — any DOM extractor reading page
120
+ content — MUST neutralize both tokens inside data at the source**, as
121
+ tosijs ≥ 1.8.0 does. This is normative because of an honest residual: a
122
+ forged arrow in *suffix* position on a bindable field (`"data ⟷ fake.path"`
123
+ as the entire text) is structurally indistinguishable from a real binding —
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.
134
+
135
+ **Producers that cannot introspect handlers** (React's synthetic delegation,
136
+ Angular's compiler output, vanilla `addEventListener` — none enumerable from
137
+ page script) assert the affordance instead: `interactive` / `editable`, per
138
+ record. When a map draws affordance-shaped boxes but **no** record carries
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,
146
+ because "nothing here is actionable" and "the producer couldn't tell" are
147
+ different statements, and a consumer must never mistake the second for the
148
+ first. Two caveats pin the semantics: **partial evidence does not establish
149
+ the rest** — on a map where some records carry evidence, a record without
150
+ any still means *unknown*, not *inert* (the note only marks the total-blindness
151
+ case; non-introspecting producers should assert per actable record, not rely
152
+ on the note); and **only `interactive: true` / `editable: true` are signal** —
153
+ `false` is indistinguishable from absent and cannot veto evidence the record
154
+ itself carries (`on`, `href`, a binding). "Introspected and found nothing"
155
+ currently has no encoding; propose one via issue before relying on it.
102
156
 
103
157
  **The picture is not the whole payload.** The renderer is *allowed to omit*:
104
158
  captions and badges below legibility thresholds move to the legend, keyed by
@@ -115,7 +169,7 @@ checkbox sizes.
115
169
 
116
170
  | you see | it means |
117
171
  | --- | --- |
118
- | **bold outline** | wired to act (has handlers) |
172
+ | **bold outline** | wired to act — handlers, a destination (`href`: a link IS an affordance), or the producer's `interactive` assertion |
119
173
  | `↔` badge, bottom-right | editable here (two-way binding, or contenteditable) |
120
174
  | caption ending `*` | required |
121
175
  | **red corner flag**, top-left | invalid *right now* — live ValidityState, the same truth `:invalid` styles |
@@ -133,14 +187,32 @@ checkbox sizes.
133
187
  | 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
188
 
135
189
  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.
190
+ pure geometry can: a link is exempt when it has text **and its box is wider
191
+ than tall** — the shape text layout produces (flagging prose links would
192
+ fire on every paragraph — a check that cries wolf gets ignored, taking the
193
+ real findings with it). Icon links stay flagged: an `<a>` wrapping an
194
+ `<svg>` with no text, and equally a **square** icon link that happens to
195
+ carry a label or a glyph — a 16×16 box was not sized by its text, whatever
196
+ the text is (the text-only rule exempted exactly the header-row-of-icons
197
+ case the check was built for; haltija's issue #2 caught it). A producer
198
+ with DOM access computes the exception *properly* (computed display +
199
+ parent text nodes) and ships the finding via `flags` — that is the
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).
144
216
 
145
217
  Captions tell the truth in priority order: a held **value** wins (as
146
218
  `label: value` when both are known), an empty control falls back to its
@@ -154,8 +226,11 @@ geometry genuinely runs out.
154
226
 
155
227
  | export | what |
156
228
  | --- | --- |
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.** |
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.** |
158
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) |
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 |
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 |
159
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) |
160
235
  | `boundsOf(element)` | an element's page-coordinate bounds — the natural `within` argument |
161
236
  | `BOUND_TO_DOM`, `BOUND_TWO_WAY` | the provenance tokens |
File without changes
package/dist/index.d.ts CHANGED
@@ -83,6 +83,23 @@ 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;
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;
86
103
  [boundProp: string]: unknown;
87
104
  }
88
105
  /** the map: only `wiring` is read. The named optional fields are the
@@ -178,16 +195,65 @@ export interface SchematicLegendEntry {
178
195
  /** interactive element below the target-size floor, e.g.
179
196
  * "18×13 — below 24×24 (WCAG 2.5.8)" */
180
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;
181
201
  }
182
202
  export interface SchematicResult {
183
203
  svg: string;
184
204
  legend: SchematicLegendEntry[];
205
+ /** set when the map draws affordance-shaped boxes but NO record carries
206
+ * any affordance evidence: "nothing here is actionable" and "the
207
+ * producer couldn't tell" are different statements, and a consumer
208
+ * acting on the first when the truth is the second is the
209
+ * confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
210
+ note?: string;
185
211
  }
186
212
  /**
187
213
  * An element's page-coordinate bounds (the same space describe() records) —
188
214
  * the natural `within` argument for a region-scoped schematic.
189
215
  */
190
216
  export declare const boundsOf: (element: Element) => SchematicBounds;
217
+ /**
218
+ * "Can I act here?" — the single implementation of the interactivity
219
+ * predicate, exported so audits (tosijs's auditAccessibility) consume THIS
220
+ * rather than keeping a drifting copy (issue #4: the two had already
221
+ * reached contradictory verdicts on the same element). Evidence, any of:
222
+ * handlers (`on`), a link destination (`href` — a link IS an affordance),
223
+ * `contentEditable`, a two-way binding in structural position, or the
224
+ * producer's own `interactive`/`editable` assertion (issue #3). Ground
225
+ * (structure, list containers) is never interactive.
226
+ */
227
+ export declare const isInteractive: (w: SchematicRecord) => boolean;
228
+ /** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
229
+ * the option default and the exported rule cannot drift */
230
+ export declare const TARGET_SIZE_DEFAULT = 24;
231
+ /**
232
+ * The WCAG 2.5.8 target-size rule, exported for the same reason as
233
+ * `isInteractive` (issue #4): one implementation, geometry judged where the
234
+ * geometry lives. Returns the measured finding string (the legend fact) or
235
+ * null. Embodies the settled exemptions: toggles (user-agent-sized); links
236
+ * plausibly sized by VISIBLE text — the WCAG inline exception as far as
237
+ * pure geometry can honour it, which is: has text, and the box is WIDER
238
+ * than tall, the shape text layout produces (issue #2 caught the earlier
239
+ * text-only rule exempting 16×16 icon links that happened to carry a
240
+ * label; an accessible name alone never sizes a box, and a square box was
241
+ * not sized by its text); and supersession by any producer-supplied flag
242
+ * whose kind mentions `target` — a producer with DOM access (computed
243
+ * display, parent text nodes) computes the real exception and ships the
244
+ * finding via `flags`; that is the INTENDED path for DOM producers, and
245
+ * the built-in never double-marks over it.
246
+ */
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;
191
257
  export declare const schematic: (description: SchematicDescription, options?: SchematicOptions) => SchematicResult;
192
258
  /** the string-only form — schematic().svg, kept for drop-in compatibility */
193
259
  export declare const schematicSVG: (description: SchematicDescription, options?: SchematicOptions) => string;
package/dist/index.js CHANGED
@@ -4,13 +4,33 @@ 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);
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)));
14
34
  var boundsOf = (element) => {
15
35
  const rect = element.getBoundingClientRect();
16
36
  return {
@@ -20,6 +40,32 @@ var boundsOf = (element) => {
20
40
  height: Math.round(rect.height)
21
41
  };
22
42
  };
43
+ var isGround = (w) => w.structural === true || w.list != null && !hasActEvidence(w) && !hasEditEvidence(w);
44
+ var isInteractive = (w) => !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w));
45
+ var TARGET_SIZE_DEFAULT = 24;
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 } = {}) => {
54
+ if (targetSize <= 0 || w.bounds == null || !isInteractive(w))
55
+ return null;
56
+ const { width, height } = w.bounds;
57
+ if (width <= 0 || height <= 0)
58
+ return null;
59
+ if (w.type === "checkbox" || w.type === "radio")
60
+ return null;
61
+ if (w.tag === "a" && typeof w.text === "string" && w.text !== "" && width > height) {
62
+ return null;
63
+ }
64
+ if (honorProducerFlags && Array.isArray(w.flags) && w.flags.some((f) => typeof f?.kind === "string" && TARGET_FLAG_KINDS.has(f.kind.toLowerCase()))) {
65
+ return null;
66
+ }
67
+ return width < targetSize || height < targetSize ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : null;
68
+ };
23
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;
24
70
  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
71
  var esc = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -29,6 +75,7 @@ var FLAG_COLORS = {
29
75
  warn: "#e6a700",
30
76
  info: "#888888"
31
77
  };
78
+ var flagColor = (severity) => typeof severity === "string" && Object.hasOwn(FLAG_COLORS, severity) ? FLAG_COLORS[severity] : FLAG_COLORS.warn;
32
79
  var wrapCaption = (caption, maxChars, maxLines) => {
33
80
  if (maxLines <= 1 || caption.length <= maxChars) {
34
81
  return [caption.slice(0, maxChars + 2)];
@@ -64,7 +111,7 @@ var schematic = (description, options = {}) => {
64
111
  fontSize = 11,
65
112
  within,
66
113
  index: showIndex = false,
67
- targetSize = 24,
114
+ targetSize = TARGET_SIZE_DEFAULT,
68
115
  legendNote = true,
69
116
  decorate
70
117
  } = options;
@@ -85,8 +132,9 @@ var schematic = (description, options = {}) => {
85
132
  const parts = [
86
133
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
87
134
  ];
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)));
135
+ const drawOrder = [...boxes].sort((a, b) => Number(isGround(b)) - Number(isGround(a)));
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;
90
138
  for (const w of drawOrder) {
91
139
  const index = description.wiring.indexOf(w);
92
140
  const pinOffsetX = w.viewportFixed === true ? minX + pad : 0;
@@ -96,9 +144,12 @@ var schematic = (description, options = {}) => {
96
144
  const { width, height } = w.bounds;
97
145
  const isContainer = w.viewportFixed !== true && boxes.some((other) => other !== w && other.viewportFixed !== true && contains(w.bounds, other.bounds));
98
146
  const toggle = w.type === "checkbox" || w.type === "radio";
147
+ const secret = Boolean(w.secret);
99
148
  let caption;
100
149
  let hint = false;
101
- if (isContainer) {
150
+ if (secret) {
151
+ caption = `<${w.tag}> [withheld]`;
152
+ } else if (isContainer) {
102
153
  caption = String(w.label ?? "");
103
154
  } else if (toggle) {
104
155
  caption = String(w.label ?? "");
@@ -115,18 +166,18 @@ var schematic = (description, options = {}) => {
115
166
  } else {
116
167
  caption = String(w.label ?? shownValue(w.text) ?? shownValue(w.value) ?? w.href ?? `<${w.tag}>`);
117
168
  }
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)));
169
+ caption = neutralizeArrows(caption);
170
+ const structural = isGround(w);
171
+ const actable = !structural && hasActEvidence(w);
172
+ const editable = !structural && hasEditEvidence(w);
121
173
  const fill = structural ? "none" : w.style != null ? w.style.background : "transparent";
122
174
  const stroke = !structural && w.style != null && w.style.borderColor !== TRANSPARENT ? w.style.borderColor : "currentColor";
123
175
  const color = w.style != null ? w.style.color : "currentColor";
124
- const drawImage = !structural && typeof w.image === "string" && w.image.startsWith("data:");
176
+ const drawImage = !structural && !secret && typeof w.image === "string" && w.image.startsWith("data:");
125
177
  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;
178
+ const undersized = targetSizeFinding(w, targetSize, {
179
+ honorProducerFlags: true
180
+ });
130
181
  const emphasis = structural ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"' : w.disabled === true ? ' opacity="0.4"' : actable ? ' stroke-width="2"' : "";
131
182
  parts.push(`<g data-record="${index}"${w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ""}>`);
132
183
  if (w.type === "radio") {
@@ -149,13 +200,11 @@ var schematic = (description, options = {}) => {
149
200
  }
150
201
  if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
151
202
  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)}"/>`);
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 : "")}"/>`);
154
204
  });
155
205
  const first = w.flags[0];
156
- 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>`);
206
+ if (typeof first?.label === "string" && first.label !== "" && height >= minLabelHeight) {
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>`);
159
208
  }
160
209
  }
161
210
  if (undersized != null) {
@@ -194,13 +243,13 @@ var schematic = (description, options = {}) => {
194
243
  const elided = { index, tag: w.tag };
195
244
  if (w.ref != null)
196
245
  elided.ref = String(w.ref);
197
- if (typeof w.href === "string" && w.href !== "" && !structural) {
246
+ if (typeof w.href === "string" && w.href !== "" && !structural && !secret) {
198
247
  elided.href = w.href;
199
248
  }
200
249
  if (cramped || truncated) {
201
250
  if (shownCaption !== "" && !toggle)
202
251
  elided.caption = caption;
203
- const heldValue = shownValue(w.value);
252
+ const heldValue = secret ? undefined : shownValue(w.value);
204
253
  if (heldValue)
205
254
  elided.value = heldValue;
206
255
  if (cramped) {
@@ -219,7 +268,9 @@ var schematic = (description, options = {}) => {
219
268
  elided.disabled = true;
220
269
  if (undersized != null)
221
270
  elided.undersized = undersized;
222
- 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;
223
274
  if (inLegend)
224
275
  legend.push(elided);
225
276
  if (showIndex || w.ref != null || inLegend) {
@@ -245,9 +296,18 @@ var schematic = (description, options = {}) => {
245
296
  if (footerExtra > 0) {
246
297
  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
298
  }
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>" : "");
299
+ const descBits = [];
300
+ if (legend.length > 0) {
301
+ 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.");
302
+ }
303
+ if (note != null)
304
+ descBits.push(note);
305
+ 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
306
  parts.push("</svg>");
250
- return { svg: parts.join(""), legend };
307
+ const result = { svg: parts.join(""), legend };
308
+ if (note != null)
309
+ result.note = note;
310
+ return result;
251
311
  };
252
312
  var schematicSVG = (description, options = {}) => schematic(description, options).svg;
253
313
  var rasterizeSVG = (svg, options = {}) => {
@@ -289,10 +349,14 @@ var rasterizeSVG = (svg, options = {}) => {
289
349
  });
290
350
  };
291
351
  export {
292
- schematicSVG,
293
- schematic,
294
- rasterizeSVG,
295
- boundsOf,
352
+ BOUND_TO_DOM,
296
353
  BOUND_TWO_WAY,
297
- BOUND_TO_DOM
354
+ TARGET_FLAG_KINDS,
355
+ TARGET_SIZE_DEFAULT,
356
+ boundsOf,
357
+ isInteractive,
358
+ rasterizeSVG,
359
+ schematic,
360
+ schematicSVG,
361
+ targetSizeFinding
298
362
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tosijs-floorplan",
3
- "version": "0.3.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",
@@ -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": {