Skip to content

Commit b781ae8

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(design): narrow recipe ownership and verify fixture coverage
1 parent 54f622a commit b781ae8

13 files changed

Lines changed: 373 additions & 134 deletions

File tree

‎packages/emcn/src/components/button/button.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ export interface ButtonProps
8484
iconPadding?: VariantProps<typeof buttonVariants>['iconPadding']
8585
}
8686

87+
/**
88+
* Consumers may stretch the button to their surrounding layout.
89+
* @designAllow className width
90+
* @designAllow style width
91+
*/
8792
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
8893
({ className, variant, size, iconPadding, ...props }, ref) => {
8994
return (

‎packages/emcn/src/components/chip-modal/chip-modal.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,7 @@ export type ChipModalFieldProps =
774774
* static permission list). For a multi-email chip-list input, prefer
775775
* `type='emails'` over a `type='custom'` `TagInput` wrapper — it internalizes
776776
* chip rendering, dedupe, format validation, paste, and Backspace handling.
777+
* @designProtect className *
777778
*/
778779
function ChipModalField(props: ChipModalFieldProps) {
779780
const id = React.useId()

‎packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ interface DropdownMenuContentProps
290290
onOpenAutoFocus?: (event: Event) => void
291291
}
292292

293+
/**
294+
* Menu dimensions adapt to the consumer's content and viewport.
295+
* @designAllow className dimensions
296+
* @designAllow style dimensions
297+
*/
293298
const DropdownMenuContent = React.forwardRef<
294299
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
295300
DropdownMenuContentProps

‎packages/emcn/src/components/popover/popover.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,8 @@ interface PopoverContentProps
423423

424424
/**
425425
* Popover content with automatic positioning and collision detection.
426+
* @designAllow className dimensions
427+
* @designAllow style dimensions
426428
*/
427429
const PopoverContent = React.forwardRef<
428430
React.ElementRef<typeof PopoverPrimitive.Content>,

‎scripts/design-conformance/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ CI uses immutable event base/head revisions. It publishes file/line warnings, a
3535

3636
Each component's supported finite design props come from its public type and its implementation/defaults, rather than every value in an underlying shared recipe. For example Chip does not inherit ChipDropdown-only variants. Styling inputs are separate slots; forwarded chrome is traced to its actual owner. The generator never imports or runs product modules.
3737

38+
Finite nested object lookups retain the selected recipe's properties. Barrel imports resolve to their implementation, and destructured inputs are excluded from rest forwarding. Studio fixture coverage follows each export separately: a family's adapter supports a variant only when that export actually receives the variant props. Nonvisual constants remain in the inventory without preview cards.
39+
3840
## Ownership metadata
3941

4042
Ordinary layout remains local. Consumer changes to component-authored colours, typography, borders, radius, spacing, dimensions or effects are findings, including newly added components without registration. Use a supported component prop/variant first. Deliberate customization or ownership that cannot be inferred belongs in the component's existing TSDoc:

‎scripts/design-conformance/contracts.generated.json‎

Lines changed: 60 additions & 60 deletions
Large diffs are not rendered by default.

‎scripts/design-conformance/contracts.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
"height",
7676
"colour"
7777
],
78-
"permission": "Registered field icon slots own geometry; no universal icon size is inferred",
78+
"permission": "Source-derived field icon slots own geometry; no universal icon size is inferred",
7979
"source": ".claude/rules/sim-styling.md#Chip Components (consumer usage)"
8080
},
8181
"central-definition": {

‎scripts/design-conformance/generated-contracts.ts‎

Lines changed: 119 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,44 @@ async function generate(
273273
}
274274
return
275275
}
276+
const memberValues = (
277+
file: string,
278+
node: t.Node,
279+
seen = new Set<string>()
280+
): { file: string; node: t.Node }[] => {
281+
node = unwrap(node)
282+
if (seen.size >= 12) return []
283+
if (t.isIdentifier(node)) {
284+
const ref = `${file}#${node.name}`
285+
const found = locate(file, node.name)
286+
return found && !seen.has(ref)
287+
? memberValues(found.file, found.node, new Set(seen).add(ref))
288+
: []
289+
}
290+
if (t.isConditionalExpression(node) || t.isLogicalExpression(node)) {
291+
const branches = t.isConditionalExpression(node)
292+
? [node.consequent, node.alternate]
293+
: [node.left, node.right]
294+
return branches.flatMap((branch) => memberValues(file, branch, seen))
295+
}
296+
if (t.isMemberExpression(node)) {
297+
const selected =
298+
!node.computed || t.isStringLiteral(node.property) || t.isNumericLiteral(node.property)
299+
? key(node.property)
300+
: undefined
301+
return memberValues(file, node.object, seen).flatMap((object) =>
302+
t.isObjectExpression(object.node)
303+
? object.node.properties.flatMap((property) =>
304+
t.isObjectProperty(property) &&
305+
(selected === undefined || key(property.key) === selected)
306+
? memberValues(object.file, property.value, seen)
307+
: []
308+
)
309+
: []
310+
)
311+
}
312+
return [{ file, node }]
313+
}
276314
const strings = (file: string, node: t.Node, seen = new Set<string>()): string[] => {
277315
node = unwrap(node)
278316
if (seen.size >= 12) {
@@ -304,16 +342,10 @@ async function generate(
304342
return node.properties.flatMap((p) =>
305343
t.isObjectProperty(p) ? strings(file, p.value, seen) : []
306344
)
307-
if (t.isMemberExpression(node) && t.isIdentifier(node.object)) {
308-
const found = locate(file, node.object.name)
309-
if (!found && modules.get(file)?.imports.has(node.object.name))
310-
note(
311-
file,
312-
node.loc?.start.line ?? 1,
313-
`Unresolved imported styling: ${node.object.name}.${key(node.property)}`
314-
)
315-
if (found)
316-
return strings(found.file, found.node, new Set(seen).add(`${file}#${node.object.name}`))
345+
if (t.isMemberExpression(node)) {
346+
const values = memberValues(file, node, seen)
347+
if (!values.length) note(file, node.loc?.start.line ?? 1, 'Unresolved styling member lookup')
348+
return values.flatMap((value) => strings(value.file, value.node, seen))
317349
}
318350
if (t.isFunction(node)) {
319351
const returns: t.Node[] = []
@@ -645,19 +677,24 @@ async function generate(
645677
const usedRecipes = new Set<string>()
646678
const params = new Map<string, string>()
647679
const objects = new Set<string>()
648-
const rests = new Set<string>()
680+
const rests = new Map<string, Set<string>>()
681+
const patternProps = (pattern: t.ObjectPattern) => {
682+
const consumed = new Set(
683+
pattern.properties.flatMap((p) => (t.isObjectProperty(p) ? [key(p.key)] : []))
684+
)
685+
for (const field of pattern.properties) {
686+
if (t.isRestElement(field) && t.isIdentifier(field.argument))
687+
rests.set(field.argument.name, consumed)
688+
if (t.isObjectProperty(field)) {
689+
const value = t.isAssignmentPattern(field.value) ? field.value.left : field.value
690+
if (t.isIdentifier(value)) params.set(value.name, key(field.key))
691+
}
692+
}
693+
}
649694
if (meta.fn) {
650695
for (const p of meta.fn.params) {
651696
if (t.isIdentifier(p)) objects.add(p.name)
652-
if (t.isObjectPattern(p))
653-
for (const field of p.properties) {
654-
if (t.isRestElement(field) && t.isIdentifier(field.argument))
655-
rests.add(field.argument.name)
656-
if (t.isObjectProperty(field)) {
657-
const value = t.isAssignmentPattern(field.value) ? field.value.left : field.value
658-
if (t.isIdentifier(value)) params.set(value.name, key(field.key))
659-
}
660-
}
697+
if (t.isObjectPattern(p)) patternProps(p)
661698
}
662699
t.traverseFast(meta.fn.body, (n) => {
663700
if (
@@ -666,14 +703,7 @@ async function generate(
666703
t.isIdentifier(n.init) &&
667704
objects.has(n.init.name)
668705
)
669-
for (const field of n.id.properties) {
670-
if (t.isRestElement(field) && t.isIdentifier(field.argument))
671-
rests.add(field.argument.name)
672-
if (t.isObjectProperty(field)) {
673-
const value = t.isAssignmentPattern(field.value) ? field.value.left : field.value
674-
if (t.isIdentifier(value)) params.set(value.name, key(field.key))
675-
}
676-
}
706+
patternProps(n.id)
677707
})
678708
const propOf = (n: t.Node) =>
679709
t.isIdentifier(n)
@@ -727,22 +757,24 @@ async function generate(
727757
forwards: [],
728758
}
729759
}
730-
const spread = n.attributes.some(
731-
(a) =>
732-
t.isJSXSpreadAttribute(a) &&
733-
t.isIdentifier(a.argument) &&
734-
(rests.has(a.argument.name) || objects.has(a.argument.name))
735-
)
760+
const spreadInputs = new Set<string>()
761+
for (const attribute of n.attributes) {
762+
if (!t.isJSXSpreadAttribute(attribute) || !t.isIdentifier(attribute.argument)) continue
763+
const name = attribute.argument.name
764+
if (!rests.has(name) && !objects.has(name)) continue
765+
for (const input of Object.keys(entry.slots))
766+
if (!rests.get(name)?.has(input)) spreadInputs.add(input)
767+
}
736768
for (const attribute of n.attributes)
737769
if (t.isJSXSpreadAttribute(attribute) && t.isIdentifier(attribute.argument)) {
738770
const bundle = bundles.get(attribute.argument.name)
739771
if (bundle && !native)
740772
for (const [forwarded, input] of bundle)
741773
entry.slots[input]?.forwards.push({ target, slot: forwarded })
742774
}
743-
if (spread && !native)
744-
for (const [name, slot] of Object.entries(entry.slots))
745-
slot.forwards.push({ target, slot: name })
775+
if (!native)
776+
for (const input of spreadInputs)
777+
entry.slots[input].forwards.push({ target, slot: input })
746778
for (const attr of n.attributes) {
747779
if (!t.isJSXAttribute(attr) || !attr.value) continue
748780
const slotName = key(attr.name)
@@ -769,7 +801,7 @@ async function generate(
769801
}
770802
}
771803
})
772-
if (spread && entry.slots[slotName]) inputs.add(slotName)
804+
if (spreadInputs.has(slotName)) inputs.add(slotName)
773805
const owned = protectedFor([
774806
...strings(meta.file, value),
775807
...[...recipes].flatMap((r) => out.recipes[r].classes),
@@ -781,7 +813,14 @@ async function generate(
781813
if (category(property)) owned.push(family(property))
782814
}
783815
})
784-
if (slotName === 'className' && inputs.has('className') && entry.slots.style)
816+
if (
817+
native &&
818+
slotName === 'className' &&
819+
inputs.has('className') &&
820+
(spreadInputs.has('style') ||
821+
n.attributes.some((a) => t.isJSXAttribute(a) && key(a.name) === 'style')) &&
822+
entry.slots.style
823+
)
785824
inputs.add('style')
786825
for (const input of inputs) {
787826
const slot = entry.slots[input]
@@ -871,8 +910,47 @@ async function generate(
871910
}
872911
}
873912
// Resolve slot forwarding to a fixed point. Unknown routes stay diagnostics, never permission.
874-
const publicTarget = (target: string) =>
875-
target.startsWith('@sim/emcn#') ? target.slice(10) : internalIds.get(target)
913+
const targetCache = new Map<string, string | undefined>()
914+
const publicTarget = (target: string): string | undefined => {
915+
if (target.startsWith('@sim/emcn#')) return target.slice(10)
916+
if (internalIds.has(target)) return internalIds.get(target)
917+
if (targetCache.has(target)) return targetCache.get(target)
918+
const [file, name] = target.split('#')
919+
const module = program.getSourceFile(path.join(virtualRoot, file))
920+
const moduleSymbol = module && checker.getSymbolAtLocation(module)
921+
const [root, ...members] = (name ?? '').split('.')
922+
let symbol =
923+
moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((s) => s.name === root)
924+
for (const member of members) {
925+
if (!symbol) break
926+
if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
927+
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]
928+
symbol = declaration
929+
? checker.getTypeOfSymbolAtLocation(symbol, declaration).getProperty(member)
930+
: undefined
931+
}
932+
if (symbol?.flags && symbol.flags & ts.SymbolFlags.Alias)
933+
symbol = checker.getAliasedSymbol(symbol)
934+
let declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
935+
if (declaration && ts.isShorthandPropertyAssignment(declaration)) {
936+
symbol = checker.getShorthandAssignmentValueSymbol(declaration)
937+
declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
938+
}
939+
const sourceName =
940+
declaration &&
941+
ts.isPropertyAssignment(declaration) &&
942+
ts.isIdentifier(declaration.initializer)
943+
? declaration.initializer.text
944+
: symbol?.name
945+
const result =
946+
declaration && sourceName
947+
? internalIds.get(
948+
`${path.relative(virtualRoot, declaration.getSourceFile().fileName)}#${sourceName}`
949+
)
950+
: undefined
951+
targetCache.set(target, result)
952+
return result
953+
}
876954
for (let pass = 0; pass < 12; pass++) {
877955
let changed = false
878956
for (const entry of Object.values(out.exports))

‎scripts/design-scan/inventory.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ test('CLI on synthetic repositories preserves 0/1/2, writes complete reports, an
496496
run(['--repo', valid.repo, '--ref', 'HEAD', '--output', path.join(valid.base, 'quiet')]).status
497497
).toBe(0)
498498
expect(f.git('status', '--porcelain')).toBe(status)
499-
})
499+
}, 60_000)
500500

501501
test('scanner matches an external review ledger without hiding raw findings', async () => {
502502
const f = fixture({ [component]: text('text-[#123456]') })

‎scripts/design-studio/refresh.mjs‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ function usageIndex(componentNames, iconNames) {
116116
const kind =
117117
from === '@sim/emcn/icons'
118118
? 'icon'
119-
: componentNames.has(name)
119+
: componentNames.has(name) ||
120+
[...componentNames].some((n) => n.startsWith(`${name}.`))
120121
? 'component'
121122
: iconNames.has(name)
122123
? 'icon'
@@ -132,18 +133,20 @@ function usageIndex(componentNames, iconNames) {
132133
const tag = jsxName(node.name)
133134
const parts = tag.split('.')
134135
const from = namespaces.get(parts[0])
135-
const record = from
136+
let record = from
136137
? {
137-
name: parts[1],
138+
name: parts.slice(1).join('.'),
138139
kind:
139140
from === '@sim/emcn/icons'
140141
? 'icon'
141-
: componentNames.has(parts[1])
142+
: componentNames.has(parts.slice(1).join('.'))
142143
? 'component'
143144
: 'icon',
144145
}
145146
: imported.get(parts[0])
146147
if (!record) return
148+
if (!from && parts.length > 1)
149+
record = { ...record, name: `${record.name}.${parts.slice(1).join('.')}` }
147150
const key = `${record.kind}:${record.name}`
148151
if (!(record.kind === 'component' ? componentNames : iconNames).has(record.name)) return
149152
const site = {
@@ -171,20 +174,19 @@ function fixtureInventory() {
171174
walk(ast, (node) => {
172175
if (node.type !== 'SwitchCase' || node.test?.type !== 'StringLiteral') return
173176
const names = new Set()
174-
let acceptsVariant = false
177+
const variantNames = new Set()
175178
for (const statement of node.consequent)
176179
walk(statement, (part) => {
177-
if (part.type === 'JSXOpeningElement') names.add(jsxName(part.name).split('.')[0])
178-
if (part.type === 'JSXSpreadAttribute')
179-
walk(part.argument, (value) => {
180-
if (value.type === 'Identifier' && value.name === 'variantProps') acceptsVariant = true
181-
})
182-
if (part.type === 'JSXAttribute' && part.value)
183-
walk(part.value, (value) => {
184-
if (value.type === 'Identifier' && value.name === 'variant') acceptsVariant = true
180+
if (part.type !== 'JSXOpeningElement') return
181+
const name = jsxName(part.name)
182+
names.add(name)
183+
for (const attribute of part.attributes)
184+
walk(attribute, (value) => {
185+
if (value.type === 'Identifier' && ['variantProps', 'variant'].includes(value.name))
186+
variantNames.add(name)
185187
})
186188
})
187-
cases.set(node.test.value, { names, acceptsVariant })
189+
cases.set(node.test.value, { names, variantNames })
188190
})
189191
return cases
190192
}
@@ -203,7 +205,7 @@ function componentInventory() {
203205
source: path.join(repo, facts.source.file),
204206
facts,
205207
}))
206-
const componentExports = discovered.filter((item) => item.facts.kind !== 'icon')
208+
const componentExports = discovered.filter((item) => item.facts.kind === 'component')
207209
const iconExports = discovered.filter((item) => item.facts.kind === 'icon')
208210
const byName = new Map()
209211
for (const item of componentExports)
@@ -295,7 +297,9 @@ function componentInventory() {
295297
values: axis.values.map(String),
296298
defaultValue: axis.default === undefined ? undefined : String(axis.default),
297299
}))
298-
const supportsVariants = Boolean(fixture && fixtureCases.get(fixture.id)?.acceptsVariant)
300+
const supportsVariants = Boolean(
301+
fixture && fixtureCases.get(fixture.id)?.variantNames.has(item.name)
302+
)
299303
for (const axis of axes)
300304
for (const value of axis.values) {
301305
if (value === axis.defaultValue) continue

0 commit comments

Comments
 (0)