parent
5f7b2354a8
commit
e6eee766ee
@ -0,0 +1,178 @@
|
||||
# Words - continuation notes
|
||||
|
||||
Fecha: 2026-05-26
|
||||
|
||||
## Contexto
|
||||
|
||||
Trabajo acotado al ecosistema `words`, salvo el documento previo
|
||||
`src/uix/eidos/eidos-motion.md` de la arquitectura de motion. El usuario pidio
|
||||
mejorar `Words` porque el toolbar y los popovers no estaban aprovechando bien
|
||||
Eidos.
|
||||
|
||||
Antes de tocar componente se releyeron:
|
||||
|
||||
- `web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md`
|
||||
- `web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md`
|
||||
- `src/uix/eidos/components/README.md`
|
||||
|
||||
## Hecho
|
||||
|
||||
### Toolbar y componentes Eidos
|
||||
|
||||
- `Words` root renderiza el toolbar automatico con wrappers locales de Eidos:
|
||||
`Toolbar`, `ToolbarGroup`, `CommandButton`, `HeadingPicker`, `LinkEditor` y
|
||||
`FindReplace`.
|
||||
- `WordsCommandButton` envuelve `Button` de Eidos y soporta:
|
||||
- `variant`, `size`, `rounded`, `iconOnly`
|
||||
- `toolbarItem` para evitar marcadores de toolbar cuando se usa dentro de
|
||||
paneles flotantes.
|
||||
- Se corrigio el bug por el que los iconos no llegaban al slot `icon` de
|
||||
`Button`; los botones icon-only ya renderizan SVG.
|
||||
- Se elimino `words-tool-popover.svelte`; `Words` usa `Popover` real de Eidos.
|
||||
|
||||
### Heading picker y link editor
|
||||
|
||||
- `Words.HeadingPicker` usa `Popover.Content` portaled con:
|
||||
- `side="top"`
|
||||
- `align="center"`
|
||||
- `sideOffset={8}`
|
||||
- `arrowPadding={8}`
|
||||
- `Popover.Arrow`
|
||||
- El panel de headings usa `CommandButton toolbarItem={false}` para no heredar
|
||||
estilos de toolbar dentro del panel.
|
||||
- `Words.LinkEditor` usa `Popover.Content` centrado, con flecha y acciones con
|
||||
`Button` de Eidos.
|
||||
- Se agrego `[data-words-floating]` como puente de tokens para contenido
|
||||
portaled. Esto evita que los popovers pierdan variables CSS internas de
|
||||
`Words`.
|
||||
|
||||
### Find/replace
|
||||
|
||||
- Se implemento `FindReplace` transversal en Morfo/Soma/Eidos:
|
||||
- Morfo declara la parte `find-replace` y el evento `commit-set-replace`.
|
||||
- Soma mantiene `findQuery`, matches, indice activo y operaciones de
|
||||
reemplazo.
|
||||
- Eidos pinta el panel, inputs, contador, botones e highlights.
|
||||
- El motor soporta:
|
||||
- busqueda case-insensitive por defecto
|
||||
- opcion case-sensitive a nivel engine
|
||||
- highlights en DOM con `mark[data-words-find-match]`
|
||||
- highlight activo con `data-words-find-active`
|
||||
- reemplazo de todas las coincidencias de forma estable.
|
||||
- `Words.FindReplace` paso de banda fija bajo el toolbar a popover:
|
||||
- trigger compacto `[data-words-find-trigger]`
|
||||
- `Popover.Content` centrado con `Popover.Arrow`
|
||||
- panel `role="search"` dentro del popover
|
||||
- `toolbarItem={false}` para uso manual fuera de toolbar.
|
||||
- La demo permite activar/desactivar `FindReplace` y refleja la parte en los
|
||||
snippets.
|
||||
|
||||
### Editor features
|
||||
|
||||
- Text alignment: `align-left`, `align-center`, `align-right`,
|
||||
`align-justify`.
|
||||
- Heading picker: H1/H2/H3/Paragraph.
|
||||
- Indentacion de listas:
|
||||
- `Tab` aumenta indent
|
||||
- `Shift+Tab` reduce indent
|
||||
- `increase-indent` / `decrease-indent` en toolbar full.
|
||||
- Soft breaks con `Shift+Enter`.
|
||||
- Triple click selecciona bloque entero.
|
||||
- Import/export:
|
||||
- HTML preserva indent de listas.
|
||||
- Markdown preserva indent de listas.
|
||||
- Sincronizacion externa de `value` sin remount del editor.
|
||||
|
||||
### Documentacion
|
||||
|
||||
- `README.md` de `words` actualizado con decisiones, fases V1.1 y estado de
|
||||
find/replace.
|
||||
- Demo `web/routes/uix/components/words/+page.svelte` actualizada con controles,
|
||||
snippets y tablas API/A11y/Recipe.
|
||||
|
||||
## Verificado
|
||||
|
||||
Comandos ejecutados y estado:
|
||||
|
||||
- `npx prettier --check ...words... web/routes/uix/components/words/+page.svelte`
|
||||
- OK.
|
||||
- `npx vitest run src/uix/soma/components/words/engine/engine.test.ts src/uix/soma/components/words/words-provider.svelte.test.ts src/uix/soma/components/words/words-content.svelte.test.ts src/uix/eidos/recipe-css-contract.test.ts src/uix/eidos/component-api-contract.test.ts src/uix/eidos/component-visual-attrs.test.ts`
|
||||
- 6 files passed, 96 tests passed.
|
||||
- `node --import tsx/esm scripts/component-audit.ts --only words`
|
||||
- PASS.
|
||||
- `node --import tsx/esm scripts/eidos-lint.ts words`
|
||||
- invalid: 0.
|
||||
|
||||
Browser:
|
||||
|
||||
- Dev server activo en `http://127.0.0.1:5174/uix/components/words`.
|
||||
- Verificado que el trigger de find/replace abre popover:
|
||||
- `data-state="open"`
|
||||
- `side="top"`
|
||||
- `alignDelta: 0`
|
||||
- `hasArrow: true`
|
||||
- `inputCount: 2`
|
||||
- `buttonCount: 3`
|
||||
- Verificado previamente que el heading picker renderiza 4 botones con icono y
|
||||
no se solapa con find/replace.
|
||||
|
||||
## Bloqueos conocidos
|
||||
|
||||
`npm run check` global no queda verde por un error fuera del ambito `words`:
|
||||
|
||||
```txt
|
||||
src/uix/soma/components/command/command-provider.svelte.ts:384:8
|
||||
Error: Cannot find name 'attrs'. Did you mean 'Attr'?
|
||||
```
|
||||
|
||||
Ese fichero estaba modificado fuera del scope de `words`; no se toco para
|
||||
respetar el limite pedido por el usuario. Tambien aparecen 24 warnings
|
||||
preexistentes en demos/componentes no relacionados y errores de carga en
|
||||
`tmp/lexical` por dependencias ausentes.
|
||||
|
||||
## Pendiente para manana
|
||||
|
||||
1. Reabrir la demo de `Words` y hacer una pasada visual completa:
|
||||
- heading picker
|
||||
- link editor
|
||||
- find/replace
|
||||
- toolbar horizontal y vertical
|
||||
- mobile/narrow viewport si aplica.
|
||||
2. Confirmar con browser que los tres popovers de `Words` tienen:
|
||||
- `align="center"`
|
||||
- `Popover.Arrow`
|
||||
- `side="top"`
|
||||
- sin solape visual incoherente.
|
||||
3. Revisar si `side="top"` debe quedar fijo en `Words` o si algunos paneles
|
||||
deberian permitir flip visual manteniendo flecha.
|
||||
4. Si se autoriza salir del ambito `words`, corregir el error externo de
|
||||
`src/uix/soma/components/command/command-provider.svelte.ts` (`attrs` fuera
|
||||
de scope) y repetir `npm run check`.
|
||||
5. Revisar posibles siguientes features del editor:
|
||||
- code blocks
|
||||
- slash commands
|
||||
- tablas
|
||||
- imagenes/archivos
|
||||
- find options UI: case sensitive, whole word, regex si se decide.
|
||||
6. Antes de nuevas features, repetir el pre-flight obligatorio y preparar una
|
||||
mini matriz contra Radix/Bits/Ark/React Aria/Chakra/MUI segun corresponda.
|
||||
|
||||
## Archivos tocados principales
|
||||
|
||||
- `src/uix/eidos/components/words/words.svelte`
|
||||
- `src/uix/eidos/components/words/words-command-button.svelte`
|
||||
- `src/uix/eidos/components/words/words-heading-picker.svelte`
|
||||
- `src/uix/eidos/components/words/words-link-editor.svelte`
|
||||
- `src/uix/eidos/components/words/words-find-replace.svelte`
|
||||
- `src/uix/eidos/components/words/words.css`
|
||||
- `src/uix/eidos/components/words/types.ts`
|
||||
- `src/uix/eidos/components/words/README.md`
|
||||
- `src/uix/soma/components/words/**`
|
||||
- `src/uix/morfo/components/words.ts`
|
||||
- `web/routes/uix/components/words/+page.svelte`
|
||||
|
||||
## Nota de continuidad
|
||||
|
||||
No hacer `git reset`, `stash` ni `checkout` de ficheros: el worktree tiene
|
||||
cambios previos del usuario y cambios de esta sesion. Trabajar por diffs
|
||||
acotados y respetar el scope que indique el usuario al retomar.
|
||||
@ -1,14 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import * as Words from '$soma/components/words';
|
||||
import type { WordsCommandButtonProps } from './types';
|
||||
|
||||
let { children, ...rest }: WordsCommandButtonProps = $props();
|
||||
let {
|
||||
children,
|
||||
child: outerChild,
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
rounded = 'md',
|
||||
iconOnly = true,
|
||||
toolbarItem = true,
|
||||
...rest
|
||||
}: WordsCommandButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
<Words.CommandButton {...rest}>
|
||||
{#snippet child({ props })}
|
||||
{#if outerChild}
|
||||
{@render outerChild({
|
||||
props: {
|
||||
...props,
|
||||
'data-toolbar-button': toolbarItem ? '' : undefined,
|
||||
'data-toolbar-group-item': toolbarItem ? '' : undefined
|
||||
}
|
||||
})}
|
||||
{:else}
|
||||
{@const label = props['aria-label']}
|
||||
{#if children}
|
||||
<Button
|
||||
{...props}
|
||||
{variant}
|
||||
{size}
|
||||
{rounded}
|
||||
{iconOnly}
|
||||
intent="neutral"
|
||||
color="neutral"
|
||||
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{@render children()}
|
||||
</Words.CommandButton>
|
||||
{/snippet}
|
||||
{typeof label === 'string' ? label : rest.command}
|
||||
</Button>
|
||||
{:else}
|
||||
<Words.CommandButton {...rest} />
|
||||
<Button
|
||||
{...props}
|
||||
{variant}
|
||||
{size}
|
||||
{rounded}
|
||||
iconOnly={false}
|
||||
intent="neutral"
|
||||
color="neutral"
|
||||
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||
>
|
||||
{rest.command}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
|
||||
@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { Search, Replace, ChevronUp, ChevronDown } from '$uix/eidos/components/icon';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Popover } from '$uix/eidos/components/popover';
|
||||
import * as Words from '$soma/components/words';
|
||||
import type { FindReplaceSnippetProps } from '$soma/components/words';
|
||||
import type { WordsFindReplaceProps } from './types';
|
||||
|
||||
let { children: customChildren, toolbarItem = true, ...rest }: WordsFindReplaceProps = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let replacement = $state('');
|
||||
|
||||
type FindReplaceChildProps = FindReplaceSnippetProps & {
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Words.FindReplace {...rest}>
|
||||
{#snippet child(props)}
|
||||
{@const sp = props as FindReplaceChildProps}
|
||||
<Popover bind:open>
|
||||
<Popover.Trigger
|
||||
data-words-find-trigger
|
||||
data-toolbar-button={toolbarItem ? '' : undefined}
|
||||
data-toolbar-group-item={toolbarItem ? '' : undefined}
|
||||
data-has-matches={sp.hasFindMatches ? '' : undefined}
|
||||
aria-label="Find and replace"
|
||||
title="Find and replace"
|
||||
disabled={sp.disabled || undefined}
|
||||
>
|
||||
<Search size="sm" decorative />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-words-floating
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
arrowPadding={8}
|
||||
size="sm"
|
||||
width="min(24rem, calc(100vw - var(--space-4)))"
|
||||
>
|
||||
<Popover.Arrow />
|
||||
<div {...sp.props}>
|
||||
{#if customChildren}
|
||||
{@render customChildren(sp)}
|
||||
{:else}
|
||||
<div data-words-find-row>
|
||||
<span data-words-find-label aria-hidden="true">
|
||||
<Search size="sm" decorative />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
data-words-find-input
|
||||
value={sp.findQuery}
|
||||
aria-label="Find text"
|
||||
placeholder="Find..."
|
||||
disabled={sp.disabled || undefined}
|
||||
oninput={(e) => {
|
||||
sp.setFindQuery(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<span data-words-find-counter aria-live="polite">
|
||||
{#if sp.hasFindMatches}
|
||||
{sp.currentMatchIndex + 1} / {sp.findMatches.length}
|
||||
{:else}
|
||||
0 / 0
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
data-words-find-prev
|
||||
aria-label="Previous match"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
rounded="md"
|
||||
iconOnly
|
||||
intent="neutral"
|
||||
color="neutral"
|
||||
disabled={sp.disabled || !sp.hasFindMatches}
|
||||
onclick={() => sp.findPrev()}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<ChevronUp size="sm" decorative />
|
||||
{/snippet}
|
||||
Previous match
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
data-words-find-next
|
||||
aria-label="Next match"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
rounded="md"
|
||||
iconOnly
|
||||
intent="neutral"
|
||||
color="neutral"
|
||||
disabled={sp.disabled || !sp.hasFindMatches}
|
||||
onclick={() => sp.findNext()}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<ChevronDown size="sm" decorative />
|
||||
{/snippet}
|
||||
Next match
|
||||
</Button>
|
||||
</div>
|
||||
<div data-words-replace-row>
|
||||
<span data-words-replace-label aria-hidden="true">
|
||||
<Replace size="sm" decorative />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
data-words-replace-input
|
||||
value={replacement}
|
||||
aria-label="Replace with"
|
||||
placeholder="Replace..."
|
||||
disabled={sp.disabled || sp.readonly || undefined}
|
||||
oninput={(e) => {
|
||||
replacement = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
data-words-replace-all
|
||||
aria-label="Replace all"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
rounded="md"
|
||||
iconOnly
|
||||
disabled={sp.disabled || sp.readonly || !sp.hasFindMatches}
|
||||
onclick={() => {
|
||||
sp.replaceAllText(replacement);
|
||||
replacement = '';
|
||||
}}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Replace size="sm" decorative />
|
||||
{/snippet}
|
||||
Replace all
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
{/snippet}
|
||||
</Words.FindReplace>
|
||||
@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { X } from '$uix/eidos/components/icon';
|
||||
import { Popover } from '$uix/eidos/components/popover';
|
||||
import type { WordsToolPopoverProps } from './types';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
tool,
|
||||
triggerLabel,
|
||||
triggerTitle = triggerLabel,
|
||||
closeLabel = 'Close',
|
||||
side = 'bottom',
|
||||
sideOffset = 6,
|
||||
align = 'start',
|
||||
alignOffset,
|
||||
width = 'min(92vw, 32em)',
|
||||
minWidth = 'min(92vw, 24em)',
|
||||
maxWidth = 'min(92vw, 32em)',
|
||||
collisionPadding,
|
||||
avoidCollisions,
|
||||
trigger,
|
||||
children,
|
||||
onTriggerPointerDown,
|
||||
onBeforeDismiss,
|
||||
onCancel
|
||||
}: WordsToolPopoverProps = $props();
|
||||
|
||||
let closeBlocked = false;
|
||||
|
||||
function canDismiss(): boolean {
|
||||
const valid = onBeforeDismiss?.() ?? true;
|
||||
closeBlocked = !valid;
|
||||
return valid;
|
||||
}
|
||||
|
||||
function guardDismiss(event: Event) {
|
||||
if (canDismiss()) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function preserveEditorSelection(event: PointerEvent) {
|
||||
onTriggerPointerDown?.(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && !canDismiss()) {
|
||||
open = true;
|
||||
return;
|
||||
}
|
||||
closeBlocked = false;
|
||||
open = nextOpen;
|
||||
}
|
||||
|
||||
function handleCloseAutoFocus(event: Event) {
|
||||
if (!closeBlocked) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function close() {
|
||||
closeBlocked = false;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
onCancel?.();
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover bind:open onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger
|
||||
data-words-tool-popover-trigger
|
||||
data-words-link-trigger={tool === 'link' || undefined}
|
||||
data-tool={tool}
|
||||
aria-label={triggerLabel}
|
||||
title={triggerTitle}
|
||||
onpointerdown={preserveEditorSelection}
|
||||
>
|
||||
{@render trigger?.()}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-words-tool-popover
|
||||
data-words-link-popover={tool === 'link' || undefined}
|
||||
data-tool={tool}
|
||||
size="sm"
|
||||
{side}
|
||||
{align}
|
||||
{sideOffset}
|
||||
{alignOffset}
|
||||
{width}
|
||||
{minWidth}
|
||||
{maxWidth}
|
||||
{collisionPadding}
|
||||
{avoidCollisions}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onInteractOutside={guardDismiss}
|
||||
onEscapeKeydown={guardDismiss}
|
||||
>
|
||||
<Popover.Arrow />
|
||||
<div data-words-tool-popover-row>
|
||||
<div data-words-tool-popover-body>
|
||||
{@render children?.({ open, close })}
|
||||
</div>
|
||||
<button
|
||||
data-words-tool-popover-close
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onclick={cancel}
|
||||
>
|
||||
<X size="sm" decorative />
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
@ -1,10 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { mergeProps } from '$uix/soma/props';
|
||||
import { Toolbar } from '$uix/eidos/components/toolbar';
|
||||
import * as Words from '$soma/components/words';
|
||||
import type { WordsToolbarProps } from './types';
|
||||
|
||||
let { children, ...rest }: WordsToolbarProps = $props();
|
||||
let {
|
||||
orientation = 'horizontal',
|
||||
'aria-label': ariaLabel = 'Formatting toolbar',
|
||||
children,
|
||||
child: outerChild,
|
||||
...rest
|
||||
}: WordsToolbarProps = $props();
|
||||
|
||||
const toolbarAriaLabel = $derived(ariaLabel ?? undefined);
|
||||
</script>
|
||||
|
||||
<Words.Toolbar {...rest}>
|
||||
<Words.Toolbar {...rest} {orientation} aria-label={toolbarAriaLabel}>
|
||||
{#snippet child({ props: wordsProps })}
|
||||
<Toolbar {orientation} aria-label={toolbarAriaLabel} size="sm" variant="ghost">
|
||||
{#snippet child({ props: toolbarProps })}
|
||||
{@const props = mergeProps(toolbarProps, wordsProps)}
|
||||
{#if outerChild}
|
||||
{@render outerChild({ props })}
|
||||
{:else}
|
||||
<div {...props}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
{/snippet}
|
||||
</Words.Toolbar>
|
||||
|
||||
@ -0,0 +1,483 @@
|
||||
# Eidos Motion
|
||||
|
||||
Documento de arquitectura propuesto para dotar a la capa visual de UIX de un
|
||||
sistema transversal de animaciones.
|
||||
|
||||
## Tesis
|
||||
|
||||
UIX tiene dos familias de animacion:
|
||||
|
||||
1. **Motion de senal**: Sema escribe una ocurrencia transitoria en el DOM
|
||||
mediante `data-event-*`. Eidos gestiona el canal visual de Sema: lee esa
|
||||
ocurrencia y la convierte en animacion, color, intensidad y politica de
|
||||
reduced motion.
|
||||
2. **Motion visual**: Eidos anima estado, presencia, interaccion y layout a
|
||||
partir de atributos como `data-state`, `data-starting-style`,
|
||||
`data-ending-style`, `data-archetype` y referencias visuales propias.
|
||||
|
||||
Sema no decide keyframes, easing ni duraciones CSS. Soma no decide visualidad.
|
||||
Eidos es el runtime visual que resuelve ambos mundos.
|
||||
|
||||
```text
|
||||
Sema visual channel
|
||||
-> data-event, data-event-family, data-event-intent, data-event-phase
|
||||
-> ventana semantica / hold de la senal
|
||||
|
||||
Eidos signal motion
|
||||
-> lee data-event-*
|
||||
-> resuelve motion registrada por event/family/intent
|
||||
-> aplica CSS, WAAPI o driver visual
|
||||
|
||||
Eidos visual motion
|
||||
-> lee data-state, data-starting-style, data-ending-style, data-archetype...
|
||||
-> aplica presets de presencia, interaccion y layout
|
||||
```
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Registrar animaciones reutilizables en Eidos.
|
||||
- Permitir que los componentes Eidos referencien una motion por nombre.
|
||||
- Mantener las primitivas existentes de motion como fuente atomica:
|
||||
duracion, easing, distancia, escala y stagger.
|
||||
- Centralizar reduced motion en Eidos usando la preferencia global proyectada
|
||||
en DOM.
|
||||
- Mantener el canal visual de Sema como entrada de senales, no como motor CSS.
|
||||
- Soportar animaciones avanzadas como `genie`, `shared-element` o
|
||||
`fly-to-target` mediante drivers visuales de Eidos.
|
||||
|
||||
## No objetivos
|
||||
|
||||
- No introducir una dependencia obligatoria de Framer Motion, Motion One ni
|
||||
ningun motor JS externo en el core.
|
||||
- No mover presencia visual a Soma.
|
||||
- No hacer que Sema conozca nombres visuales como `genie`, `scale-fade` o
|
||||
`drawer`.
|
||||
- No reutilizar `data-motion` para presets visuales. Ese atributo ya representa
|
||||
la preferencia global `allow` / `reduce`.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Capa | Responsabilidad |
|
||||
| --- | --- |
|
||||
| Morfo | Declara atributos estructurales y estados compartidos por capas. |
|
||||
| Soma | Sincroniza estado, ARIA, presence lifecycle y attrs como `data-state`, `data-starting-style`, `data-ending-style`. |
|
||||
| Sema | Emite senales perceptivas y escribe `data-event-*` durante el hold del canal visual. |
|
||||
| Eidos | Gestiona el canal visual de Sema, registra motions, resuelve tokens, aplica CSS/WAAPI/drivers y reduced motion. |
|
||||
| ActivePrefs DOM projection | Proyecta preferencia global: `data-motion="allow"` o `data-motion="reduce"`. |
|
||||
|
||||
## Registro de motion
|
||||
|
||||
Eidos deberia tener un registro tipado de animaciones. Las primitivas actuales
|
||||
siguen siendo atomicas; el registro compone esas primitivas en recetas
|
||||
semanticas.
|
||||
|
||||
```ts
|
||||
type EidosMotionKind = 'presence' | 'event' | 'interaction' | 'layout';
|
||||
|
||||
type EidosMotionDriver = 'css' | 'eidos-rect' | 'waapi';
|
||||
|
||||
type EidosReducedMotionPolicy = 'instant' | 'opacity-only' | 'none';
|
||||
|
||||
interface EidosMotionDefinition {
|
||||
kind: EidosMotionKind;
|
||||
driver?: EidosMotionDriver;
|
||||
duration?: DurationKey;
|
||||
ease?: EaseKey;
|
||||
distance?: MotionDistanceKey;
|
||||
scale?: MotionScaleKey;
|
||||
stagger?: boolean;
|
||||
reduce?: EidosReducedMotionPolicy;
|
||||
requires?: Array<'sourceRect' | 'targetRect' | 'placement' | 'direction'>;
|
||||
}
|
||||
```
|
||||
|
||||
La configuracion podria quedar asi:
|
||||
|
||||
```ts
|
||||
interface EidosConfig {
|
||||
motion?: {
|
||||
registry?: Record<string, EidosMotionDefinition>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Las primitivas actuales no deberian moverse dentro de `registry`. El registro
|
||||
usa claves de primitivas ya definidas por Eidos.
|
||||
|
||||
## Nombres de referencia
|
||||
|
||||
Usar nombres con prefijo semantico:
|
||||
|
||||
```ts
|
||||
const motionRegistry = {
|
||||
'event.announce': {
|
||||
kind: 'event',
|
||||
duration: 'normal',
|
||||
ease: 'out',
|
||||
reduce: 'opacity-only'
|
||||
},
|
||||
'event.dismiss': {
|
||||
kind: 'event',
|
||||
duration: 'fast',
|
||||
ease: 'in',
|
||||
reduce: 'instant'
|
||||
},
|
||||
'presence.fade': {
|
||||
kind: 'presence',
|
||||
duration: 'normal',
|
||||
ease: 'out',
|
||||
reduce: 'opacity-only'
|
||||
},
|
||||
'presence.scale-fade': {
|
||||
kind: 'presence',
|
||||
duration: 'moderate',
|
||||
ease: 'spring',
|
||||
scale: 'enter',
|
||||
reduce: 'opacity-only'
|
||||
},
|
||||
'presence.genie': {
|
||||
kind: 'presence',
|
||||
driver: 'eidos-rect',
|
||||
duration: 'slow',
|
||||
ease: 'spring',
|
||||
reduce: 'opacity-only',
|
||||
requires: ['sourceRect', 'targetRect']
|
||||
},
|
||||
'interaction.press': {
|
||||
kind: 'interaction',
|
||||
duration: 'fast',
|
||||
ease: 'out',
|
||||
scale: 'press',
|
||||
reduce: 'instant'
|
||||
},
|
||||
'layout.indicator': {
|
||||
kind: 'layout',
|
||||
duration: 'fast',
|
||||
ease: 'default',
|
||||
reduce: 'instant'
|
||||
}
|
||||
} satisfies Record<string, EidosMotionDefinition>;
|
||||
```
|
||||
|
||||
## API publica de componentes
|
||||
|
||||
Los wrappers Eidos pueden exponer una prop visual `motion`.
|
||||
|
||||
```svelte
|
||||
<Dialog motion="presence.scale-fade" />
|
||||
<Popover motion="presence.scale-fade" />
|
||||
<Drawer motion="presence.drawer-end" />
|
||||
<Tabs motion="presence.slide-inline" />
|
||||
<Button motion="interaction.press" />
|
||||
```
|
||||
|
||||
Para componentes multiparte, aceptar un mapa por parte:
|
||||
|
||||
```svelte
|
||||
<Dialog
|
||||
motion={{
|
||||
overlay: 'presence.fade',
|
||||
content: 'presence.scale-fade'
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
La API publica usa `motion`; el DOM no debe usar `data-motion` para esa
|
||||
referencia. En DOM se proyecta como referencia visual de Eidos:
|
||||
|
||||
```html
|
||||
<div data-dialog-content data-motion-ref="presence.scale-fade">
|
||||
```
|
||||
|
||||
Motivo: `data-motion` ya pertenece a la preferencia global de motion.
|
||||
|
||||
## Contrato DOM
|
||||
|
||||
Atributos propuestos:
|
||||
|
||||
| Atributo | Owner | Uso |
|
||||
| --- | --- | --- |
|
||||
| `data-motion="allow\|reduce"` | ActivePrefs DOM projection | Preferencia global transversal. |
|
||||
| `data-motion-ref="presence.scale-fade"` | Eidos | Referencia a una motion registrada. |
|
||||
| `data-motion-origin="top\|right\|bottom\|left\|center"` | Eidos | Origen visual para transforms. |
|
||||
| `data-motion-direction="from-start\|from-end\|to-start\|to-end"` | Soma/Morfo cuando sea estado real | Direccion de transicion cuando afecta a comportamiento o layout. |
|
||||
| `data-motion-target` | Eidos | Marcador opcional para drivers con rect target. |
|
||||
| `data-starting-style` | Soma Presence | Hook de entrada. |
|
||||
| `data-ending-style` | Soma Presence | Hook de salida. |
|
||||
| `data-event-*` | Sema VisualChannel | Senal perceptiva transitoria. |
|
||||
|
||||
Regla: un atributo visual puro puede vivir solo en Eidos. Si el atributo afecta
|
||||
a semantica, accesibilidad, lifecycle o comportamiento, debe declararse en
|
||||
Morfo/Soma.
|
||||
|
||||
## CSS transversal
|
||||
|
||||
Crear un modulo `motion.css` importado por `index.css` cerca de
|
||||
`archetypes.css` y `events.css`.
|
||||
|
||||
```css
|
||||
[data-motion-ref='presence.fade'] {
|
||||
transition:
|
||||
opacity var(--motion-presence-fade-duration)
|
||||
var(--motion-presence-fade-ease);
|
||||
}
|
||||
|
||||
[data-motion-ref='presence.fade'][data-starting-style],
|
||||
[data-motion-ref='presence.fade'][data-ending-style] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-motion-ref='presence.scale-fade'] {
|
||||
transition:
|
||||
opacity var(--motion-presence-scale-fade-duration)
|
||||
var(--motion-presence-scale-fade-ease),
|
||||
transform var(--motion-presence-scale-fade-duration)
|
||||
var(--motion-presence-scale-fade-ease);
|
||||
transform-origin: var(--motion-origin, center);
|
||||
}
|
||||
|
||||
[data-motion-ref='presence.scale-fade'][data-starting-style],
|
||||
[data-motion-ref='presence.scale-fade'][data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(var(--motion-scale-enter));
|
||||
}
|
||||
```
|
||||
|
||||
`events.css` puede migrar gradualmente para consumir el mismo registro:
|
||||
|
||||
```css
|
||||
[data-event-family='commit'][data-event-phase='active'] {
|
||||
animation: var(--motion-event-commit-animation);
|
||||
}
|
||||
```
|
||||
|
||||
## Reduced motion
|
||||
|
||||
La preferencia global ya se proyecta como `data-motion`. Eidos debe convertirla
|
||||
en politica visual.
|
||||
|
||||
```css
|
||||
[data-motion='reduce'] [data-motion-ref] {
|
||||
animation-duration: 1ms;
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
[data-motion='reduce'] [data-motion-ref][data-motion-reduce='opacity-only'] {
|
||||
transform: none;
|
||||
}
|
||||
```
|
||||
|
||||
Tambien conviene mantener fallback por media query:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-motion-ref] {
|
||||
animation-duration: 1ms;
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
La politica por motion debe venir del registro:
|
||||
|
||||
- `instant`: reduce a cambio inmediato.
|
||||
- `opacity-only`: conserva fades breves y elimina transformaciones.
|
||||
- `none`: mantiene la animacion porque representa feedback critico y ya es
|
||||
segura.
|
||||
|
||||
## Driver `css`
|
||||
|
||||
El driver por defecto. No requiere medicion ni JavaScript. Sirve para:
|
||||
|
||||
- fade
|
||||
- scale-fade
|
||||
- slide-inline
|
||||
- slide-block
|
||||
- collapse-block con CSS vars conocidas
|
||||
- press
|
||||
- indicator transitions
|
||||
- signal reactions basicas
|
||||
|
||||
Debe cubrir la mayoria de componentes.
|
||||
|
||||
## Driver `eidos-rect`
|
||||
|
||||
Driver visual para animaciones que necesitan geometria. Eidos mide rects y
|
||||
escribe variables CSS o invoca WAAPI.
|
||||
|
||||
Casos:
|
||||
|
||||
- `presence.genie`
|
||||
- `layout.shared-element`
|
||||
- `layout.fly-to-target`
|
||||
- `presence.expand-from-trigger`
|
||||
|
||||
Entrada esperada:
|
||||
|
||||
```svelte
|
||||
<Dialog motion="presence.genie" motionTarget={dockButton} />
|
||||
<Toast motion="presence.genie" motionTarget="#notification-center" />
|
||||
```
|
||||
|
||||
Variables que puede escribir Eidos:
|
||||
|
||||
```html
|
||||
<div
|
||||
data-motion-ref="presence.genie"
|
||||
style="
|
||||
--motion-source-x: 120px;
|
||||
--motion-source-y: 80px;
|
||||
--motion-source-w: 480px;
|
||||
--motion-source-h: 320px;
|
||||
--motion-target-x: 32px;
|
||||
--motion-target-y: 720px;
|
||||
--motion-target-w: 48px;
|
||||
--motion-target-h: 48px;
|
||||
"
|
||||
>
|
||||
```
|
||||
|
||||
Para `genie`, el efecto puede empezar como CSS aproximado y evolucionar a
|
||||
WAAPI si el calculo de keyframes necesita interpolacion mas precisa.
|
||||
|
||||
## Driver `waapi`
|
||||
|
||||
Driver reservado para animaciones que no se expresan bien con CSS estatico.
|
||||
Debe seguir siendo opt-in por motion registrada, no dependencia general de los
|
||||
componentes.
|
||||
|
||||
Responsabilidades:
|
||||
|
||||
- Construir keyframes desde rects y tokens.
|
||||
- Respetar reduced motion.
|
||||
- Cancelar animaciones obsoletas cuando el nodo se desmonta o cambia de estado.
|
||||
- No bloquear a Soma salvo por el lifecycle que Soma ya obtiene con
|
||||
`getAnimations()`.
|
||||
|
||||
## Gestion del canal visual de Sema
|
||||
|
||||
Sema escribe la senal:
|
||||
|
||||
```html
|
||||
data-event="dismiss"
|
||||
data-event-family="commit"
|
||||
data-event-intent="fulfill"
|
||||
data-event-phase="active"
|
||||
```
|
||||
|
||||
Eidos resuelve esa entrada contra el registro. La resolucion puede seguir este
|
||||
orden:
|
||||
|
||||
1. `event.{data-event}`
|
||||
2. `event.{data-event-family}.{data-event-intent}`
|
||||
3. `event.{data-event-family}`
|
||||
4. fallback global de `event.default`
|
||||
|
||||
Ejemplo:
|
||||
|
||||
```ts
|
||||
resolveSignalMotion({
|
||||
event: 'announce',
|
||||
family: 'commit',
|
||||
intent: 'fulfill'
|
||||
});
|
||||
```
|
||||
|
||||
Resultado posible:
|
||||
|
||||
```ts
|
||||
'event.commit.fulfill'
|
||||
```
|
||||
|
||||
El `hold` de Sema no debe usarse como duracion CSS. El hold define cuanto vive
|
||||
la senal en DOM; la duracion visual pertenece a Eidos.
|
||||
|
||||
## Defaults por componente
|
||||
|
||||
Cada componente puede tener default visual sin obligar a la persona usuaria a
|
||||
pasar `motion`.
|
||||
|
||||
Ejemplos iniciales:
|
||||
|
||||
| Componente | Parte | Motion default |
|
||||
| --- | --- | --- |
|
||||
| Dialog | overlay | `presence.fade` |
|
||||
| Dialog | content | `presence.scale-fade` |
|
||||
| Popover | content | `presence.scale-fade` |
|
||||
| Drawer | content | `presence.drawer` |
|
||||
| Accordion | content | `presence.collapse-block` |
|
||||
| Tabs | content | `presence.fade` o `presence.slide-inline` |
|
||||
| NavigationMenu | content | `presence.slide-inline` con direccion |
|
||||
| Button | root | `interaction.press` |
|
||||
| Switch/Slider | thumb | `layout.indicator` o `interaction.press` |
|
||||
|
||||
La prop `motion` debe permitir anular el default:
|
||||
|
||||
```svelte
|
||||
<Dialog motion="none" />
|
||||
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
|
||||
```
|
||||
|
||||
## Migracion
|
||||
|
||||
### Fase 0: documentacion
|
||||
|
||||
- Corregir la deriva documental donde Sema parezca propietario de motion
|
||||
visual.
|
||||
- Dejar claro que Eidos gestiona el canal visual de Sema.
|
||||
|
||||
### Fase 1: contrato base
|
||||
|
||||
- Agregar tipos del registro.
|
||||
- Agregar validacion de nombres y definiciones.
|
||||
- Generar CSS vars por motion registrada.
|
||||
- Crear `motion.css`.
|
||||
- Mantener `events.css` y `archetypes.css` funcionando como hoy.
|
||||
|
||||
### Fase 2: canarios
|
||||
|
||||
Migrar pocos componentes para probar todos los casos:
|
||||
|
||||
- Dialog o Popover: overlay + content + presence lifecycle.
|
||||
- Drawer: origen/direccion.
|
||||
- Accordion: collapse.
|
||||
- Tabs: reemplazar `motionPreset` por `motion`.
|
||||
- NavigationMenu: aislar o renombrar el uso actual de `data-motion` como
|
||||
direccion.
|
||||
|
||||
### Fase 3: canal visual de Sema
|
||||
|
||||
- Migrar `events.css` para resolver motions de tipo `event`.
|
||||
- Mantener compatibilidad con selectores actuales `data-event-*`.
|
||||
- Probar intents: `neutral`, `affirm`, `fulfill`, `risk`, `threat`.
|
||||
|
||||
### Fase 4: drivers avanzados
|
||||
|
||||
- Introducir `eidos-rect` para `presence.genie`.
|
||||
- Agregar pruebas con target rect y reduced motion.
|
||||
- Evaluar si WAAPI aporta valor para shared element y fly-to-target.
|
||||
|
||||
## Pruebas recomendadas
|
||||
|
||||
- Test de contrato: las claves del registro producen CSS vars estables.
|
||||
- Test de validacion: `motion="unknown"` falla temprano o queda en fallback
|
||||
controlado.
|
||||
- Test de DOM attrs: permitir `data-motion-ref`, `data-motion-origin` y
|
||||
`data-motion-direction` donde aplique.
|
||||
- Test anti-colision: no introducir nuevos usos de `data-motion` para presets.
|
||||
- Test reduced motion: `data-motion="reduce"` reduce transforms y duraciones.
|
||||
- Test presence: Soma espera las animaciones resultantes antes de desmontar.
|
||||
|
||||
## Decision propuesta
|
||||
|
||||
Adoptar `motion` como prop publica de Eidos y `data-motion-ref` como contrato
|
||||
DOM interno.
|
||||
|
||||
```svelte
|
||||
<Dialog motion="presence.scale-fade" />
|
||||
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
|
||||
```
|
||||
|
||||
Eidos registra y resuelve motions. Sema emite senales. Soma conserva lifecycle.
|
||||
Morfo declara los atributos que cruzan capas. La animacion queda centralizada
|
||||
en el runtime visual sin romper la separacion actual de UIX.
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { WordsFindReplaceProvider } from '../words-provider.svelte';
|
||||
import type { WordsFindReplaceProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'words-find-replace'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: WordsFindReplaceProps = $props();
|
||||
|
||||
const state = WordsFindReplaceProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v as HTMLDivElement | null)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const divProps = $derived.by(() => {
|
||||
const { ...props } = mergedProps;
|
||||
return props;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...divProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,91 @@
|
||||
import { createText, type WordsDocument, type WordsText } from './document';
|
||||
import { getTextEntries } from './selection';
|
||||
import { updateNodeAtPath, type WordsPath } from './path';
|
||||
|
||||
export interface WordsTextMatch {
|
||||
readonly path: WordsPath;
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface FindOptions {
|
||||
readonly caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
export function findTextInDocument(
|
||||
document: WordsDocument,
|
||||
query: string,
|
||||
options: FindOptions = {}
|
||||
): readonly WordsTextMatch[] {
|
||||
if (!query) return [];
|
||||
const matches: WordsTextMatch[] = [];
|
||||
const entries = getTextEntries(document);
|
||||
const flags = options.caseSensitive ? 'g' : 'gi';
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(escaped, flags);
|
||||
|
||||
for (const entry of entries) {
|
||||
const text = entry.text.text;
|
||||
const entryMatches = [...text.matchAll(regex)];
|
||||
for (const match of entryMatches) {
|
||||
const start = match.index!;
|
||||
const end = start + match[0].length;
|
||||
matches.push({
|
||||
path: entry.path,
|
||||
start,
|
||||
end,
|
||||
text: match[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function replaceTextAtMatch(
|
||||
document: WordsDocument,
|
||||
match: WordsTextMatch,
|
||||
replacement: string
|
||||
): WordsDocument {
|
||||
return updateNodeAtPath(document, match.path, (node) => {
|
||||
if (!isWordsText(node)) return node;
|
||||
const before = node.text.slice(0, match.start);
|
||||
const after = node.text.slice(match.end);
|
||||
return createText(before + replacement + after, node.marks);
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceAllTextInDocument(
|
||||
document: WordsDocument,
|
||||
query: string,
|
||||
replacement: string,
|
||||
options: FindOptions = {}
|
||||
): WordsDocument {
|
||||
const matches = findTextInDocument(document, query, options);
|
||||
if (!matches.length) return document;
|
||||
|
||||
// Group matches by their text node path so we can process each node back-to-front
|
||||
const byPath = new Map<string, WordsTextMatch[]>();
|
||||
for (const match of matches) {
|
||||
const key = match.path.join(',');
|
||||
const list = byPath.get(key);
|
||||
if (list) list.push(match);
|
||||
else byPath.set(key, [match]);
|
||||
}
|
||||
|
||||
let nextDocument = document;
|
||||
for (const pathMatches of byPath.values()) {
|
||||
// Sort descending by start so offsets remain valid as we mutate
|
||||
pathMatches.sort((a, b) => b.start - a.start);
|
||||
for (const match of pathMatches) {
|
||||
nextDocument = replaceTextAtMatch(nextDocument, match, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
return nextDocument;
|
||||
}
|
||||
|
||||
function isWordsText(node: unknown): node is WordsText {
|
||||
return typeof node === 'object' && node !== null && 'type' in node && (node as { type: string }).type === 'text';
|
||||
}
|
||||
Loading…
Reference in new issue