Guard Eidos component API shape

active-uix
dev 5 months ago
parent a8045658aa
commit 250ae7f75c

@ -157,6 +157,24 @@ Actualizacion Eidos recipe contract 2026-05-16:
- `npx vitest run src/uix/eidos` -> 5 archivos, 59 tests OK.
- `npm run check` -> 0 errores, 0 warnings.
Actualizacion Eidos component API 2026-05-16:
- Corregida documentacion stale de `Collapsible`: ya no describe dos shapes,
ni flat con snippet `trigger`, ni `<Collapsible.Provider>` publico. La
superficie vigente es `<Collapsible>` + `.Trigger` + `.Content`; el provider
de Soma queda como detalle interno.
- Corregido comentario stale en `Dialog.Close`: no existe un auto-render flat
de `<Dialog>`.
- Guardia nueva `src/uix/eidos/component-api-contract.test.ts`:
- barrels de componentes sin `Object.assign`;
- sin export publico de `Provider`;
- sin imports publicos desde `*-provider.svelte`;
- cada componente publico, salvo `svg` primitives, tiene root
`{component}.svelte`.
- Validacion:
- `npx vitest run src/uix/eidos` -> 6 archivos, 61 tests OK.
- `npm run check` -> 0 errores, 0 warnings.
Actualizacion 2026-05-16:
- Barrido de wrappers publicos Soma frente a providers cerrado:

@ -0,0 +1,51 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const COMPONENTS_DIR = 'src/uix/eidos/components'
const ROOT_FILE_EXCEPTIONS = new Set(['svg'])
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
}
function readComponentDirs(): readonly string[] {
return readdirSync(COMPONENTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
}
function readIndexSource(component: string): string {
return stripComments(readFileSync(join(COMPONENTS_DIR, component, 'index.ts'), 'utf8'))
}
describe('Eidos component API contract', () => {
it('keeps component barrels on the disciplined option C surface', () => {
const violations: string[] = []
for (const component of readComponentDirs()) {
const source = readIndexSource(component)
if (/\bObject\.assign\s*\(/.test(source)) {
violations.push(`${component}: Object.assign`)
}
if (/export\s+(?:type\s+)?\{[^}]*\bProvider\b[^}]*\}/s.test(source)) {
violations.push(`${component}: public Provider export`)
}
if (/from\s+['"]\.\/.*provider\.svelte['"]/.test(source)) {
violations.push(`${component}: provider component import`)
}
}
expect(violations).toEqual([])
})
it('keeps every public Eidos component rooted at {component}.svelte', () => {
const missingRoots = readComponentDirs()
.filter((component) => !ROOT_FILE_EXCEPTIONS.has(component))
.filter((component) => !existsSync(join(COMPONENTS_DIR, component, `${component}.svelte`)))
expect(missingRoots).toEqual([])
})
})

@ -1,53 +1,29 @@
# `eidos/components/collapsible/`
Disclosure pattern: trigger + content with open/closed state.
Disclosure pattern: trigger + content with open/closed state. Eidos exposes
one public shape: root plus attached child parts. The root wraps Soma's
provider internally, but `Provider` is not part of the Eidos API.
## Dos shapes (per guide §13)
**Flat ergonomic — el 90% case.** Default export. Auto-compone Provider
- Trigger + Content vía un `trigger` snippet:
## API pública
```svelte
<script>
import Collapsible from '$uix/eidos/components/collapsible';
import { Collapsible } from '$uix/eidos/components/collapsible';
let open = $state(false);
</script>
<Collapsible bind:open>
{#snippet trigger()}Show details{/snippet}
<p>The disclosure body.</p>
</Collapsible>
```
**Compound — casos avanzados.** Cuando los parts viven en distintos
subtrees del DOM, hay múltiples triggers, o el content se renderea
condicionalmente:
```svelte
<script>
import * as Collapsible from '$uix/eidos/components/collapsible';
let open = $state(false);
</script>
<header>
<Collapsible.Provider bind:open>
<Collapsible.Trigger>Show details</Collapsible.Trigger>
</Collapsible.Provider>
</header>
<aside>
<!-- Same Provider context, content lives elsewhere in the DOM -->
<Collapsible.Trigger>Show details</Collapsible.Trigger>
<Collapsible.Content>
<p>The disclosure body.</p>
</Collapsible.Content>
</aside>
</Collapsible>
```
Y si ningún shape encaja dentro de Eidos, baja al Soma
`$soma/components/collapsible` y al contrato morfo correspondiente
para crear un wrapper nuevo. **Headless + morfo son la primitiva
universal**; eidos es el atajo opinionado del design system, no un
wrapper bloqueante.
Si esta composición no encaja, baja al Soma `$soma/components/collapsible`
y al contrato morfo correspondiente para crear otro wrapper. Headless +
morfo son la primitiva universal; Eidos es la capa visual, no un fork del
comportamiento.
### Props
@ -66,8 +42,8 @@ Pure pass-through from the Soma. No eidos-specific props.
- **No `intent` / `color`** — disclosure es no-evaluativo per guide §3.1
(Accordion no aparece en la tabla de subsets, default neutral).
- **No `size`** — air no lo tenía; sin regresión.
- **Multi-part API**: `Provider + Trigger + Content`. NO flat default
per doctrine §10 (solo single-part exports flat).
- **Multi-part API**: `<Collapsible>` + `Trigger` + `Content`. Sin `Provider`
publico y sin flat con snippet slots.
## Eventos sema
@ -101,11 +77,11 @@ Ambos `emerge` (transitional family — sin intent), `sequence='pre'`
```
collapsible/
collapsible.css → recipe (selectores [data-collapsible*])
collapsible-provider.svelte → wrapper sobre headless Provider
collapsible.svelte → root visual sobre el provider headless
collapsible-trigger.svelte → wrapper sobre headless Trigger
collapsible-content.svelte → wrapper sobre headless Content
types.ts → re-exports headless + JSDoc
index.ts → barrel multi-part (Provider/Trigger/Content)
index.ts → barrel Option C (Root/Trigger/Content)
README.md → este archivo
```

@ -1,8 +1,8 @@
<script lang="ts">
/**
* Eidos `<Collapsible>` — root component. Wraps Soma's
* `<Collapsible.Provider>` to set up the headless context;
* children compose via the named members attached in `index.ts`:
* root provider internally; children compose via the named members
* attached in `index.ts`:
*
* <Collapsible bind:open>
* <Collapsible.Trigger>Toggle</Collapsible.Trigger>

@ -6,27 +6,23 @@ import type {
} from '$soma/components/collapsible';
/**
* Eidos Collapsible exposes BOTH shapes per guide §10:
* Eidos Collapsible follows the disciplined option C shape:
*
* - Flat `<Collapsible>` (default export) — ergonomic 90% case. Takes
* a `trigger` snippet for the toggle label and `children` for the
* content body. Auto-composes Provider + Trigger + Content.
* <Collapsible>
* <Collapsible.Trigger>...</Collapsible.Trigger>
* <Collapsible.Content>...</Collapsible.Content>
* </Collapsible>
*
* - Compound `<Collapsible.Provider>` + `<Collapsible.Trigger>` +
* `<Collapsible.Content>` — advanced cases where the parts need to
* live in different DOM subtrees, multiple triggers point at the
* same content, or content rendering is conditional.
*
* Both shapes share the same headless behaviour (state, ARIA, keyboard,
* focus). Eidos for collapsible doesn't add evaluative props — disclosure
* es no-evaluativo per guide §3.1 (no `intent` / `color` / `size`).
* The root wraps the Soma provider internally, but Eidos does not expose
* `Provider` as a public part. Collapsible does not add evaluative props:
* disclosure es no-evaluativo per guide §3.1 (no `intent` / `color` / `size`).
*/
/** Props for the `<Collapsible>` root. Pass-through from headless. */
export type CollapsibleProps = SomaCollapsibleProviderProps;
/** Props for the compound `<Collapsible.Trigger>`. Pass-through from headless. */
/** Props for `<Collapsible.Trigger>`. Pass-through from headless. */
export type CollapsibleTriggerProps = SomaCollapsibleTriggerProps;
/** Props for the compound `<Collapsible.Content>`. Pass-through from headless. */
/** Props for `<Collapsible.Content>`. Pass-through from headless. */
export type CollapsibleContentProps = SomaCollapsibleContentProps;

@ -1,10 +1,10 @@
<script lang="ts">
/**
* Default `position` is intentionally `undefined`. Only when the consumer
* (or the flat `<Dialog>` auto-render) passes a position does the
* resulting `data-position` attr land on the button — and only then does
* the recipe's `[data-dialog-close][data-position='X']` rule kick in to
* float the close absolutely over Content.
* passes a position does the resulting `data-position` attr land on the
* button — and only then does the recipe's
* `[data-dialog-close][data-position='X']` rule kick in to float the close
* absolutely over Content.
*
* Without `data-position`, the button flows naturally — useful inside
* `actions` snippets or other compound layouts where the close should

Loading…
Cancel
Save

Powered by TurnKey Linux.