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/src/index.ts
CHANGED
|
@@ -69,6 +69,23 @@ 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
|
|
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
|
|
72
89
|
[boundProp: string]: unknown
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -165,25 +182,98 @@ export interface SchematicLegendEntry {
|
|
|
165
182
|
/** interactive element below the target-size floor, e.g.
|
|
166
183
|
* "18×13 — below 24×24 (WCAG 2.5.8)" */
|
|
167
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
|
|
168
188
|
}
|
|
169
189
|
|
|
170
190
|
export interface SchematicResult {
|
|
171
191
|
svg: string
|
|
172
192
|
legend: SchematicLegendEntry[]
|
|
193
|
+
/** set when the map draws affordance-shaped boxes but NO record carries
|
|
194
|
+
* any affordance evidence: "nothing here is actionable" and "the
|
|
195
|
+
* producer couldn't tell" are different statements, and a consumer
|
|
196
|
+
* acting on the first when the truth is the second is the
|
|
197
|
+
* confident-wrong-answer case (issue #3). Also rides the svg's <desc>. */
|
|
198
|
+
note?: string
|
|
173
199
|
}
|
|
174
200
|
|
|
175
201
|
// strip provenance from a bound-value string: "shown ⟷ path" → "shown"
|
|
176
202
|
// (empty when the binding holds no value yet); a plain string (no arrow)
|
|
177
|
-
// is a live-but-unbound value and passes through whole
|
|
203
|
+
// is a live-but-unbound value and passes through whole.
|
|
204
|
+
// The STRUCTURAL arrow is the LAST one in the string — the surface appends
|
|
205
|
+
// it, so everything before it is data, and data can carry arrow tokens
|
|
206
|
+
// (forged, or from a producer older than tosijs 1.8.0, which neutralizes
|
|
207
|
+
// them at the source). A renderer consumes maps it did not generate, so it
|
|
208
|
+
// parses defensively: split at the last arrow, and neutralize any arrow
|
|
209
|
+
// left INSIDE the shown value (geometry over glyphs — a rare glyph must
|
|
210
|
+
// never ride a caption run, and a fake arrow must never read as structure).
|
|
178
211
|
const shownValue = (v: unknown): string | undefined => {
|
|
179
212
|
if (typeof v !== 'string') return undefined
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
const at = Math.max(v.lastIndexOf(BOUND_TWO_WAY), v.lastIndexOf(BOUND_TO_DOM))
|
|
214
|
+
return neutralizeArrows(at >= 0 ? v.slice(0, at).trim() : v)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// arrow tokens inside record data must neither ride a caption run
|
|
218
|
+
// (geometry over glyphs) nor read as structure — neutralized the same way
|
|
219
|
+
// tosijs ≥1.8.0 does at the source
|
|
220
|
+
const neutralizeArrows = (s: string): string =>
|
|
221
|
+
s.replaceAll(BOUND_TWO_WAY, '<->').replaceAll(BOUND_TO_DOM, '<-')
|
|
222
|
+
|
|
223
|
+
// is this string a live two-way binding? Only the arrow in STRUCTURAL
|
|
224
|
+
// position (last) counts — a ⟷ buried inside the data must not confer an
|
|
225
|
+
// affordance (a drawing that lies about what the page can do is worse than
|
|
226
|
+
// no drawing).
|
|
227
|
+
const boundTwoWay = (v: unknown): boolean => {
|
|
228
|
+
if (typeof v !== 'string') return false
|
|
229
|
+
const at = v.lastIndexOf(BOUND_TWO_WAY)
|
|
230
|
+
return at >= 0 && at > v.lastIndexOf(BOUND_TO_DOM)
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
// fields the surface NEVER appends a binding arrow to: identity, naming,
|
|
234
|
+
// hints, destinations. In these, any arrow is data (or forgery) — the
|
|
235
|
+
// last-occurrence rule only protects fields that actually receive an
|
|
236
|
+
// appended binding, so these are excluded from the binding scan entirely
|
|
237
|
+
// (0.4.0 review B1: a lone forged arrow in a never-bindable field is
|
|
238
|
+
// always in "last = structural" position).
|
|
239
|
+
const NEVER_BOUND = new Set([
|
|
240
|
+
'tag', 'id', 'part', 'role', 'label', 'placeholder', 'type',
|
|
241
|
+
'description', 'href', 'ref', 'image',
|
|
242
|
+
])
|
|
243
|
+
const hasTwoWayBinding = (w: SchematicRecord): boolean =>
|
|
244
|
+
Object.entries(w).some(
|
|
245
|
+
([key, v]) => !NEVER_BOUND.has(key) && boundTwoWay(v)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
// the two kinds of affordance evidence, split once and shared by the
|
|
249
|
+
// renderer AND the exported predicate — three independently edited copies
|
|
250
|
+
// of this logic is how the renderer and tosijs's audit drifted into
|
|
251
|
+
// contradicting each other (issue #4); parity is now by construction
|
|
252
|
+
const hasActEvidence = (w: SchematicRecord): boolean =>
|
|
253
|
+
w.on != null ||
|
|
254
|
+
w.interactive === true ||
|
|
255
|
+
(typeof w.href === 'string' && w.href !== '')
|
|
256
|
+
const hasEditEvidence = (w: SchematicRecord): boolean =>
|
|
257
|
+
w.editable === true || w.contentEditable === true || hasTwoWayBinding(w)
|
|
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
|
+
|
|
187
277
|
/**
|
|
188
278
|
* An element's page-coordinate bounds (the same space describe() records) —
|
|
189
279
|
* the natural `within` argument for a region-scoped schematic.
|
|
@@ -198,6 +288,100 @@ export const boundsOf = (element: Element): SchematicBounds => {
|
|
|
198
288
|
}
|
|
199
289
|
}
|
|
200
290
|
|
|
291
|
+
// structure behind affordances — a LIST CONTAINER is ground too: it's
|
|
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.
|
|
297
|
+
const isGround = (w: SchematicRecord): boolean =>
|
|
298
|
+
w.structural === true ||
|
|
299
|
+
(w.list != null && !hasActEvidence(w) && !hasEditEvidence(w))
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* "Can I act here?" — the single implementation of the interactivity
|
|
303
|
+
* predicate, exported so audits (tosijs's auditAccessibility) consume THIS
|
|
304
|
+
* rather than keeping a drifting copy (issue #4: the two had already
|
|
305
|
+
* reached contradictory verdicts on the same element). Evidence, any of:
|
|
306
|
+
* handlers (`on`), a link destination (`href` — a link IS an affordance),
|
|
307
|
+
* `contentEditable`, a two-way binding in structural position, or the
|
|
308
|
+
* producer's own `interactive`/`editable` assertion (issue #3). Ground
|
|
309
|
+
* (structure, list containers) is never interactive.
|
|
310
|
+
*/
|
|
311
|
+
export const isInteractive = (w: SchematicRecord): boolean =>
|
|
312
|
+
!isGround(w) && (hasActEvidence(w) || hasEditEvidence(w))
|
|
313
|
+
|
|
314
|
+
/** the WCAG 2.5.8 audit floor (24×24, the AA minimum) — one constant so
|
|
315
|
+
* the option default and the exported rule cannot drift */
|
|
316
|
+
export const TARGET_SIZE_DEFAULT = 24
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The WCAG 2.5.8 target-size rule, exported for the same reason as
|
|
320
|
+
* `isInteractive` (issue #4): one implementation, geometry judged where the
|
|
321
|
+
* geometry lives. Returns the measured finding string (the legend fact) or
|
|
322
|
+
* null. Embodies the settled exemptions: toggles (user-agent-sized); links
|
|
323
|
+
* plausibly sized by VISIBLE text — the WCAG inline exception as far as
|
|
324
|
+
* pure geometry can honour it, which is: has text, and the box is WIDER
|
|
325
|
+
* than tall, the shape text layout produces (issue #2 caught the earlier
|
|
326
|
+
* text-only rule exempting 16×16 icon links that happened to carry a
|
|
327
|
+
* label; an accessible name alone never sizes a box, and a square box was
|
|
328
|
+
* not sized by its text); and supersession by any producer-supplied flag
|
|
329
|
+
* whose kind mentions `target` — a producer with DOM access (computed
|
|
330
|
+
* display, parent text nodes) computes the real exception and ships the
|
|
331
|
+
* finding via `flags`; that is the INTENDED path for DOM producers, and
|
|
332
|
+
* the built-in never double-marks over it.
|
|
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
|
+
|
|
348
|
+
export const targetSizeFinding = (
|
|
349
|
+
w: SchematicRecord,
|
|
350
|
+
targetSize = TARGET_SIZE_DEFAULT,
|
|
351
|
+
{ honorProducerFlags = false } = {}
|
|
352
|
+
): string | null => {
|
|
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
|
|
359
|
+
if (w.type === 'checkbox' || w.type === 'radio') return null
|
|
360
|
+
if (
|
|
361
|
+
w.tag === 'a' &&
|
|
362
|
+
typeof w.text === 'string' &&
|
|
363
|
+
w.text !== '' &&
|
|
364
|
+
width > height
|
|
365
|
+
) {
|
|
366
|
+
return null
|
|
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
|
|
371
|
+
if (
|
|
372
|
+
honorProducerFlags &&
|
|
373
|
+
Array.isArray(w.flags) &&
|
|
374
|
+
w.flags.some(
|
|
375
|
+
(f) => typeof f?.kind === 'string' && TARGET_FLAG_KINDS.has(f.kind.toLowerCase())
|
|
376
|
+
)
|
|
377
|
+
) {
|
|
378
|
+
return null
|
|
379
|
+
}
|
|
380
|
+
return width < targetSize || height < targetSize
|
|
381
|
+
? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
|
|
382
|
+
: null
|
|
383
|
+
}
|
|
384
|
+
|
|
201
385
|
const intersects = (a: SchematicBounds, b: SchematicBounds): boolean =>
|
|
202
386
|
a.x < b.x + b.width &&
|
|
203
387
|
b.x < a.x + a.width &&
|
|
@@ -227,6 +411,14 @@ const FLAG_COLORS: Record<string, string> = {
|
|
|
227
411
|
info: '#888888',
|
|
228
412
|
}
|
|
229
413
|
|
|
414
|
+
// severity comes from producer JSON, so it can be anything — including
|
|
415
|
+
// 'constructor', which a bare index would resolve up the prototype chain
|
|
416
|
+
// into a function serialized straight into a fill attribute
|
|
417
|
+
const flagColor = (severity: unknown): string =>
|
|
418
|
+
typeof severity === 'string' && Object.hasOwn(FLAG_COLORS, severity)
|
|
419
|
+
? FLAG_COLORS[severity]
|
|
420
|
+
: FLAG_COLORS.warn
|
|
421
|
+
|
|
230
422
|
// greedy word-wrap: captions should USE vertical room, not truncate with
|
|
231
423
|
// space to spare (a <p> that wraps on the real page has the same height
|
|
232
424
|
// here). Returns at most maxLines lines, each at most maxChars long;
|
|
@@ -271,7 +463,7 @@ export const schematic = (
|
|
|
271
463
|
fontSize = 11,
|
|
272
464
|
within,
|
|
273
465
|
index: showIndex = false,
|
|
274
|
-
targetSize =
|
|
466
|
+
targetSize = TARGET_SIZE_DEFAULT,
|
|
275
467
|
legendNote = true,
|
|
276
468
|
decorate,
|
|
277
469
|
} = options
|
|
@@ -328,14 +520,35 @@ export const schematic = (
|
|
|
328
520
|
} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`,
|
|
329
521
|
]
|
|
330
522
|
// 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)
|
|
523
|
+
// reads as grouping, not controls (isGround, module level — the exported
|
|
524
|
+
// predicates share it).
|
|
336
525
|
const drawOrder = [...boxes].sort(
|
|
337
|
-
(a, b) => Number(
|
|
526
|
+
(a, b) => Number(isGround(b)) - Number(isGround(a))
|
|
338
527
|
)
|
|
528
|
+
// NO AFFORDANCE EVIDENCE ANYWHERE: for a producer that cannot introspect
|
|
529
|
+
// handlers (issue #3), every record answers "can I act here?" with no —
|
|
530
|
+
// silently, which is the confident-wrong-answer failure. "Nothing here is
|
|
531
|
+
// actionable" and "I couldn't tell" are different statements; when the
|
|
532
|
+
// map draws non-ground boxes but not one record carries any evidence, the
|
|
533
|
+
// result says so instead of letting silence claim the first.
|
|
534
|
+
// evidence is judged over the WHOLE wiring, not the drawn subset: a
|
|
535
|
+
// `within` crop of a map whose evidence lies outside the region is not a
|
|
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.
|
|
540
|
+
const blind =
|
|
541
|
+
boxes.some((w) => !isGround(w)) &&
|
|
542
|
+
!description.wiring.some(isInteractive) &&
|
|
543
|
+
!description.wiring.some(hasCapabilityEvidence)
|
|
544
|
+
const note = blind
|
|
545
|
+
? 'no record carries affordance evidence (on, href, contentEditable, ' +
|
|
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)'
|
|
551
|
+
: undefined
|
|
339
552
|
for (const w of drawOrder) {
|
|
340
553
|
const index = description.wiring.indexOf(w)
|
|
341
554
|
const pinOffsetX = w.viewportFixed === true ? minX + pad : 0
|
|
@@ -363,9 +576,24 @@ export const schematic = (
|
|
|
363
576
|
// back to its placeholder in italics (a hint must not read as content)
|
|
364
577
|
// - everything else: label, then text, then value
|
|
365
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)
|
|
366
583
|
let caption: string
|
|
367
584
|
let hint = false
|
|
368
|
-
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) {
|
|
369
597
|
caption = String(w.label ?? '')
|
|
370
598
|
} else if (toggle) {
|
|
371
599
|
caption = String(w.label ?? '')
|
|
@@ -398,18 +626,18 @@ export const schematic = (
|
|
|
398
626
|
`<${w.tag}>`
|
|
399
627
|
)
|
|
400
628
|
}
|
|
401
|
-
|
|
402
|
-
// the
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
629
|
+
// EVERY caption source (label, placeholder, href, tag fallback — not
|
|
630
|
+
// just the text/value paths shownValue serves) is neutralized here, at
|
|
631
|
+
// one choke point: the 0.4.0 review's B1 found the arrow defense
|
|
632
|
+
// bypassed by exactly the sources this line now covers
|
|
633
|
+
caption = neutralizeArrows(caption)
|
|
634
|
+
const structural = isGround(w)
|
|
635
|
+
// the affordance grammar, explicit: BOLD outline = wired to act —
|
|
636
|
+
// handlers, a link destination (href: a link IS an affordance, 0.4.0),
|
|
637
|
+
// or the producer's `interactive` word. The ↔ badge = editable here.
|
|
638
|
+
// Solid = affordance, dotted = structure.
|
|
639
|
+
const actable = !structural && hasActEvidence(w)
|
|
640
|
+
const editable = !structural && hasEditEvidence(w)
|
|
413
641
|
const fill = structural
|
|
414
642
|
? 'none'
|
|
415
643
|
: w.style != null
|
|
@@ -423,7 +651,10 @@ export const schematic = (
|
|
|
423
651
|
// embedded media first: pixels the producer captured, drawn in place —
|
|
424
652
|
// everything else (state geometry, captions, badges) reads over it
|
|
425
653
|
const drawImage =
|
|
426
|
-
!structural &&
|
|
654
|
+
!structural &&
|
|
655
|
+
!secret && // B1: withheld pixels never draw
|
|
656
|
+
typeof w.image === 'string' &&
|
|
657
|
+
w.image.startsWith('data:')
|
|
427
658
|
// CRAMPED: the box can't legibly carry its dress — draw it bare (shape,
|
|
428
659
|
// state geometry, emphasis, focus) with an auto stamp pointing into the
|
|
429
660
|
// legend, where the metadata actually lives. Toggles are exempt from
|
|
@@ -432,35 +663,13 @@ export const schematic = (
|
|
|
432
663
|
!structural && (height < minLabelHeight || width < fontSize * 3)
|
|
433
664
|
// UNDERSIZED: an interactive element below the target-size floor is a
|
|
434
665
|
// 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
|
-
|
|
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
|
|
666
|
+
// platform touch bar). The whole rule — the interactivity predicate,
|
|
667
|
+
// the toggle and inline-link exemptions, producer-flag supersession —
|
|
668
|
+
// lives in the exported targetSizeFinding (issue #4: one
|
|
669
|
+
// implementation, shared with tosijs's audit).
|
|
670
|
+
const undersized = targetSizeFinding(w, targetSize, {
|
|
671
|
+
honorProducerFlags: true,
|
|
672
|
+
})
|
|
464
673
|
const emphasis = structural
|
|
465
674
|
? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"'
|
|
466
675
|
: w.disabled === true
|
|
@@ -517,22 +726,27 @@ export const schematic = (
|
|
|
517
726
|
// the LEFT edge — the unclaimed slot — plus the first flag's label
|
|
518
727
|
if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
|
|
519
728
|
w.flags.forEach((flag, at) => {
|
|
520
|
-
|
|
729
|
+
// kind is producer JSON too (#12) — same defence severity gets
|
|
521
730
|
parts.push(
|
|
522
731
|
`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` +
|
|
523
|
-
`fill="${
|
|
732
|
+
`fill="${flagColor(flag?.severity)}" ` +
|
|
733
|
+
`data-flag="${esc(typeof flag?.kind === 'string' ? flag.kind : '')}"/>`
|
|
524
734
|
)
|
|
525
735
|
})
|
|
526
736
|
const first = w.flags[0]
|
|
527
|
-
|
|
528
|
-
|
|
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) {
|
|
529
740
|
parts.push(
|
|
530
741
|
`<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` +
|
|
531
742
|
`width="${first.label.length * 4.5 + 2}" height="8" ` +
|
|
532
743
|
`fill="white" opacity="0.85"/>`,
|
|
533
744
|
`<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` +
|
|
534
|
-
`font-size="7" font-family="monospace"
|
|
535
|
-
|
|
745
|
+
`font-size="7" font-family="monospace" ` +
|
|
746
|
+
`fill="${flagColor(first.severity)}">` +
|
|
747
|
+
// the drawn label is a text RUN (neutralize: rare glyphs tofu);
|
|
748
|
+
// the legend's copy of flags stays verbatim, per the spec
|
|
749
|
+
`${esc(neutralizeArrows(first.label))}</text>`
|
|
536
750
|
)
|
|
537
751
|
}
|
|
538
752
|
}
|
|
@@ -638,12 +852,17 @@ export const schematic = (
|
|
|
638
852
|
// a destination is always legend-worthy: it never fits a caption
|
|
639
853
|
// legibly, and it's the fact an agent acts on ("goes to Y", not
|
|
640
854
|
// "says X")
|
|
641
|
-
if (
|
|
855
|
+
if (
|
|
856
|
+
typeof w.href === 'string' &&
|
|
857
|
+
w.href !== '' &&
|
|
858
|
+
!structural &&
|
|
859
|
+
!secret // G1: a withheld destination never reaches the legend
|
|
860
|
+
) {
|
|
642
861
|
elided.href = w.href
|
|
643
862
|
}
|
|
644
863
|
if (cramped || truncated) {
|
|
645
864
|
if (shownCaption !== '' && !toggle) elided.caption = caption
|
|
646
|
-
const heldValue = shownValue(w.value)
|
|
865
|
+
const heldValue = secret ? undefined : shownValue(w.value)
|
|
647
866
|
if (heldValue) elided.value = heldValue
|
|
648
867
|
if (cramped) {
|
|
649
868
|
if (editable) elided.editable = true
|
|
@@ -656,7 +875,12 @@ export const schematic = (
|
|
|
656
875
|
if (w.invalid === true && cramped) elided.invalid = true
|
|
657
876
|
if (w.disabled === true && cramped) elided.disabled = true
|
|
658
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
|
|
659
882
|
const inLegend =
|
|
883
|
+
elided.redacted != null ||
|
|
660
884
|
elided.caption != null ||
|
|
661
885
|
elided.href != null ||
|
|
662
886
|
elided.value != null ||
|
|
@@ -711,20 +935,27 @@ export const schematic = (
|
|
|
711
935
|
`details in legend — match by stamped number</text>`
|
|
712
936
|
)
|
|
713
937
|
}
|
|
938
|
+
const descBits: string[] = []
|
|
939
|
+
if (legend.length > 0) {
|
|
940
|
+
descBits.push(
|
|
941
|
+
`${description.wiring.length} records; ${legend.length} ` +
|
|
942
|
+
'legend entries carry metadata the drawing could not — pair this ' +
|
|
943
|
+
'image with its legend JSON (schematic().legend), matched by the ' +
|
|
944
|
+
'stamped number / data-record index.'
|
|
945
|
+
)
|
|
946
|
+
}
|
|
947
|
+
if (note != null) descBits.push(note)
|
|
714
948
|
parts[0] =
|
|
715
949
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${
|
|
716
950
|
maxX - minX
|
|
717
951
|
} ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${
|
|
718
952
|
maxY - minY + footerExtra
|
|
719
953
|
}">` +
|
|
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
|
-
: '')
|
|
954
|
+
(descBits.length > 0 ? `<desc>${esc(descBits.join(' '))}</desc>` : '')
|
|
726
955
|
parts.push('</svg>')
|
|
727
|
-
|
|
956
|
+
const result: SchematicResult = { svg: parts.join(''), legend }
|
|
957
|
+
if (note != null) result.note = note
|
|
958
|
+
return result
|
|
728
959
|
}
|
|
729
960
|
|
|
730
961
|
/** the string-only form — schematic().svg, kept for drop-in compatibility */
|