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 +58 -11
- package/dist/.metadata_never_index +0 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +73 -27
- package/package.json +3 -1
- package/src/index.ts +199 -66
- package/src/schematic.test.ts +223 -5
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 (
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -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 =
|
|
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
|
|
89
|
-
const
|
|
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
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
schematic,
|
|
294
|
-
rasterizeSVG,
|
|
295
|
-
boundsOf,
|
|
335
|
+
BOUND_TO_DOM,
|
|
296
336
|
BOUND_TWO_WAY,
|
|
297
|
-
|
|
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
|
+
"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
|
-
|
|
181
|
-
|
|
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 =
|
|
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
|
|
332
|
-
//
|
|
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(
|
|
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
|
-
|
|
402
|
-
// the
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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)
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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="${
|
|
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"
|
|
535
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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 */
|
package/src/schematic.test.ts
CHANGED
|
@@ -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;
|
|
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
|
|
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.
|
|
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 <-> 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 <- 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 <-> spoof.path')
|
|
848
|
+
expect(svg).toContain('type <- 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 <-> 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
|
+
})
|