diff --git a/src/uix/contracts.test.ts b/src/uix/contracts.test.ts index b6b5f9a5a..407239fdd 100644 --- a/src/uix/contracts.test.ts +++ b/src/uix/contracts.test.ts @@ -71,6 +71,46 @@ function grepSources(root: string, pattern: RegExp): string[] { return listSourceFiles(root).filter((file) => pattern.test(readFileSync(file, 'utf8'))); } +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +function collectKnownMorfoDataAttrs(): Set { + const attrs = new Set(); + for (const file of listSourceFiles(join(HERE, 'morfo', 'components'))) { + const source = stripComments(readFileSync(file, 'utf8')); + const component = /kebab:\s*'([^']+)'/.exec(source)?.[1]; + if (!component) continue; + + const partRe = /kebab:\s*'([^']+)'/g; + let partMatch: RegExpExecArray | null; + while ((partMatch = partRe.exec(source))) { + const part = partMatch[1]; + attrs.add(part === 'provider' ? `data-${component}` : `data-${component}-${part}`); + } + + const dataRe = /attr:\s*'([^']+)'/g; + let dataMatch: RegExpExecArray | null; + while ((dataMatch = dataRe.exec(source))) { + if (dataMatch[1].startsWith('data-')) attrs.add(dataMatch[1]); + } + } + return attrs; +} + +function collectDataAttrs(source: string): Set { + const clean = stripComments(source); + const attrs = new Set(); + const stringRe = /['"`](data-[a-zA-Z0-9_-]+)['"`]/g; + const inlineRe = /\b(data-[a-zA-Z0-9_-]+)(?=(?:=|\s|>|\/|}|\]))/g; + let match: RegExpExecArray | null; + + while ((match = stringRe.exec(clean))) attrs.add(match[1]); + while ((match = inlineRe.exec(clean))) attrs.add(match[1]); + + return attrs; +} + describe('UIX layer contracts', () => { it('pins ActiveUix public service names and rejects legacy aliases', () => { const uix = createActiveUix({ langs: minimalLang }); @@ -205,6 +245,27 @@ describe('UIX layer contracts', () => { expect(violations).toEqual([]); }); + it('guards Soma from direct mutable DOM writes', () => { + const violations = grepSources( + join(HERE, 'soma'), + /\.setAttribute\(|\.removeAttribute\(|\.toggleAttribute\(|\.classList\.|\.style\.setProperty\(|\.style\.[a-zA-Z]+\s*=/ + ); + + expect(violations).toEqual([]); + }); + + it('guards hardcoded Soma component data attrs with morfo contracts', () => { + const knownAttrs = collectKnownMorfoDataAttrs(); + const violations = listSourceFiles(join(HERE, 'soma', 'components')).flatMap((file) => { + const attrs = collectDataAttrs(readFileSync(file, 'utf8')); + return [...attrs] + .filter((attr) => !knownAttrs.has(attr)) + .map((attr) => `${file.replace(REPO_ROOT, '')}: ${attr}`); + }); + + expect(violations).toEqual([]); + }); + it('events/sema uses events as service name and requires dom or projector for visual projection', () => { const factory = defineEngineSemantic({ visual: false }); expect(factory.name).toBe(UIX_LAYER_CONTRACTS.events.publicServiceName); diff --git a/src/uix/eidos/components/popover/popover.css b/src/uix/eidos/components/popover/popover.css index edde79d02..3dc4c499d 100644 --- a/src/uix/eidos/components/popover/popover.css +++ b/src/uix/eidos/components/popover/popover.css @@ -203,7 +203,7 @@ stroke: none; } -[data-popover-arrow] [data-arrow-outline] { +[data-popover-arrow] path { stroke: var(--popover-border); stroke-width: var(--popover-border-width); stroke-linecap: round; diff --git a/src/uix/soma/components/internal/arrow.svelte b/src/uix/soma/components/internal/arrow.svelte index 931d40088..41c6d91ca 100644 --- a/src/uix/soma/components/internal/arrow.svelte +++ b/src/uix/soma/components/internal/arrow.svelte @@ -29,15 +29,9 @@ {#if children} {@render children?.()} {:else} - - - + + + {/if}