tosijs-floorplan 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -11
- package/dist/.metadata_never_index +0 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +97 -33
- package/package.json +3 -1
- package/src/index.ts +302 -71
- package/src/schematic.test.ts +384 -5
package/README.md
CHANGED
|
@@ -88,6 +88,9 @@ One flat record per wired element. Producers may add fields beyond these —
|
|
|
88
88
|
| `required` | `boolean` | the field is required |
|
|
89
89
|
| `disabled` | `boolean` | disabled right now |
|
|
90
90
|
| `contentEditable` | `boolean` | an editable region — treated as an input field |
|
|
91
|
+
| `interactive` | `boolean` | the producer's **assertion** that this element can be acted on — for producers that cannot introspect handlers (React delegates at a root; vanilla `addEventListener` is not enumerable from page script). Asserting is truth-telling; fabricating `on` to unlock the styling would be a lie in the payload. A binding framework never needs it |
|
|
92
|
+
| `editable` | `boolean` | the producer's assertion that text goes in here — the DOM-side counterpart of `contentEditable` / a two-way binding |
|
|
93
|
+
| `secret` | `boolean` | the producer **withheld** facts about this element (a secret-marked region: a token lives in the destination, so neither `label` nor `href` is published). **Fail-closed on the renderer side**: a secret record's label/text/value/placeholder/href/**image** never reach the drawing or the legend even if a producer bug leaves them in the record, and any truthy `secret` scrubs (malformed errs toward withholding) — it draws `<tag> [withheld]` and its legend entry says `redacted: true` (structural records included) — "no destination" and "destination withheld" are different facts |
|
|
91
94
|
| `on` | `Record<string, string \| string[]>` | handlers by event type — a path when nameable, `ƒ` (or `ƒ name`) when not |
|
|
92
95
|
| `list` | `{path, idPath?}` | this element renders a collection (drawn as *ground*, not figure) |
|
|
93
96
|
| `structural` | `boolean` | structure, not affordance (headings, landmarks, containers) |
|
|
@@ -98,7 +101,58 @@ One flat record per wired element. Producers may add fields beyond these —
|
|
|
98
101
|
**Provenance tokens** (exported as `BOUND_TO_DOM` / `BOUND_TWO_WAY`): a bound
|
|
99
102
|
value reads `"<shown> <arrow> <path>"` — `⟵` means state flows to the DOM
|
|
100
103
|
(display), `⟷` means two-way (a user-writable affordance). A plain string
|
|
101
|
-
with no arrow is a live-but-unbound value.
|
|
104
|
+
with no arrow is a live-but-unbound value. The **structural arrow is the
|
|
105
|
+
LAST one in the string** — the surface appends it, so consumers must split
|
|
106
|
+
at the last occurrence, and an arrow token buried inside the data confers
|
|
107
|
+
nothing (the renderer parses defensively: it neutralizes interior arrows to
|
|
108
|
+
`<->` / `<-` in every drawn text run and in the legend's *display* fields —
|
|
109
|
+
`caption` and `value` are always neutralized — and never scans
|
|
110
|
+
identity/name fields (`tag`, `id`, `part`, `role`, `label`, `placeholder`,
|
|
111
|
+
`type`, `description`, `href`, `ref`, `image`) for bindings at all, since
|
|
112
|
+
the surface never appends an arrow to those). Two legend fields are
|
|
113
|
+
**verbatim, deliberately**: `href` is an opaque destination — rewriting
|
|
114
|
+
bytes inside a URL corrupts the one fact an agent acts on — and `flags`
|
|
115
|
+
are copied as the producer computed them. Consumers must never parse
|
|
116
|
+
provenance from either (they are in the never-scanned set; an arrow there
|
|
117
|
+
is data), and must not forward them into a caption-style text run without
|
|
118
|
+
neutralizing first. **Producers whose record
|
|
119
|
+
content derives from untrusted sources — any DOM extractor reading page
|
|
120
|
+
content — MUST neutralize both tokens inside data at the source**, as
|
|
121
|
+
tosijs ≥ 1.8.0 does. This is normative because of an honest residual: a
|
|
122
|
+
forged arrow in *suffix* position on a bindable field (`"data ⟷ fake.path"`
|
|
123
|
+
as the entire text) is structurally indistinguishable from a real binding —
|
|
124
|
+
renderer-side defense ends where the format's own syntax begins. And the
|
|
125
|
+
bindable set is **open by design** (bound props ride under their own keys),
|
|
126
|
+
so the never-scanned list is a denylist over an open key set: any key a
|
|
127
|
+
producer invents is bindable, and arrows in it are trusted as structure.
|
|
128
|
+
The defense is narrowed, not closed — producer-side neutralization remains
|
|
129
|
+
the actual perimeter (issue #11). The capability scan (below) likewise
|
|
130
|
+
counts an arrow in **any position** within a bindable field: under the
|
|
131
|
+
format's own last-occurrence parse, "contains an arrow" and "has a
|
|
132
|
+
structural arrow" are equivalent for a lone token, so a position check
|
|
133
|
+
would add no security — only the #11 perimeter does.
|
|
134
|
+
|
|
135
|
+
**Producers that cannot introspect handlers** (React's synthetic delegation,
|
|
136
|
+
Angular's compiler output, vanilla `addEventListener` — none enumerable from
|
|
137
|
+
page script) assert the affordance instead: `interactive` / `editable`, per
|
|
138
|
+
record. When a map draws affordance-shaped boxes but **no** record carries
|
|
139
|
+
affordance evidence (no `on`, `href`, `contentEditable`, two-way binding, or
|
|
140
|
+
assertion) **and none carries capability evidence either** — a handler, an
|
|
141
|
+
assertion, or a provenance arrow in a bindable field *anywhere in the map*,
|
|
142
|
+
display-only `⟵` included, proves the producer can see wiring (#10: a
|
|
143
|
+
read-only dashboard from a binding framework is a sighted map of an inert
|
|
144
|
+
page, not a blind map) — the result carries a `note`, and the svg's
|
|
145
|
+
`<desc>` repeats it,
|
|
146
|
+
because "nothing here is actionable" and "the producer couldn't tell" are
|
|
147
|
+
different statements, and a consumer must never mistake the second for the
|
|
148
|
+
first. Two caveats pin the semantics: **partial evidence does not establish
|
|
149
|
+
the rest** — on a map where some records carry evidence, a record without
|
|
150
|
+
any still means *unknown*, not *inert* (the note only marks the total-blindness
|
|
151
|
+
case; non-introspecting producers should assert per actable record, not rely
|
|
152
|
+
on the note); and **only `interactive: true` / `editable: true` are signal** —
|
|
153
|
+
`false` is indistinguishable from absent and cannot veto evidence the record
|
|
154
|
+
itself carries (`on`, `href`, a binding). "Introspected and found nothing"
|
|
155
|
+
currently has no encoding; propose one via issue before relying on it.
|
|
102
156
|
|
|
103
157
|
**The picture is not the whole payload.** The renderer is *allowed to omit*:
|
|
104
158
|
captions and badges below legibility thresholds move to the legend, keyed by
|
|
@@ -115,7 +169,7 @@ checkbox sizes.
|
|
|
115
169
|
|
|
116
170
|
| you see | it means |
|
|
117
171
|
| --- | --- |
|
|
118
|
-
| **bold outline** | wired to act (
|
|
172
|
+
| **bold outline** | wired to act — handlers, a destination (`href`: a link IS an affordance), or the producer's `interactive` assertion |
|
|
119
173
|
| `↔` badge, bottom-right | editable here (two-way binding, or contenteditable) |
|
|
120
174
|
| caption ending `*` | required |
|
|
121
175
|
| **red corner flag**, top-left | invalid *right now* — live ValidityState, the same truth `:invalid` styles |
|
|
@@ -133,14 +187,32 @@ checkbox sizes.
|
|
|
133
187
|
| footer strip: "N elements with details in legend" | the image's confession that it isn't the whole map — fetch `schematic().legend` (a machine-readable `<desc>` says the same) |
|
|
134
188
|
|
|
135
189
|
The target-size audit honours WCAG 2.5.8's **inline exception** as far as
|
|
136
|
-
pure geometry can: a link
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
pure geometry can: a link is exempt when it has text **and its box is wider
|
|
191
|
+
than tall** — the shape text layout produces (flagging prose links would
|
|
192
|
+
fire on every paragraph — a check that cries wolf gets ignored, taking the
|
|
193
|
+
real findings with it). Icon links stay flagged: an `<a>` wrapping an
|
|
194
|
+
`<svg>` with no text, and equally a **square** icon link that happens to
|
|
195
|
+
carry a label or a glyph — a 16×16 box was not sized by its text, whatever
|
|
196
|
+
the text is (the text-only rule exempted exactly the header-row-of-icons
|
|
197
|
+
case the check was built for; haltija's issue #2 caught it). A producer
|
|
198
|
+
with DOM access computes the exception *properly* (computed display +
|
|
199
|
+
parent text nodes) and ships the finding via `flags` — that is the
|
|
200
|
+
**intended path** for DOM producers. A producer flag whose `kind` is one
|
|
201
|
+
of the **target-claim kinds** (`TARGET_FLAG_KINDS`, case-insensitive:
|
|
202
|
+
`target`, `target-size`, `targetsize`, `target_size`, `smalltarget`)
|
|
203
|
+
**supersedes** the drawn audit, so the two never double-mark — an explicit
|
|
204
|
+
set, because a substring match let `target-ok` stand the audit down (#8);
|
|
205
|
+
new target-claim kinds are added to `TARGET_FLAG_KINDS` via an issue here,
|
|
206
|
+
the same additive path as any other format change, and the set is
|
|
207
|
+
read-only by contract.
|
|
208
|
+
Supersession is a *drawing* concern and therefore **opt-in**:
|
|
209
|
+
`targetSizeFinding` ignores producer flags by default (an audit wants the
|
|
210
|
+
geometry verdict regardless of what got drawn); `schematic()` passes
|
|
211
|
+
`honorProducerFlags: true`. Hidden is not small: zero-size records are
|
|
212
|
+
never undersized (#9). Both rules are exported (`isInteractive`,
|
|
213
|
+
`targetSizeFinding`) so audits share this implementation instead of
|
|
214
|
+
keeping a drifting copy — and as of 0.5.0 they reproduce an audit's
|
|
215
|
+
verdicts without consumer-side normalization (#7/#8/#9/#13).
|
|
144
216
|
|
|
145
217
|
Captions tell the truth in priority order: a held **value** wins (as
|
|
146
218
|
`label: value` when both are known), an empty control falls back to its
|
|
@@ -154,8 +226,11 @@ geometry genuinely runs out.
|
|
|
154
226
|
|
|
155
227
|
| export | what |
|
|
156
228
|
| --- | --- |
|
|
157
|
-
| `schematic(description, options?)` | the renderer's primary form — returns `{ svg, legend }`: the drawing
|
|
229
|
+
| `schematic(description, options?)` | the renderer's primary form — returns `{ svg, legend, note? }`: the drawing, the metadata it could not legibly carry (cramped/truncated/undersized records, keyed by index/ref), and — when no record carries affordance evidence — the note saying so. **Pair every raster with its legend.** |
|
|
158
230
|
| `schematicSVG(description, options?)` | `schematic().svg` — the string-only form; each `<g>` carries `data-record="<i>"` linking back to `description.wiring[i]` (the image as index) |
|
|
231
|
+
| `isInteractive(record)` | "can I act here?" — the single implementation (handlers, `href`, `contentEditable`, a structural two-way binding, or the producer's assertion; ground never). Exported so audits consume it instead of keeping a drifting copy |
|
|
232
|
+
| `targetSizeFinding(record, targetSize?, {honorProducerFlags?})` | the WCAG 2.5.8 rule with the settled exemptions (toggles, text-sized links, zero-size; producer-flag supersession only when honoured — the renderer's setting, not an audit's) — the measured legend fact, or `null` |
|
|
233
|
+
| `TARGET_FLAG_KINDS` | the flag kinds that claim to *be* a target-size finding and may supersede the drawn audit |
|
|
159
234
|
| `rasterizeSVG(svg, {scale})` | SVG → PNG Blob for vision encoders (browser canvas; under bun/node use `@resvg/resvg-js` — rasterize at 2× so labels OCR cleanly) |
|
|
160
235
|
| `boundsOf(element)` | an element's page-coordinate bounds — the natural `within` argument |
|
|
161
236
|
| `BOUND_TO_DOM`, `BOUND_TWO_WAY` | the provenance tokens |
|
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,23 @@ export interface SchematicRecord {
|
|
|
83
83
|
* ("3") or bound ("3 ⟷ app.qty"). tosijs emits it as a bound prop; the
|
|
84
84
|
* declared field gives plain-DOM producers the same home */
|
|
85
85
|
value?: string;
|
|
86
|
+
/** the producer's ASSERTION that this element can be acted on — for
|
|
87
|
+
* producers that cannot introspect handlers (React delegates at a root;
|
|
88
|
+
* vanilla addEventListener is not enumerable from page script). A binding
|
|
89
|
+
* framework never needs it: `on` and two-way bindings already say so.
|
|
90
|
+
* Asserting is truth-telling; fabricating `on` to unlock the styling
|
|
91
|
+
* would be a lie in the payload. (issue #3, haltija) */
|
|
92
|
+
interactive?: boolean;
|
|
93
|
+
/** the producer's assertion that text goes in here — the DOM-side
|
|
94
|
+
* counterpart of contentEditable/two-way bindings (issue #3) */
|
|
95
|
+
editable?: boolean;
|
|
96
|
+
/** the producer WITHHELD facts about this element (tosijs 1.11.0's
|
|
97
|
+
* secret regions: a magic-link token lives in the href, so neither
|
|
98
|
+
* label nor href is published). Drawn with a `[withheld]` caption when
|
|
99
|
+
* nothing else names it, and the legend says redacted — "this link has
|
|
100
|
+
* no destination" and "its destination was withheld" are different
|
|
101
|
+
* facts (issue #15) */
|
|
102
|
+
secret?: boolean;
|
|
86
103
|
[boundProp: string]: unknown;
|
|
87
104
|
}
|
|
88
105
|
/** the map: only `wiring` is read. The named optional fields are the
|
|
@@ -178,16 +195,65 @@ export interface SchematicLegendEntry {
|
|
|
178
195
|
/** interactive element below the target-size floor, e.g.
|
|
179
196
|
* "18×13 — below 24×24 (WCAG 2.5.8)" */
|
|
180
197
|
undersized?: string;
|
|
198
|
+
/** the producer withheld facts about this record (`secret: true`) —
|
|
199
|
+
* a missing href here means "withheld", not "no destination" */
|
|
200
|
+
redacted?: boolean;
|
|
181
201
|
}
|
|
182
202
|
export interface SchematicResult {
|
|
183
203
|
svg: string;
|
|
184
204
|
legend: SchematicLegendEntry[];
|
|
205
|
+
/** set when the map draws affordance-shaped boxes but NO record carries
|
|
206
|
+
* any affordance evidence: "nothing here is actionable" and "the
|
|
207
|
+
* producer couldn't tell" are different statements, and a consumer
|
|
208
|
+
* acting on the first when the truth is the second is the
|
|
209
|
+
* confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
|
|
210
|
+
note?: string;
|
|
185
211
|
}
|
|
186
212
|
/**
|
|
187
213
|
* An element's page-coordinate bounds (the same space describe() records) —
|
|
188
214
|
* the natural `within` argument for a region-scoped schematic.
|
|
189
215
|
*/
|
|
190
216
|
export declare const boundsOf: (element: Element) => SchematicBounds;
|
|
217
|
+
/**
|
|
218
|
+
* "Can I act here?" — the single implementation of the interactivity
|
|
219
|
+
* predicate, exported so audits (tosijs's auditAccessibility) consume THIS
|
|
220
|
+
* rather than keeping a drifting copy (issue #4: the two had already
|
|
221
|
+
* reached contradictory verdicts on the same element). Evidence, any of:
|
|
222
|
+
* handlers (`on`), a link destination (`href` — a link IS an affordance),
|
|
223
|
+
* `contentEditable`, a two-way binding in structural position, or the
|
|
224
|
+
* producer's own `interactive`/`editable` assertion (issue #3). Ground
|
|
225
|
+
* (structure, list containers) is never interactive.
|
|
226
|
+
*/
|
|
227
|
+
export declare const isInteractive: (w: SchematicRecord) => boolean;
|
|
228
|
+
/** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
|
|
229
|
+
* the option default and the exported rule cannot drift */
|
|
230
|
+
export declare const TARGET_SIZE_DEFAULT = 24;
|
|
231
|
+
/**
|
|
232
|
+
* The WCAG 2.5.8 target-size rule, exported for the same reason as
|
|
233
|
+
* `isInteractive` (issue #4): one implementation, geometry judged where the
|
|
234
|
+
* geometry lives. Returns the measured finding string (the legend fact) or
|
|
235
|
+
* null. Embodies the settled exemptions: toggles (user-agent-sized); links
|
|
236
|
+
* plausibly sized by VISIBLE text — the WCAG inline exception as far as
|
|
237
|
+
* pure geometry can honour it, which is: has text, and the box is WIDER
|
|
238
|
+
* than tall, the shape text layout produces (issue #2 caught the earlier
|
|
239
|
+
* text-only rule exempting 16×16 icon links that happened to carry a
|
|
240
|
+
* label; an accessible name alone never sizes a box, and a square box was
|
|
241
|
+
* not sized by its text); and supersession by any producer-supplied flag
|
|
242
|
+
* whose kind mentions `target` — a producer with DOM access (computed
|
|
243
|
+
* display, parent text nodes) computes the real exception and ships the
|
|
244
|
+
* finding via `flags`; that is the INTENDED path for DOM producers, and
|
|
245
|
+
* the built-in never double-marks over it.
|
|
246
|
+
*/
|
|
247
|
+
/** flag kinds that claim to BE a target-size finding, and therefore
|
|
248
|
+
* supersede the built-in audit when the renderer honours producer flags.
|
|
249
|
+
* An explicit set, not a substring match: `includes('target')` let
|
|
250
|
+
* 'target-ok' — or any kind merely mentioning the word — silently stand
|
|
251
|
+
* the audit down (#8). Covers both producers' kinds in the wild
|
|
252
|
+
* (haltija: 'target', 'smallTarget'; tosijs auditFlags: 'target-size'). */
|
|
253
|
+
export declare const TARGET_FLAG_KINDS: ReadonlySet<string>;
|
|
254
|
+
export declare const targetSizeFinding: (w: SchematicRecord, targetSize?: number, { honorProducerFlags }?: {
|
|
255
|
+
honorProducerFlags?: boolean | undefined;
|
|
256
|
+
}) => string | null;
|
|
191
257
|
export declare const schematic: (description: SchematicDescription, options?: SchematicOptions) => SchematicResult;
|
|
192
258
|
/** the string-only form — schematic().svg, kept for drop-in compatibility */
|
|
193
259
|
export declare const schematicSVG: (description: SchematicDescription, options?: SchematicOptions) => string;
|
package/dist/index.js
CHANGED
|
@@ -4,13 +4,33 @@ var BOUND_TWO_WAY = "⟷";
|
|
|
4
4
|
var shownValue = (v) => {
|
|
5
5
|
if (typeof v !== "string")
|
|
6
6
|
return;
|
|
7
|
-
|
|
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);
|
|
33
|
+
var hasCapabilityEvidence = (w) => w.on != null || w.interactive === true || w.editable === true || Object.entries(w).some(([key, v]) => !NEVER_BOUND.has(key) && typeof v === "string" && (v.includes(BOUND_TWO_WAY) || v.includes(BOUND_TO_DOM)));
|
|
14
34
|
var boundsOf = (element) => {
|
|
15
35
|
const rect = element.getBoundingClientRect();
|
|
16
36
|
return {
|
|
@@ -20,6 +40,32 @@ var boundsOf = (element) => {
|
|
|
20
40
|
height: Math.round(rect.height)
|
|
21
41
|
};
|
|
22
42
|
};
|
|
43
|
+
var isGround = (w) => w.structural === true || w.list != null && !hasActEvidence(w) && !hasEditEvidence(w);
|
|
44
|
+
var isInteractive = (w) => !isGround(w) && (hasActEvidence(w) || hasEditEvidence(w));
|
|
45
|
+
var TARGET_SIZE_DEFAULT = 24;
|
|
46
|
+
var TARGET_FLAG_KINDS = new Set([
|
|
47
|
+
"target",
|
|
48
|
+
"target-size",
|
|
49
|
+
"targetsize",
|
|
50
|
+
"target_size",
|
|
51
|
+
"smalltarget"
|
|
52
|
+
]);
|
|
53
|
+
var targetSizeFinding = (w, targetSize = TARGET_SIZE_DEFAULT, { honorProducerFlags = false } = {}) => {
|
|
54
|
+
if (targetSize <= 0 || w.bounds == null || !isInteractive(w))
|
|
55
|
+
return null;
|
|
56
|
+
const { width, height } = w.bounds;
|
|
57
|
+
if (width <= 0 || height <= 0)
|
|
58
|
+
return null;
|
|
59
|
+
if (w.type === "checkbox" || w.type === "radio")
|
|
60
|
+
return null;
|
|
61
|
+
if (w.tag === "a" && typeof w.text === "string" && w.text !== "" && width > height) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (honorProducerFlags && Array.isArray(w.flags) && w.flags.some((f) => typeof f?.kind === "string" && TARGET_FLAG_KINDS.has(f.kind.toLowerCase()))) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return width < targetSize || height < targetSize ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : null;
|
|
68
|
+
};
|
|
23
69
|
var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
24
70
|
var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
|
|
25
71
|
var esc = (s) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -29,6 +75,7 @@ var FLAG_COLORS = {
|
|
|
29
75
|
warn: "#e6a700",
|
|
30
76
|
info: "#888888"
|
|
31
77
|
};
|
|
78
|
+
var flagColor = (severity) => typeof severity === "string" && Object.hasOwn(FLAG_COLORS, severity) ? FLAG_COLORS[severity] : FLAG_COLORS.warn;
|
|
32
79
|
var wrapCaption = (caption, maxChars, maxLines) => {
|
|
33
80
|
if (maxLines <= 1 || caption.length <= maxChars) {
|
|
34
81
|
return [caption.slice(0, maxChars + 2)];
|
|
@@ -64,7 +111,7 @@ var schematic = (description, options = {}) => {
|
|
|
64
111
|
fontSize = 11,
|
|
65
112
|
within,
|
|
66
113
|
index: showIndex = false,
|
|
67
|
-
targetSize =
|
|
114
|
+
targetSize = TARGET_SIZE_DEFAULT,
|
|
68
115
|
legendNote = true,
|
|
69
116
|
decorate
|
|
70
117
|
} = options;
|
|
@@ -85,8 +132,9 @@ var schematic = (description, options = {}) => {
|
|
|
85
132
|
const parts = [
|
|
86
133
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
|
|
87
134
|
];
|
|
88
|
-
const
|
|
89
|
-
const
|
|
135
|
+
const drawOrder = [...boxes].sort((a, b) => Number(isGround(b)) - Number(isGround(a)));
|
|
136
|
+
const blind = boxes.some((w) => !isGround(w)) && !description.wiring.some(isInteractive) && !description.wiring.some(hasCapabilityEvidence);
|
|
137
|
+
const note = blind ? "no record carries affordance evidence (on, href, contentEditable, " + "a two-way binding, or an interactive/editable assertion), and none " + "shows the producer can see wiring at all (no handler, assertion, " + 'or provenance arrow anywhere) — "nothing here is actionable" is ' + "NOT established; a producer that cannot introspect handlers " + "should assert `interactive`/`editable` per record (see README)" : undefined;
|
|
90
138
|
for (const w of drawOrder) {
|
|
91
139
|
const index = description.wiring.indexOf(w);
|
|
92
140
|
const pinOffsetX = w.viewportFixed === true ? minX + pad : 0;
|
|
@@ -96,9 +144,12 @@ var schematic = (description, options = {}) => {
|
|
|
96
144
|
const { width, height } = w.bounds;
|
|
97
145
|
const isContainer = w.viewportFixed !== true && boxes.some((other) => other !== w && other.viewportFixed !== true && contains(w.bounds, other.bounds));
|
|
98
146
|
const toggle = w.type === "checkbox" || w.type === "radio";
|
|
147
|
+
const secret = Boolean(w.secret);
|
|
99
148
|
let caption;
|
|
100
149
|
let hint = false;
|
|
101
|
-
if (
|
|
150
|
+
if (secret) {
|
|
151
|
+
caption = `<${w.tag}> [withheld]`;
|
|
152
|
+
} else if (isContainer) {
|
|
102
153
|
caption = String(w.label ?? "");
|
|
103
154
|
} else if (toggle) {
|
|
104
155
|
caption = String(w.label ?? "");
|
|
@@ -115,18 +166,18 @@ var schematic = (description, options = {}) => {
|
|
|
115
166
|
} else {
|
|
116
167
|
caption = String(w.label ?? shownValue(w.text) ?? shownValue(w.value) ?? w.href ?? `<${w.tag}>`);
|
|
117
168
|
}
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
169
|
+
caption = neutralizeArrows(caption);
|
|
170
|
+
const structural = isGround(w);
|
|
171
|
+
const actable = !structural && hasActEvidence(w);
|
|
172
|
+
const editable = !structural && hasEditEvidence(w);
|
|
121
173
|
const fill = structural ? "none" : w.style != null ? w.style.background : "transparent";
|
|
122
174
|
const stroke = !structural && w.style != null && w.style.borderColor !== TRANSPARENT ? w.style.borderColor : "currentColor";
|
|
123
175
|
const color = w.style != null ? w.style.color : "currentColor";
|
|
124
|
-
const drawImage = !structural && typeof w.image === "string" && w.image.startsWith("data:");
|
|
176
|
+
const drawImage = !structural && !secret && typeof w.image === "string" && w.image.startsWith("data:");
|
|
125
177
|
const cramped = !structural && (height < minLabelHeight || width < fontSize * 3);
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const undersized = targetSize > 0 && interactive && !producerTargetFlag && !textSizedLink && !(w.type === "checkbox" || w.type === "radio") && (width < targetSize || height < targetSize) ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : undefined;
|
|
178
|
+
const undersized = targetSizeFinding(w, targetSize, {
|
|
179
|
+
honorProducerFlags: true
|
|
180
|
+
});
|
|
130
181
|
const emphasis = structural ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"' : w.disabled === true ? ' opacity="0.4"' : actable ? ' stroke-width="2"' : "";
|
|
131
182
|
parts.push(`<g data-record="${index}"${w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ""}>`);
|
|
132
183
|
if (w.type === "radio") {
|
|
@@ -149,13 +200,11 @@ var schematic = (description, options = {}) => {
|
|
|
149
200
|
}
|
|
150
201
|
if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
|
|
151
202
|
w.flags.forEach((flag, at) => {
|
|
152
|
-
|
|
153
|
-
parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${color2}" data-flag="${esc(flag.kind)}"/>`);
|
|
203
|
+
parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${flagColor(flag?.severity)}" ` + `data-flag="${esc(typeof flag?.kind === "string" ? flag.kind : "")}"/>`);
|
|
154
204
|
});
|
|
155
205
|
const first = w.flags[0];
|
|
156
|
-
if (first.label && height >= minLabelHeight) {
|
|
157
|
-
|
|
158
|
-
parts.push(`<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` + `width="${first.label.length * 4.5 + 2}" height="8" ` + `fill="white" opacity="0.85"/>`, `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` + `font-size="7" font-family="monospace" fill="${flagColor}">` + `${esc(first.label)}</text>`);
|
|
206
|
+
if (typeof first?.label === "string" && first.label !== "" && height >= minLabelHeight) {
|
|
207
|
+
parts.push(`<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` + `width="${first.label.length * 4.5 + 2}" height="8" ` + `fill="white" opacity="0.85"/>`, `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` + `font-size="7" font-family="monospace" ` + `fill="${flagColor(first.severity)}">` + `${esc(neutralizeArrows(first.label))}</text>`);
|
|
159
208
|
}
|
|
160
209
|
}
|
|
161
210
|
if (undersized != null) {
|
|
@@ -194,13 +243,13 @@ var schematic = (description, options = {}) => {
|
|
|
194
243
|
const elided = { index, tag: w.tag };
|
|
195
244
|
if (w.ref != null)
|
|
196
245
|
elided.ref = String(w.ref);
|
|
197
|
-
if (typeof w.href === "string" && w.href !== "" && !structural) {
|
|
246
|
+
if (typeof w.href === "string" && w.href !== "" && !structural && !secret) {
|
|
198
247
|
elided.href = w.href;
|
|
199
248
|
}
|
|
200
249
|
if (cramped || truncated) {
|
|
201
250
|
if (shownCaption !== "" && !toggle)
|
|
202
251
|
elided.caption = caption;
|
|
203
|
-
const heldValue = shownValue(w.value);
|
|
252
|
+
const heldValue = secret ? undefined : shownValue(w.value);
|
|
204
253
|
if (heldValue)
|
|
205
254
|
elided.value = heldValue;
|
|
206
255
|
if (cramped) {
|
|
@@ -219,7 +268,9 @@ var schematic = (description, options = {}) => {
|
|
|
219
268
|
elided.disabled = true;
|
|
220
269
|
if (undersized != null)
|
|
221
270
|
elided.undersized = undersized;
|
|
222
|
-
|
|
271
|
+
if (secret)
|
|
272
|
+
elided.redacted = true;
|
|
273
|
+
const inLegend = elided.redacted != null || elided.caption != null || elided.href != null || elided.value != null || elided.editable != null || elided.required != null || elided.flags != null || elided.invalid != null || elided.disabled != null || elided.undersized != null;
|
|
223
274
|
if (inLegend)
|
|
224
275
|
legend.push(elided);
|
|
225
276
|
if (showIndex || w.ref != null || inLegend) {
|
|
@@ -245,9 +296,18 @@ var schematic = (description, options = {}) => {
|
|
|
245
296
|
if (footerExtra > 0) {
|
|
246
297
|
parts.push(`<text x="${minX + pad}" y="${maxY + 10}" font-size="8" ` + `font-family="monospace" fill="currentColor" opacity="0.75">` + `${legend.length} element${legend.length === 1 ? "" : "s"} with ` + `details in legend — match by stamped number</text>`);
|
|
247
298
|
}
|
|
248
|
-
|
|
299
|
+
const descBits = [];
|
|
300
|
+
if (legend.length > 0) {
|
|
301
|
+
descBits.push(`${description.wiring.length} records; ${legend.length} ` + "legend entries carry metadata the drawing could not — pair this " + "image with its legend JSON (schematic().legend), matched by the " + "stamped number / data-record index.");
|
|
302
|
+
}
|
|
303
|
+
if (note != null)
|
|
304
|
+
descBits.push(note);
|
|
305
|
+
parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${maxY - minY + footerExtra}">` + (descBits.length > 0 ? `<desc>${esc(descBits.join(" "))}</desc>` : "");
|
|
249
306
|
parts.push("</svg>");
|
|
250
|
-
|
|
307
|
+
const result = { svg: parts.join(""), legend };
|
|
308
|
+
if (note != null)
|
|
309
|
+
result.note = note;
|
|
310
|
+
return result;
|
|
251
311
|
};
|
|
252
312
|
var schematicSVG = (description, options = {}) => schematic(description, options).svg;
|
|
253
313
|
var rasterizeSVG = (svg, options = {}) => {
|
|
@@ -289,10 +349,14 @@ var rasterizeSVG = (svg, options = {}) => {
|
|
|
289
349
|
});
|
|
290
350
|
};
|
|
291
351
|
export {
|
|
292
|
-
|
|
293
|
-
schematic,
|
|
294
|
-
rasterizeSVG,
|
|
295
|
-
boundsOf,
|
|
352
|
+
BOUND_TO_DOM,
|
|
296
353
|
BOUND_TWO_WAY,
|
|
297
|
-
|
|
354
|
+
TARGET_FLAG_KINDS,
|
|
355
|
+
TARGET_SIZE_DEFAULT,
|
|
356
|
+
boundsOf,
|
|
357
|
+
isInteractive,
|
|
358
|
+
rasterizeSVG,
|
|
359
|
+
schematic,
|
|
360
|
+
schematicSVG,
|
|
361
|
+
targetSizeFinding
|
|
298
362
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tosijs-floorplan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Render an agent-surface map (plain records) as a floorplan SVG — the affordance grammar as a pure, dependency-free function. No DOM, no framework. Formerly tosijs-schematic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "bun build src/index.ts --outdir dist --format esm && tsc -p tsconfig.json",
|
|
29
29
|
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit --emitDeclarationOnly false",
|
|
31
|
+
"stability": "bun tools/byte-stability.ts",
|
|
30
32
|
"prepublishOnly": "bun test && bun run build"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|