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.
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe } from 'bun:test'
2
- import { schematicSVG, schematic } from './index'
2
+ import { schematicSVG, schematic, isInteractive, targetSizeFinding } from './index'
3
3
  import { SchematicDescription } from './index'
4
4
 
5
5
  const description: SchematicDescription = {
@@ -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('&lt;a&gt; [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('&lt;a&gt; [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
@@ -690,18 +848,21 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
690
848
  expect(legend[0].value).toBe('3') // provenance stripped
691
849
  })
692
850
 
693
- test('inline exception: a text-sized link is not flagged undersized; an icon link is', () => {
851
+ test('inline exception: a text-sized link is not flagged undersized; icon links are — even labelled ones (#2)', () => {
694
852
  const { legend } = schematic({
695
853
  wiring: [
696
- // a link in prose: sized by its text — WCAG 2.5.8 exempts it
854
+ // a link in prose: sized by its text (wider than tall) — exempt
697
855
  { tag: 'a', text: 'terms', href: '/terms', on: { click: 'ƒ' }, bounds: at(10, 10, 34, 16) },
698
856
  // an icon link (an <a> wrapping an <svg>, no text): flagged
699
857
  { tag: 'a', label: 'settings', href: '/settings', on: { click: 'ƒ' }, bounds: at(60, 10, 16, 16) },
858
+ // haltija's counter-case: the same 16×16 icon link CARRYING text
859
+ // (an icon-font glyph, a one-char label) — a square box was not
860
+ // sized by its text; the old text-only rule wrongly exempted it
861
+ { tag: 'a', text: '⚙', href: '/gear', on: { click: 'ƒ' }, bounds: at(90, 10, 16, 16) },
700
862
  ],
701
863
  })
702
864
  const undersized = legend.filter((entry) => entry.undersized != null)
703
- expect(undersized.length).toBe(1)
704
- expect(undersized[0].href).toBe('/settings')
865
+ expect(undersized.map((entry) => entry.href)).toEqual(['/settings', '/gear'])
705
866
  })
706
867
 
707
868
  test('a producer-supplied target flag supersedes the built-in audit — no double bars', () => {
@@ -710,6 +871,7 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
710
871
  {
711
872
  tag: 'button',
712
873
  text: 'go',
874
+ on: { click: 'ƒ' }, // genuinely interactive — supersession, not the interactivity gate, must stand the audit down
713
875
  flags: [{ kind: 'smallTarget', label: '16x16 (WCAG 2.5.8 needs 24x24)', severity: 'warn' }],
714
876
  bounds: at(10, 10, 60, 16), // roomy enough to caption, short enough to flag
715
877
  },
@@ -735,3 +897,220 @@ describe('the legend convergence — href, value, the inline exception (0.3.0)',
735
897
  expect(svg).not.toContain('details in legend')
736
898
  })
737
899
  })
900
+
901
+ describe('producer-asserted affordance and defensive parsing (0.4.0 — #2/#3/#4/#5)', () => {
902
+ const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
903
+
904
+ test('a forged arrow inside data confers nothing: parse at the LAST arrow, neutralize the rest (#5)', () => {
905
+ const { svg } = schematic({
906
+ wiring: [
907
+ // the tosijs SEC-8 repro: state value containing " ⟷ " with the
908
+ // real display binding appended — the shown value is everything
909
+ // before the LAST arrow, with interior arrows neutralized so the
910
+ // rare glyph never rides a caption run
911
+ {
912
+ tag: 'span',
913
+ text: 'confirmed ⟷ spoof.orderStatus ⟵ spoof.note',
914
+ bounds: at(10, 10, 300, 24),
915
+ },
916
+ ],
917
+ })
918
+ expect(svg).toContain('confirmed &lt;-&gt; spoof.orderStatus</text>')
919
+ expect(svg).not.toContain('⟷') // the buried token neither draws…
920
+ expect(svg).not.toContain('>↔</text>') // …nor confers the editable badge
921
+ expect(svg).not.toContain('stroke-width="2"') // …nor actability
922
+ })
923
+
924
+ test('a two-way arrow in structural (last) position still means editable (#5)', () => {
925
+ const { svg } = schematic({
926
+ wiring: [
927
+ // data containing a forged arrow AND a real two-way binding after
928
+ { tag: 'input', label: 'q', value: 'a ⟵ b ⟷ app.q', bounds: at(10, 10) },
929
+ ],
930
+ })
931
+ expect(svg).toContain('>↔</text>')
932
+ expect(svg).toContain('q: a &lt;- b</text>')
933
+ })
934
+
935
+ test('interactive: true — the producer\'s word makes it actable, and the audit can fire (#2, #3)', () => {
936
+ const { svg, legend, note } = schematic({
937
+ wiring: [
938
+ // haltija's row 4: a <button> read off a live page — no handler
939
+ // knowable. The assertion unlocks bold AND the target-size audit.
940
+ { tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 16, 16) },
941
+ ],
942
+ })
943
+ expect(svg).toContain('stroke-width="2"')
944
+ expect(legend.find((e) => e.undersized != null)!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
945
+ expect(note).toBeUndefined() // the assertion IS affordance evidence
946
+ })
947
+
948
+ test('editable: true — the producer\'s word wears the badge (#3)', () => {
949
+ const { svg } = schematic({
950
+ wiring: [
951
+ { tag: 'div', role: 'textbox', editable: true, bounds: at(10, 10, 200, 30) },
952
+ ],
953
+ })
954
+ expect(svg).toContain('>↔</text>')
955
+ })
956
+
957
+ test('a destination is an affordance: href alone draws bold, and an icon link alone gets audited (#3, #4)', () => {
958
+ const { svg, legend } = schematic({
959
+ wiring: [
960
+ // a plain link, no handler introspectable — it still navigates
961
+ { tag: 'a', text: 'read the docs', href: '/docs', bounds: at(10, 10, 120, 20) },
962
+ // a nameless 16×16 icon link with no handler: the case tosijs's
963
+ // audit reported and 0.3.0 drew nothing for (#4's second row)
964
+ { tag: 'a', href: '/next', bounds: at(150, 10, 16, 16) },
965
+ ],
966
+ })
967
+ expect(svg).toContain('stroke-width="2"')
968
+ expect(legend.find((e) => e.href === '/next')!.undersized).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
969
+ })
970
+
971
+ test('no affordance evidence anywhere: the result says so instead of silently claiming "nothing actionable" (#3)', () => {
972
+ // a React-ish producer: real elements, no introspectable handlers
973
+ const blind = schematic({
974
+ wiring: [
975
+ { tag: 'button', text: 'save', bounds: at(10, 10, 80, 30) },
976
+ { tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
977
+ ],
978
+ })
979
+ expect(blind.note).toContain('NOT established')
980
+ expect(blind.svg).toContain('<desc>') // the confession rides the image too
981
+ // one asserted (or wired) record and the map is no longer blind
982
+ const seeing = schematic({
983
+ wiring: [
984
+ { tag: 'button', text: 'save', interactive: true, bounds: at(10, 10, 80, 30) },
985
+ { tag: 'input', label: 'name', bounds: at(10, 50, 160, 30) },
986
+ ],
987
+ })
988
+ expect(seeing.note).toBeUndefined()
989
+ // an all-structural map makes no affordance claim — no note either
990
+ const structure = schematic({
991
+ wiring: [{ tag: 'header', structural: true, bounds: at(10, 10, 300, 40) }],
992
+ })
993
+ expect(structure.note).toBeUndefined()
994
+ })
995
+
996
+ test('hostile captions: label/placeholder forgery confers nothing and never carries the raw glyph (review B1)', () => {
997
+ const { svg } = schematic({
998
+ wiring: [
999
+ // the accessible name IS page content in a DOM producer's threat
1000
+ // model — an arrow here must neither badge the box nor ride the run
1001
+ { tag: 'button', label: 'Save ⟷ spoof.path', on: { click: 'ƒ' }, bounds: at(10, 10) },
1002
+ { tag: 'input', placeholder: 'type ⟵ here', bounds: at(10, 40) },
1003
+ ],
1004
+ })
1005
+ expect(svg).toContain('Save &lt;-&gt; spoof.path')
1006
+ expect(svg).toContain('type &lt;- here')
1007
+ expect(svg).not.toContain('⟷')
1008
+ expect(svg).not.toContain('⟵')
1009
+ expect(svg).not.toContain('>↔</text>')
1010
+ })
1011
+
1012
+ test('never-bindable fields are never scanned for bindings; bindable ones remain the documented residual (B1/M1)', () => {
1013
+ // identity/name fields: a lone arrow is always "last" — excluded
1014
+ expect(isInteractive({ tag: 'span', label: 'a ⟷ b' })).toBe(false)
1015
+ expect(isInteractive({ tag: 'span', placeholder: 'a ⟷ b' })).toBe(false)
1016
+ expect(isInteractive({ tag: 'span', ref: 'a ⟷ b' })).toBe(false)
1017
+ // a bindable extra prop in suffix position is indistinguishable from a
1018
+ // real bound prop BY CONSTRUCTION — the spec's confessed residual,
1019
+ // pinned here so the limit is documented, not rediscovered. Producers
1020
+ // extracting untrusted content MUST neutralize at the source.
1021
+ expect(isInteractive({ tag: 'span', 'data-x': 'a ⟷ b' })).toBe(true)
1022
+ expect(isInteractive({ tag: 'span', text: 'confirmed ⟷ spoof.orderStatus' })).toBe(true)
1023
+ })
1024
+
1025
+ test('a forged label cannot fabricate evidence: the blind-map note survives it, and the legend receives neutralized values', () => {
1026
+ const { legend, note } = schematic({
1027
+ wiring: [
1028
+ // cramped, so the caption lands in the legend JSON
1029
+ { tag: 'button', label: 'go ⟷ fake.path', bounds: at(10, 10, 30, 10) },
1030
+ ],
1031
+ })
1032
+ expect(note).toContain('NOT established') // forged label ≠ evidence
1033
+ expect(legend[0].caption).toBe('go <-> fake.path') // machine channel: neutralized, never raw
1034
+ })
1035
+
1036
+ test('within: a blind REGION of a sighted map is not a blind map', () => {
1037
+ const { note } = schematic(
1038
+ {
1039
+ wiring: [
1040
+ { tag: 'span', text: 'inert', bounds: at(10, 10, 80, 20) },
1041
+ // the evidence lives outside the crop
1042
+ { tag: 'button', text: 'go', on: { click: 'ƒ' }, bounds: at(10, 500, 80, 30) },
1043
+ ],
1044
+ },
1045
+ { within: { x: 0, y: 0, width: 200, height: 100 } }
1046
+ )
1047
+ expect(note).toBeUndefined()
1048
+ })
1049
+
1050
+ test('a malformed flag severity cannot reach up the prototype chain into a fill attribute', () => {
1051
+ const { svg } = schematic({
1052
+ wiring: [
1053
+ {
1054
+ tag: 'button',
1055
+ text: 'go',
1056
+ on: { click: 'ƒ' },
1057
+ flags: [{ kind: 'x', label: 'y', severity: 'constructor' as any }],
1058
+ bounds: at(10, 10),
1059
+ },
1060
+ ],
1061
+ })
1062
+ expect(svg).not.toContain('function')
1063
+ expect(svg).toContain('fill="#e6a700"') // falls back to warn
1064
+ })
1065
+
1066
+ test('legend href is verbatim BY DESIGN — an opaque destination that confers nothing (round-2 G1)', () => {
1067
+ const hostile = 'https://x.test/?q=a ⟷ fake.path'
1068
+ const { svg, legend } = schematic({
1069
+ wiring: [{ tag: 'a', href: hostile, on: { click: 'ƒ' }, bounds: at(10, 10) }],
1070
+ })
1071
+ // a URL's bytes ARE the destination — the legend must not rewrite them
1072
+ expect(legend[0].href).toBe(hostile)
1073
+ // …but the DRAWN side (caption falls back to href here) never carries
1074
+ // the raw glyph, and the arrow inside the URL confers nothing
1075
+ expect(svg).not.toContain('⟷')
1076
+ expect(svg).not.toContain('>↔</text>')
1077
+ })
1078
+
1079
+ test('a drawn flag label neutralizes; the legend copy of flags is verbatim (spec)', () => {
1080
+ const flags = [{ kind: 'contrast', label: '2:1 ⟷ x', severity: 'error' as const }]
1081
+ const { svg } = schematic({
1082
+ wiring: [{ tag: 'button', text: 'go', on: { click: 'ƒ' }, flags, bounds: at(10, 10) }],
1083
+ })
1084
+ expect(svg).toContain('2:1 &lt;-&gt; x') // the run is display text — neutralized
1085
+ expect(svg).not.toContain('⟷')
1086
+ const cramped = schematic({
1087
+ wiring: [{ tag: 'button', text: 'go', flags, interactive: true, bounds: at(10, 10, 30, 10) }],
1088
+ })
1089
+ expect(cramped.legend[0].flags![0].label).toBe('2:1 ⟷ x') // machine channel: as the producer computed it
1090
+ })
1091
+
1092
+ test('the predicates are exported — one implementation for renderer and audits (#4)', () => {
1093
+ // "can I act here?": every kind of evidence, and ground never
1094
+ expect(isInteractive({ tag: 'button', on: { click: 'app.go' } })).toBe(true)
1095
+ expect(isInteractive({ tag: 'a', href: '/x' })).toBe(true)
1096
+ expect(isInteractive({ tag: 'button', interactive: true })).toBe(true)
1097
+ expect(isInteractive({ tag: 'div', editable: true })).toBe(true)
1098
+ expect(isInteractive({ tag: 'div', contentEditable: true })).toBe(true)
1099
+ expect(isInteractive({ tag: 'input', value: '3 ⟷ a.qty' })).toBe(true)
1100
+ expect(isInteractive({ tag: 'button' })).toBe(false)
1101
+ expect(isInteractive({ tag: 'button', on: { click: 'ƒ' }, interactive: false })).toBe(true) // false cannot veto evidence — only true is signal
1102
+ expect(isInteractive({ tag: 'span', text: 'x ⟷ y ⟵ a.b' })).toBe(false) // forged
1103
+ expect(isInteractive({ tag: 'ul', list: { path: 'a.items' } })).toBe(false) // ground
1104
+ expect(isInteractive({ tag: 'header', structural: true, on: { click: 'ƒ' } })).toBe(false)
1105
+ // the target-size rule, directly
1106
+ const icon = { tag: 'a', href: '/x', bounds: { x: 0, y: 0, width: 16, height: 16 } }
1107
+ expect(targetSizeFinding(icon)).toBe('16×16 — below 24×24 (WCAG 2.5.8)')
1108
+ expect(targetSizeFinding(icon, 0)).toBeNull() // 0 disables
1109
+ expect(targetSizeFinding({ ...icon, text: 'terms', bounds: { x: 0, y: 0, width: 34, height: 16 } })).toBeNull() // text-sized
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()
1114
+ expect(targetSizeFinding({ tag: 'input', type: 'checkbox', value: 'x ⟷ a.on', bounds: { x: 0, y: 0, width: 13, height: 13 } })).toBeNull() // toggles exempt
1115
+ })
1116
+ })