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