Skip to content

Commit 86bfd56

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(design): preserve structural and imperative style coverage
1 parent 82bec90 commit 86bfd56

9 files changed

Lines changed: 128 additions & 44 deletions

‎scripts/check-design-conformance-colour-assignments.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,23 @@ test('HSL channel tokens require a colour function at the visible sink', () => {
548548
flagged({ ...global, [css]: '.a{--alias:var(--background);color:hsl(var(--alias))}' })
549549
).toEqual([])
550550
})
551+
552+
test('imperative paint properties inspect literals, tokens and unresolved values', () => {
553+
for (const statement of ['node.style.color=VALUE', "node.style.setProperty('color',VALUE)"]) {
554+
expect(
555+
flagged({
556+
[ui]: `export function update(node){${statement.replace('VALUE', "'#ff00ff'")}}`,
557+
}).some((f) => f.rule === 'central-colour-assignment')
558+
).toBe(true)
559+
expect(
560+
flagged({
561+
[ui]: `export function update(node){${statement.replace('VALUE', "'var(--text-body)'")}}`,
562+
})
563+
).toEqual([])
564+
expect(
565+
inspect({
566+
[ui]: `export function update(node,value){${statement.replace('VALUE', 'value')}}`,
567+
}).unchecked.length
568+
).toBeGreaterThan(0)
569+
}
570+
})

‎scripts/check-design-conformance-composition.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,18 @@ test('source-proven modal composition distinguishes prose from actual field grou
571571
).findings.some((f) => f.contract === 'component-chrome')
572572
).toBe(true)
573573
})
574+
575+
test('modal field deduplication stays within the actual field container', async () => {
576+
const p = `${root}two-fields.tsx`
577+
const code =
578+
'import {ChipModalBody,ChipInput,ChipModalField} from "@sim/emcn";export const A=()=> <ChipModalBody><div><label>First</label><ChipInput/></div><section><label>Second</label><ChipInput/></section></ChipModalBody>'
579+
const result = await compare({}, { [p]: code })
580+
expect(result.findings.filter((f) => f.contract === 'modal-field')).toHaveLength(2)
581+
const wrapped = code.replace(
582+
'<div><label>First</label><ChipInput/></div><section><label>Second</label><ChipInput/></section>',
583+
'<ChipModalField title="First"/><ChipModalField title="Second"/>'
584+
)
585+
expect(
586+
(await compare({}, { [p]: wrapped })).findings.filter((f) => f.contract === 'modal-field')
587+
).toEqual([])
588+
})

‎scripts/check-design-conformance-controls.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,18 @@ test('undefined class alternatives cannot exempt visible unnamed buttons', () =>
259259
matching(run('<Button className="hidden"><X/></Button>'), 'control-accessible-name')
260260
).toHaveLength(0)
261261
})
262+
263+
test('native image inputs require an accessible name from alt or ARIA', () => {
264+
expect(
265+
matching(run('<input type="image" src="/action.png"/>'), 'control-accessible-name')
266+
).toHaveLength(1)
267+
expect(
268+
matching(run('<input type="image" src="/action.png" alt="Submit"/>'), 'control-accessible-name')
269+
).toEqual([])
270+
expect(
271+
matching(
272+
run('<input type="image" src="/action.png" aria-label="Submit"/>'),
273+
'control-accessible-name'
274+
)
275+
).toEqual([])
276+
})

‎scripts/check-design-conformance.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async function diff(
5555
const text = (classes: string) => `const A=()=> <p className=${JSON.stringify(classes)}>Text</p>`
5656
const shared = (body: string) =>
5757
`import {Button,Chip,ChipInput,ChipTextarea,ChipModalBody,ChipModalField,ChipTag,cn} from '@sim/emcn';const A=()=> <>${body}</>`
58+
// This fixture extracts the real EMCN inventory, including a cold type program.
5859
for (const classes of [
5960
'text-small text-[var(--text-body)]',
6061
'font-mono',
@@ -64,8 +65,11 @@ for (const classes of [
6465
'text-sm',
6566
'text-white',
6667
])
67-
test(`approved ${classes} passes`, async () =>
68-
expect((await diff('', text(classes))).flagged).toBe(false))
68+
test(
69+
`approved ${classes} passes`,
70+
async () => expect((await diff('', text(classes))).flagged).toBe(false),
71+
15000
72+
)
6973
for (const classes of [
7074
'text-[#434343]',
7175
'text-[13px]',

‎scripts/design-conformance/conformance.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,6 @@ function displayInput(atom: Atom | undefined, value: string): string {
393393
}
394394
function prepared(facts: Facts, file: string, index: SourceIndex): Facts {
395395
const unchecked = [...facts.unchecked]
396-
const ownersWithFields = new Set(
397-
(facts.surfaces ?? []).filter((s) => s.fieldContainer).map((s) => s.owner)
398-
)
399396
const surfaces = (facts.surfaces ?? []).map((surface) => {
400397
if (!surface.atoms.length && !surface.fieldContainer && !surface.structuralViolation)
401398
return { ...surface, target: index.canonicalTarget(surface.target) }
@@ -451,9 +448,7 @@ function prepared(facts: Facts, file: string, index: SourceIndex): Facts {
451448
)
452449
.map((s) => `; central definition ${s}`)
453450
.join('')}`
454-
: ownersWithFields.has(surface.owner)
455-
? undefined
456-
: surface.structuralViolation,
451+
: surface.structuralViolation,
457452
}
458453
})
459454
return { ...facts, surfaces, atoms: surfaces.flatMap((s) => s.atoms), unchecked }

‎scripts/design-conformance/control-colour-assignments.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,17 @@ export class ColourAssignments {
424424
unresolved: evaluated.unknown || evaluated.undefined || !values.length,
425425
input: pending.input,
426426
})
427-
else if (colourProperty.test(kebab(name)))
427+
else if (colourProperty.test(kebab(name))) {
428+
this.assignments.push({
429+
...pending,
430+
name: kebab(name),
431+
values,
432+
unresolved: evaluated.unknown || evaluated.undefined || !values.length,
433+
input: pending.input,
434+
direct: true,
435+
})
428436
for (const value of values) this.use(value, pending, kebab(name))
437+
}
429438
}
430439
if (names.unknown || names.undefined || !names.values.length)
431440
this.assignments.push({

‎scripts/design-conformance/control-simplifications.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,8 +776,14 @@ export function inspectSimplifications(
776776
}
777777
for (const use of controls.records) {
778778
if (use.file.startsWith('packages/emcn/') || use.syntax !== 'jsx' || use.hidden) continue
779+
const imageInput =
780+
use.target === 'native:input' &&
781+
!use.inputs.type?.unresolved &&
782+
!!use.inputs.type?.values?.length &&
783+
use.inputs.type.values.every((value) => value === 'image')
779784
const isButton =
780785
use.target === 'native:button' ||
786+
imageInput ||
781787
(use.target.startsWith('native:') &&
782788
!use.inputs.role?.unresolved &&
783789
!!use.inputs.role?.values?.length &&
@@ -822,6 +828,7 @@ export function inspectSimplifications(
822828
})
823829
)
824830
}
831+
if (state === 'empty' && imageInput) state = texts(statics.property(props, 'alt'))
825832
if (state === 'empty')
826833
state = siblings(detail.content.children.map((c) => contentName(c, detail)))
827834
if (state === 'empty') state = texts(statics.property(props, 'title'))
@@ -836,7 +843,7 @@ export function inspectSimplifications(
836843
{
837844
target: direct(use),
838845
children: 'statically empty or decorative',
839-
namingChannels: 'no nonempty aria-label, labelledby, text or title',
846+
namingChannels: 'no nonempty aria-label, labelledby, alt, text or title',
840847
}
841848
)
842849
}

‎scripts/design-conformance/extract.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,15 +1191,22 @@ export function extract(
11911191
traverse(ast, {
11921192
JSXElement(p) {
11931193
const group = elements.get(p.node)
1194-
if (!group || group.target !== 'div') return
1195-
const f = shape(p.node)
1196-
group.fieldGroup = f.field
1197-
if (
1198-
f.label &&
1199-
f.control &&
1200-
!p.node.children.some((c) => t.isJSXElement(c) && shape(c).label && shape(c).control)
1201-
)
1202-
group.fieldContainer = true
1194+
if (!group) return
1195+
if (group.target === 'div') {
1196+
const f = shape(p.node)
1197+
group.fieldGroup = f.field
1198+
if (
1199+
f.label &&
1200+
f.control &&
1201+
!p.node.children.some((c) => t.isJSXElement(c) && shape(c).label && shape(c).control)
1202+
)
1203+
group.fieldContainer = true
1204+
}
1205+
if (group.structuralViolation) {
1206+
const parent = p.findParent((q) => q.isJSXElement())
1207+
if (parent?.isJSXElement() && elements.get(parent.node)?.fieldContainer)
1208+
group.structuralViolation = undefined
1209+
}
12031210
},
12041211
})
12051212
}

‎tools/design-studio/_components/studio-fixture.tsx‎

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { type CSSProperties, useEffect, useState } from 'react'
4+
import { cn } from '@sim/emcn'
45
import * as Icons from '@sim/emcn/icons'
56
import { ComponentPreview } from '@studio/_components/component-fixtures'
67
import type { StudioSample } from '@studio/_lib/manifest'
@@ -30,17 +31,6 @@ interface StudioFixtureProps {
3031
sample?: StudioSample
3132
}
3233

33-
const SAMPLE_RADII: Record<string, string> = {
34-
'rounded-none': '0',
35-
'rounded-sm': '2px',
36-
'rounded-md': '6px',
37-
'rounded-lg': '8px',
38-
'rounded-xl': '12px',
39-
'rounded-2xl': '16px',
40-
'rounded-3xl': '24px',
41-
'rounded-full': '9999px',
42-
}
43-
4434
function GenericSample({ sample }: { sample: StudioSample }) {
4535
const authoredStyle: CSSProperties = {}
4636
if (/^(?:\d+(?:\.\d+)?(?:px|rem|em|%)|0)$/.test(sample.value)) {
@@ -55,8 +45,6 @@ function GenericSample({ sample }: { sample: StudioSample }) {
5545
const classTokens = sample.className.split(/\s+/)
5646
for (const token of classTokens) {
5747
if (token.includes(':')) continue
58-
const radius = SAMPLE_RADII[token]
59-
if (radius) authoredStyle.borderRadius = radius
6048
const arbitrary = /^([\w-]+)-\[(.+)\]!?$/.exec(token)
6149
if (!arbitrary) continue
6250
const [, utility, raw] = arbitrary
@@ -124,7 +112,7 @@ function GenericSample({ sample }: { sample: StudioSample }) {
124112
if (sample.kind === 'text') {
125113
return (
126114
<p
127-
className={`max-w-80 text-center text-[var(--text-body)] ${visualClasses}`}
115+
className={cn('max-w-80 text-center text-[var(--text-body)]', visualClasses)}
128116
style={authoredStyle}
129117
>
130118
The quick brown fox jumps over the lazy dog.
@@ -137,7 +125,7 @@ function GenericSample({ sample }: { sample: StudioSample }) {
137125
return (
138126
<label
139127
style={authoredStyle}
140-
className={`inline-flex items-center gap-3 text-[var(--text-body)] ${visualClasses}`}
128+
className={cn('inline-flex items-center gap-3 text-[var(--text-body)]', visualClasses)}
141129
>
142130
<input type='checkbox' defaultChecked />
143131
Example label
@@ -151,7 +139,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
151139
readOnly
152140
value='Example text'
153141
style={authoredStyle}
154-
className={`max-w-60 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)] ${visualClasses}`}
142+
className={cn(
143+
'max-w-60 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)]',
144+
visualClasses
145+
)}
155146
/>
156147
)
157148
}
@@ -162,7 +153,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
162153
readOnly
163154
value='Example input'
164155
style={authoredStyle}
165-
className={`max-w-60 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)] ${visualClasses}`}
156+
className={cn(
157+
'max-w-60 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)]',
158+
visualClasses
159+
)}
166160
/>
167161
)
168162
}
@@ -171,7 +165,7 @@ function GenericSample({ sample }: { sample: StudioSample }) {
171165
<a
172166
href='#sample-link'
173167
style={authoredStyle}
174-
className={`text-[var(--text-body)] underline underline-offset-4 ${visualClasses}`}
168+
className={cn('text-[var(--text-body)] underline underline-offset-4', visualClasses)}
175169
>
176170
Example link ↗
177171
</a>
@@ -181,7 +175,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
181175
return (
182176
<span
183177
style={authoredStyle}
184-
className={`flex size-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] text-[var(--text-body)] text-xs ${visualClasses}`}
178+
className={cn(
179+
'flex size-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] text-[var(--text-body)] text-xs',
180+
visualClasses
181+
)}
185182
>
186183
AL
187184
</span>
@@ -191,7 +188,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
191188
return (
192189
<span
193190
style={authoredStyle}
194-
className={`inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] px-3 py-1 text-[var(--text-body)] text-xs ${visualClasses}`}
191+
className={cn(
192+
'inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] px-3 py-1 text-[var(--text-body)] text-xs',
193+
visualClasses
194+
)}
195195
>
196196
Example tag
197197
</span>
@@ -202,7 +202,7 @@ function GenericSample({ sample }: { sample: StudioSample }) {
202202
<div
203203
role='menuitem'
204204
style={authoredStyle}
205-
className={`rounded-md px-3 py-2 text-[var(--text-body)] text-sm ${visualClasses}`}
205+
className={cn('rounded-md px-3 py-2 text-[var(--text-body)] text-sm', visualClasses)}
206206
>
207207
Example menu item
208208
</div>
@@ -212,7 +212,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
212212
return (
213213
<div
214214
style={authoredStyle}
215-
className={`min-w-40 rounded-lg border border-[var(--border)] bg-[var(--surface-3)] p-3 text-[var(--text-body)] text-sm shadow-sm ${visualClasses}`}
215+
className={cn(
216+
'min-w-40 rounded-lg border border-[var(--border)] bg-[var(--surface-3)] p-3 text-[var(--text-body)] text-sm shadow-sm',
217+
visualClasses
218+
)}
216219
>
217220
Example content
218221
</div>
@@ -222,7 +225,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
222225
return (
223226
<div
224227
style={authoredStyle}
225-
className={`min-w-40 rounded-lg border border-[var(--border)] bg-[var(--surface-3)] px-4 py-3 text-[var(--text-body)] text-sm ${visualClasses}`}
228+
className={cn(
229+
'min-w-40 rounded-lg border border-[var(--border)] bg-[var(--surface-3)] px-4 py-3 text-[var(--text-body)] text-sm',
230+
visualClasses
231+
)}
226232
>
227233
Example content
228234
</div>
@@ -232,7 +238,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
232238
<button
233239
type='button'
234240
style={authoredStyle}
235-
className={`min-h-9 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)] ${visualClasses}`}
241+
className={cn(
242+
'min-h-9 rounded-md border border-[var(--border)] bg-[var(--surface-3)] px-3 py-2 text-[var(--text-body)]',
243+
visualClasses
244+
)}
236245
>
237246
{visualClasses.includes('size-') ? '●' : 'Example action'}
238247
</button>
@@ -241,7 +250,10 @@ function GenericSample({ sample }: { sample: StudioSample }) {
241250
return (
242251
<div
243252
style={authoredStyle}
244-
className={`flex h-24 w-48 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--surface-3)] text-[var(--text-body)] ${visualClasses}`}
253+
className={cn(
254+
'flex h-24 w-48 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--surface-3)] text-[var(--text-body)]',
255+
visualClasses
256+
)}
245257
>
246258
<span className='text-xs'>Sample surface</span>
247259
</div>

0 commit comments

Comments
 (0)