palabras(desnudo): capa VISUAL eliminada entera - 14 ficheros .svelte fuera

Orden literal del usuario: fuera todos los ficheros que muestran la parte visual (panel, breadcrumb, handle, activate, drag, inserter, nav, segmented, color-row, spacing, typography, appearance, nav y el runtime de drag). Se CONSERVAN: el motor soma/words, types.ts, palabras-scheme(+parse+test), panel-schema, block-types/actions/anchor/image-file y los .md. index.ts reducido a contrato; la demo queda en stub honesto hasta la fase 1 de la reconstruccion ratificada. Check limpio en el track.
menubar-v4-safe
dev 3 months ago
parent d50a138d52
commit 66b63570e1

@ -1,5 +1,8 @@
export { default } from './palabras.svelte';
export { default as Palabras } from './palabras.svelte';
/**
* palabras — CONTRACT-ONLY entry while the visual layer is rebuilt from
* scratch (user order, 2026-07-15: every presentation .svelte deleted; the
* engine, the scheme contract and the panel registry live on).
*/
export { PALABRAS_PANEL_REGISTRY, getPanelSchema } from './panel-schema';
export {
PALABRAS_DEFAULT_SCHEME,

@ -1,228 +0,0 @@
<script lang="ts">
/**
* Click-to-activate bridge.
*
* A render-less helper (same shape as the gutter): given the editor
* content root, it listens for `pointerdown` and sets the provider's
* ACTIVE BLOCK to the nearest `[data-words-id]` ancestor of the click
* target. This is the single, explicit gesture that drives the
* inspector — per the architecture, the active block changes ONLY on a
* click on a block (or insert / breadcrumb), never on caret / focus
* churn.
*
* `pointerdown` (not `click`) so it lands BEFORE the browser moves
* focus / collapses the selection — the active block is set first and
* is immune to any subsequent selectionchange. Clicks that don't land
* on a block (whitespace inside the editor frame) leave the current
* active block untouched; clicks outside `content` (toolbar, inspector,
* gutter) never reach this listener since it's bound to `content`.
*
* Every block carries `data-words-id` (the engine stamps it from the
* block's stable `id`); inline text spans carry only `data-words-path`,
* so `closest('[data-words-id]')` climbs from a text click up to its
* owning block — exactly the granularity the inspector wants.
*/
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
let {
api,
content,
dom,
enabled = true,
onActivate,
panelOpen = false,
onPanelClose
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
/** When false (preview / read-only) block activation is off: no
* click-to-select and the active-block outline is cleared. */
enabled?: boolean;
/** Fired after a CLICK sets the active block (not on caret nav). Receives
* the block's id-path + its engine type (`data-words-block`). Lets a host
* (palabras) react — e.g. open the inspector when an atomic image is clicked. */
onActivate?: (idPath: string, blockType: string | null) => void;
/** Whether the host's properties panel is open — Esc precedence
* (INTERACCION §3): an open panel wins over the ladder. */
panelOpen?: boolean;
/** Close the host's panel (first Esc while it's open). */
onPanelClose?: () => void;
} = $props();
// Build the MATERIALIZED-PATH id of the clicked block: walk the DOM up
// from the target collecting every ancestor's `data-words-id` until the
// content root, then join root → block. This IS the id the provider
// tracks — its segments encode the full ancestry, so the resolver
// descends it directly and the breadcrumb falls out of its prefixes.
function idPathOf(target: HTMLElement): string {
const nearest: HTMLElement | null = target.closest('[data-words-id]');
// A click whose nearest block element is a structural LAYOUT slot (a column
// or table cell) landed on the slot's OWN padding / empty area, not on a
// child block — those slots are not selectable blocks. Leave the active
// block unchanged (an empty column is seeded via its "+" affordance, not by
// selecting it; the columns container is selected from the grip menu).
const slot = nearest?.getAttribute('data-words-node');
if (slot === 'column' || slot === 'table-cell') return '';
const ids: string[] = [];
let el: HTMLElement | null = nearest;
while (el && content.contains(el)) {
const id = el.getAttribute('data-words-id');
// A LIST ITEM is not a selectable block on its own — the LIST is the unit.
// Skip it so the active block (and its full-width dashed marker) is the
// whole list, not an indented, narrower `<li>`.
if (id && el.getAttribute('data-words-node') !== 'list-item') {
ids.unshift(id); // prepend → root ends up first
}
el = el.parentElement?.closest('[data-words-id]') ?? null;
}
return ids.join('/');
}
// Epoch — timestamp of the last CLICK that activated an ATOMIC block. The
// caret-follower ignores selectionchanges in the short window right after, which
// is immune to reactive-snapshot timing (`api.activeBlockId` can lag a tick, so
// a marker/id read in the handler may still see the previous block).
let lastAtomicAt = 0;
// Whether the CURRENT active block is ATOMIC — read from the COMMITTED DOM
// marker (`[data-words-active]`), NOT the possibly-stale api snapshot. An atomic
// block (image / divider, contenteditable=false) hosts no caret, so the
// caret-follower must never demote it (a stray selectionchange always lands its
// anchor in a NEIGHBOURING text block).
function isActiveBlockAtomic(): boolean {
if (!content) return false;
const el = content.querySelector('[data-words-active]');
return el instanceof HTMLElement && el.getAttribute('contenteditable') === 'false';
}
$effect(() => {
if (!content || !enabled) return;
const onPointerDown = (e: Event) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const idPath = idPathOf(target);
if (!idPath) return;
api.setActiveBlock(idPath);
// Notify the host (palabras opens the inspector for atomic image clicks).
const blockEl = target.closest('[data-words-block]');
// Stamp the atomic-activation epoch SYNCHRONOUSLY so the trailing
// selectionchange (which lands in a neighbour) can't demote this block.
if (blockEl?.getAttribute('contenteditable') === 'false') lastAtomicAt = Date.now();
onActivate?.(idPath, blockEl?.getAttribute('data-words-block') ?? null);
};
return dom.listen(content, 'pointerdown', onPointerDown, { capture: true });
});
// The Esc LADDER (INTERACCION §3): Esc climbs the ancestry one level per
// press — block → parent (cell / row / column / callout) → … → document
// (no active block). This is how cell / row / table are reached from a
// block INSIDE them without the breadcrumb. The caret is untouched (the
// selection stays where it was — "el caret nunca se pierde"); Esc fires no
// selectionchange, so the caret-follower below never demotes the climb.
// Precedence (§3, pila única): an OPEN PANEL wins — the first Esc closes
// it, the next one climbs.
$effect(() => {
if (!content || !enabled) return;
const onKeydown = (e: Event) => {
const ev = e as KeyboardEvent;
if (ev.key !== 'Escape') return;
if (panelOpen) {
onPanelClose?.();
ev.preventDefault();
ev.stopPropagation();
return;
}
const id = api.activeBlockId;
if (!id) return;
const parent = String(id).split('/').filter(Boolean).slice(0, -1).join('/');
api.setActiveBlock(parent || null);
ev.preventDefault();
};
return dom.listen(content, 'keydown', onKeydown);
});
// Caret-follows-active — SUBORDINATE to intentional activation. When the caret
// moves into a DIFFERENT EDITABLE block (Arrow Up/Down, or crossing a block
// boundary) it re-points the active block so grip / breadcrumb / outline follow.
// It is gated three ways: (1) intra-block moves resolve to the same id → no-op;
// (2) clicks already set the same block via pointerdown (fires first) → the
// trailing selectionchange is a no-op; (3) — THE KEY RULE — it NEVER demotes an
// ATOMIC active block (image/divider, contenteditable=false), because such a
// block holds no caret and the selectionchange anchor always lands in a
// NEIGHBOURING text block. Without (3), clicking an image flipped the active
// block back to the previous heading ("the panel reverts" bug). The doctrine:
// clicks / inserts / grip / breadcrumb SET the active block; the follower only
// REFINES it among editable blocks.
$effect(() => {
if (!content || !enabled) return;
const doc = dom.getDocument(content);
const onSelectionChange = () => {
if (Date.now() - lastAtomicAt < 800) return; // just clicked an atomic block
if (isActiveBlockAtomic()) return; // current active is atomic — never demote
const sel = doc.getSelection?.();
if (!sel || sel.rangeCount === 0) return;
const node = sel.anchorNode;
if (!node || !content.contains(node)) return;
const host = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement;
const blockEl = host?.closest('[data-words-id]') as HTMLElement | null;
if (!blockEl) return;
const idPath = idPathOf(blockEl);
if (idPath && idPath !== api.activeBlockId) api.setActiveBlock(idPath);
};
return dom.listen(doc, 'selectionchange', onSelectionChange);
});
// Active-block visual marker. Stamps `data-words-active` + a depth index
// on the active block's DOM element so eidos CSS can paint a dotted
// outline (colour varies by nesting depth). Done IMPERATIVELY — not via
// the document model / `html` derived — so it never re-renders the
// contenteditable (which would fight the caret). It runs as an $effect
// that depends on BOTH `api.activeBlockId` (re-mark on activation
// change) AND `api.html` (re-apply after every render, since `{@html}`
// replaces the subtree and wipes imperative attributes).
//
// The active id is a materialized path (`colsId/colId/childId`); the
// element carries only its LOCAL id in `data-words-id`, and local ids
// are unique, so we match the LAST segment. The segment count is the
// nesting depth → `data-words-depth`.
$effect(() => {
const on = enabled; // re-run (and clear) when activation is turned off
const idPath = api.activeBlockId;
// Touch `html` so this effect re-runs after each re-render and
// re-stamps the (freshly recreated) element.
void api.html;
if (!content) return;
// Clear any stale marker (activation can change WITHOUT a re-render
// — clicking a sibling block — so the previous element keeps its
// attribute until we remove it here).
for (const prev of content.querySelectorAll('[data-words-active]')) {
prev.removeAttribute('data-words-active');
prev.removeAttribute('data-words-depth');
}
// Preview / read-only: stamps cleared above, never re-applied.
if (!on || !idPath) return;
const segments = idPath.split('/').filter(Boolean);
const localId = segments[segments.length - 1];
if (!localId) return;
const el = content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`);
if (el instanceof HTMLElement) {
el.setAttribute('data-words-active', '');
el.setAttribute('data-words-depth', String(segments.length));
}
});
// There is ALWAYS an active block: on first load (no click yet) select the
// first top-level block so the inspector + the gutter rail have a target
// from the start instead of "nothing selected".
let seeded = false;
$effect(() => {
if (seeded || !enabled) return;
const first = api.document.children[0];
if (!first?.id) return;
if (!api.activeBlockId) api.setActiveBlock(first.id);
seeded = true;
});
</script>

@ -1,245 +0,0 @@
<script lang="ts">
/**
* Appearance popover — opened by the panel's "Editar" button. Edits THIS
* block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`.
* It's the framework `<FloatPanel>` anchored to the active block on its LEFT
* (opposite the main panel); a draggable free window (closes on click-outside /
* Esc). Its CONTENT is organised in `<Tabs>` by style group — Tipografía (only
* for text blocks), Colores, Disposición — Elementor-style. Colours use our
* `<ColorPicker>`, numbers our `<NumberField>`, alignment our `<ToggleGroup>`.
*/
import type { ProviderSnippetProps } from '$soma/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { NumberField } from '$uix/eidos/components/number-field';
import { Tabs } from '$uix/eidos/components/tabs';
import PalabrasColorRow from './palabras-color-row.svelte';
import { FloatPanel } from '$uix/eidos/components/float-panel';
const PANEL_W = 284;
const PANEL_H = 300;
// Style-group tabs. Tipografía only shows for text-bearing blocks ("la
// tipografía solo aparece en las partes que son tipográficas").
const TEXT_TYPES = new Set(['heading', 'paragraph']);
// `tab` = the user's explicit pick (null → use the default). See `activeTab`.
let tab = $state<string | null>(null);
let {
api,
content,
open,
onClose
}: {
api: ProviderSnippetProps;
content: HTMLElement | null;
open: boolean;
onClose: () => void;
} = $props();
const loc = $derived(api.activeBlockLocation);
const isText = $derived(TEXT_TYPES.has(loc?.node?.type ?? ''));
// Active tab: the user's pick if still valid ('type'/Tipografía only on text
// blocks), else the default — Tipografía for text, Colores otherwise. A derived
// (not an effect) so it's robust to `isText` settling AFTER mount: no transient
// 'color' sticks the way an effect-set value would.
const activeTab = $derived(
tab && (tab !== 'type' || isText) ? tab : isText ? 'type' : 'color'
);
// The active block's element (resolved by its stable `data-words-id`, like the
// main panel). FloatPanel seeds its open position from this on the LEFT.
const anchorEl = $derived.by(() => {
void open;
const idPath = api.activeBlockId;
if (!content || !idPath) return null;
const segments = String(idPath).split('/').filter(Boolean);
const localId = segments[segments.length - 1];
if (!localId) return null;
return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null;
});
// Re-anchor to the LEFT of the block: FloatPanel seeds on the first open; when
// the active block changes while open, reposition next to the new block. A drag
// within one block is preserved. Cleared on close so the next open re-seeds.
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
let posBlock: string | null = null;
$effect(() => {
if (!open) {
panelPos = undefined;
posBlock = null;
return;
}
const el = anchorEl;
const win = content?.ownerDocument?.defaultView;
const id = api.activeBlockId;
if (!el || !win || !id) return;
if (posBlock === null) {
posBlock = id;
return;
}
if (id === posBlock) return;
posBlock = id;
const r = el.getBoundingClientRect();
const gap = 16;
const pad = 16;
panelPos = {
x: Math.min(Math.max(r.left - PANEL_W - gap, pad), win.innerWidth - PANEL_W - pad),
y: Math.min(Math.max(r.top + r.height / 2 - PANEL_H / 2, pad), win.innerHeight - PANEL_H - pad)
};
});
type Align = 'left' | 'center' | 'right' | 'justify';
type VisualPatch = {
color?: string;
background?: string;
fontSize?: number;
lineHeight?: number;
align?: Align;
};
function setVisual(patch: VisualPatch) {
const path = loc?.path;
if (!path) return;
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch });
}
function activeEl(): HTMLElement | null {
return (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
}
function effectiveColor(prop: 'color' | 'background'): string | undefined {
const o = loc?.node?.[prop];
if (typeof o === 'string') return o;
const el = activeEl();
if (!el) return undefined;
const cs = getComputedStyle(el);
return prop === 'color' ? cs.color : cs.backgroundColor;
}
function fontSizeNum(): number | undefined {
const o = loc?.node?.fontSize;
if (typeof o === 'number') return o;
const el = activeEl();
if (!el) return undefined;
const px = parseFloat(getComputedStyle(el).fontSize);
return Number.isFinite(px) ? Math.round(px) : undefined;
}
function lineHeightNum(): number | undefined {
const o = loc?.node?.lineHeight;
return typeof o === 'number' ? o : undefined;
}
function effectiveAlign(): Align {
const o = loc?.node?.align;
if (typeof o === 'string') return o as Align;
const el = activeEl();
let ta = el ? getComputedStyle(el).textAlign : 'left';
if (ta === 'start') ta = 'left';
if (ta === 'end') ta = 'right';
return (['left', 'center', 'right', 'justify'].includes(ta) ? ta : 'left') as Align;
}
const ALIGNS: readonly { v: Align; label: string }[] = [
{ v: 'left', label: 'Izq.' },
{ v: 'center', label: 'Centro' },
{ v: 'right', label: 'Der.' },
{ v: 'justify', label: 'Just.' }
];
</script>
<FloatPanel
{open}
onOpenChange={(v) => !v && onClose()}
bind:position={panelPos}
anchor={anchorEl}
side="left"
align="center"
offset={16}
draggable
closeOnInteractOutside
variant="surface"
color="neutral"
defaultSize={{ width: PANEL_W, height: PANEL_H }}
>
<FloatPanel.Content data-palabras-appearance data-words-external-tool>
<FloatPanel.Header closable={false}>
<FloatPanel.Title>Apariencia</FloatPanel.Title>
</FloatPanel.Header>
<FloatPanel.Body>
<Tabs value={activeTab} onValueChange={(v) => (tab = v)} variant="line" size="sm" scrollable>
<Tabs.List aria-label="Grupos de estilo" data-palabras-style-tabs>
{#if isText}
<Tabs.Trigger value="type">Tipografía</Tabs.Trigger>
{/if}
<Tabs.Trigger value="color">Colores</Tabs.Trigger>
<Tabs.Trigger value="layout">Disposición</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
{#if isText}
<Tabs.Content value="type">
<div data-palabras-panel-fields>
<div data-palabras-field data-inline>
<span data-palabras-field-label>Tamaño (px)</span>
<NumberField
size="sm"
min={8}
value={fontSizeNum()}
onValueChange={(v) =>
setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</div>
<div data-palabras-field data-inline>
<span data-palabras-field-label>Interlineado</span>
<NumberField
size="sm"
min={1}
step={0.1}
value={lineHeightNum()}
onValueChange={(v) =>
setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
</div>
</div>
</Tabs.Content>
{/if}
<Tabs.Content value="color">
<div data-palabras-panel-fields>
<PalabrasColorRow
label="Color de texto"
current={effectiveColor('color')}
onPick={(hex) => setVisual({ color: hex })}
/>
<PalabrasColorRow
label="Fondo"
current={effectiveColor('background')}
onPick={(hex) => setVisual({ background: hex })}
/>
</div>
</Tabs.Content>
<Tabs.Content value="layout">
<div data-palabras-panel-fields>
<div data-palabras-field>
<span data-palabras-field-label>Alineación</span>
<ToggleGroup
selectionMode="single"
size="sm"
value={[effectiveAlign()]}
onValueChange={(v) => {
const a = v[0] as Align;
if (a) setVisual({ align: a });
}}
>
{#each ALIGNS as a (a.v)}
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Tabs.Content>
</Tabs>
</FloatPanel.Body>
</FloatPanel.Content>
</FloatPanel>

@ -1,133 +0,0 @@
<script lang="ts">
/**
* Block drag-reorder controller (the drop sink).
*
* The left-gutter grip is `draggable`; grabbing it starts an HTML5 drag
* that carries the block's top-level index on a private MIME type
* (`WORDS_BLOCK_DRAG_TYPE`). This controller listens on the contenteditable,
* paints a horizontal indicator at the nearest inter-block seam during the
* drag, and on drop repositions the block via the engine's `moveBlockTo`
* command.
*
* Native text drag-drop is untouched: every handler bails unless the drag
* carries our private type, so selecting text and dragging it still goes
* through the browser's default path.
*
* Block detection mirrors the gutter exactly — top-level blocks are the
* content's direct `[data-words-node='block']` children, and `fromIndex`
* (written by the grip) indexes the same array. Reorder is therefore
* top-level only; nested blocks (inside columns / cells) are not draggable.
*/
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
let {
api,
content,
dom,
dropZone
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
/**
* Element to listen on for `dragover` / `drop`. Defaults to `content`
* (Words' own layout, where the grip floats over the content's left
* padding). When the grip lives in a SEPARATE column to the side of the
* content (palabras' rail), pass the wrapper row that spans BOTH columns
* so a straight-down drag over the rail still fires `dragover` — `dropAt`
* uses only `clientY`, so the seam geometry is unaffected by where we
* listen. Block geometry always comes from `content`.
*/
dropZone?: HTMLElement | null;
} = $props();
// The `[data-words]` frame is a positioned ancestor; the indicator is
// placed relative to it, same as the gutter.
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let line = $state<{ top: number; left: number; width: number } | null>(null);
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement =>
el instanceof HTMLElement && el.matches("[data-words-node='block']")
);
}
function isBlockDrag(e: DragEvent): boolean {
return !!e.dataTransfer && Array.from(e.dataTransfer.types).includes(WORDS_BLOCK_DRAG_TYPE);
}
// Nearest inter-block seam for a viewport-Y cursor. `n` is the insertion
// point in 0..length (n === length ⇒ after the last block). Coords come
// back frame-relative for the indicator.
function dropAt(
clientY: number
): { n: number; top: number; left: number; width: number } | null {
if (!frame) return null;
const blocks = topLevelBlocks();
if (blocks.length === 0) return null;
const fr = frame.getBoundingClientRect();
for (let i = 0; i < blocks.length; i++) {
const r = blocks[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) {
return { n: i, top: r.top - fr.top, left: r.left - fr.left, width: r.width };
}
}
const last = blocks[blocks.length - 1].getBoundingClientRect();
return {
n: blocks.length,
top: last.bottom - fr.top,
left: last.left - fr.left,
width: last.width
};
}
$effect(() => {
const doc = dom.getDocument(content) ?? content;
// Listen on the drop zone (the rail+canvas row in palabras) so a
// straight-down drag over the gutter still fires dragover. Defaults to
// `content` for Words' own gutter-over-content layout.
const zone = dropZone ?? content;
const disposers = [
dom.listen(zone, 'dragover', (event) => {
const e = event as DragEvent;
if (!isBlockDrag(e)) return; // leave native text drag alone
e.preventDefault(); // a drop only fires when dragover is prevented
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const d = dropAt(e.clientY);
line = d ? { top: d.top, left: d.left, width: d.width } : null;
}),
dom.listen(zone, 'drop', (event) => {
const e = event as DragEvent;
if (!isBlockDrag(e)) return;
e.preventDefault();
const from = Number(e.dataTransfer?.getData(WORDS_BLOCK_DRAG_TYPE));
const d = dropAt(e.clientY);
line = null;
if (!Number.isInteger(from) || from < 0 || !d) return;
// `moveBlockTo`'s target is post-removal: dropping at seam `n`
// when the source sits before it shifts everything down one.
const to = from < d.n ? d.n - 1 : d.n;
api.applyCommand({ type: 'moveBlockTo', fromIndex: from, toIndex: to });
}),
// `dragend` fires on the SOURCE (the grip) and does not bubble, but
// the capture phase still reaches a document-level listener — clears
// the indicator wherever the drag ends, including a drop cancelled
// outside the content.
dom.listen(doc, 'dragend', () => (line = null), { capture: true })
];
return () => {
for (const dispose of disposers) dispose?.();
};
});
</script>
{#if line}
<div
data-words-block-drop-line
style="top: {line.top}px; left: {line.left}px; width: {line.width}px;"
></div>
{/if}

@ -1,70 +0,0 @@
<script lang="ts">
/**
* Breadcrumb of the active node's ancestry, built from
* `api.activeBlockLocation.ancestry` with our `<Breadcrumb>` compound.
*
* Clicking a crumb activates that node AND opens its properties panel (the
* second way to open the panel, besides the block grip). Click handling is
* DELEGATED on a wrapper via each crumb's `data-idpath` so it works whether
* the crumb renders as an `<a>` (ancestor) or a `<span>` (the current page).
* The document crumb only clears the active block (no panel at doc level).
*/
import type { ProviderSnippetProps } from '$soma/components/words';
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
import { ActiveEidos } from '$uix/eidos';
import { crumbLabel } from './panel-schema';
let { api, onOpen }: { api: ProviderSnippetProps; onOpen: () => void } = $props();
const eidos = ActiveEidos.require();
const ts = (ref: string) => eidos.langs.ts(ref);
const ancestry = $derived(api.activeBlockLocation?.ancestry ?? []);
function onClick(e: MouseEvent) {
const el = (e.target as HTMLElement).closest('[data-idpath]') as HTMLElement | null;
if (!el) return;
e.preventDefault();
const idPath = el.getAttribute('data-idpath') ?? '';
if (el.dataset.kind === 'document') {
api.setActiveBlock(null);
return;
}
api.setActiveBlock(idPath || null);
onOpen();
}
// A crumb press must NOT count as "outside" for the open panel: the
// FloatPanel's interact-outside dismiss honors `defaultPrevented`, so
// preventing the pointerdown keeps the panel open and it FOLLOWS the crumb
// navigation (the grip survives the same way via its drag preventDefault).
// `click` still fires — only focus/selection side effects are suppressed.
function onPointerDown(e: PointerEvent) {
const el = (e.target as HTMLElement).closest('[data-idpath]');
if (el) e.preventDefault();
}
</script>
{#if ancestry.length > 0}
<!-- Keyboard path: the crumbs are real <a> links (Enter fires click, which
bubbles to this delegated handler) — the wrapper itself needs no key handler. -->
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div data-palabras-breadcrumb-wrap onclick={onClick} onpointerdown={onPointerDown}>
<Breadcrumb size="sm" data-palabras-breadcrumb>
<Breadcrumb.List>
{#each ancestry as node, i (node.idPath)}
{#if i > 0}
<Breadcrumb.Separator />
{/if}
<Breadcrumb.Item>
<Breadcrumb.Link
href="#"
current={i === ancestry.length - 1}
data-idpath={node.idPath}
data-kind={node.kind}>{crumbLabel(node.kind, node.index, ts)}</Breadcrumb.Link
>
</Breadcrumb.Item>
{/each}
</Breadcrumb.List>
</Breadcrumb>
</div>
{/if}

@ -1,81 +0,0 @@
<script lang="ts">
/**
* Colour row — labelled eidos `<ColorPicker>` bound to a block's hex (our
* component, NOT a native input). Same draft/`onValueChangeEnd` pattern as the
* Words inspector: a local `ColorValue` draft synced from `current` (the
* effective hex — override or inherited) so external edits propagate without
* clobbering a mid-drag value.
*/
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { parseColor, colorValueFromHsv, type ColorValue } from '$libs/color';
let {
label,
current,
presets = [],
onPick
}: {
label: string;
current: string | undefined;
presets?: readonly string[];
onPick: (hex: string | undefined) => void;
} = $props();
let draft = $state<ColorValue | undefined>(undefined);
$effect(() => {
const hexIn = current;
untrack(() => {
if (!hexIn) {
if (draft !== undefined) draft = undefined;
return;
}
const hsv = parseColor(hexIn);
const next = hsv ? colorValueFromHsv(hsv) : undefined;
if (next?.hex !== draft?.hex) draft = next;
});
});
</script>
<div data-palabras-field data-inline>
<span data-palabras-field-label>{label}</span>
<ColorPicker
size="sm"
bind:value={draft}
onValueChange={(cv: ColorValue | undefined) => {
if (cv === undefined && current !== undefined) onPick(undefined);
}}
onValueChangeEnd={(cv: ColorValue | undefined) => onPick(cv?.hex)}
>
<ColorPicker.Control>
<!-- The Trigger already paints ONE swatch (showSwatch); no inner ValueSwatch. -->
<ColorPicker.Trigger />
</ColorPicker.Control>
<ColorPicker.Portal>
<ColorPicker.Content>
<PickerShell.Body>
<ColorPicker.Area />
<ColorPicker.ChannelSlider channel="hue" />
<ColorPicker.ChannelSlider channel="alpha" />
<ColorPicker.ChannelInput />
{#if presets.length}
<ColorPicker.SwatchGroup>
{#each presets as c (c)}
<ColorPicker.SwatchTrigger color={c}>
<ColorPicker.Swatch color={c} />
<ColorPicker.SwatchIndicator />
</ColorPicker.SwatchTrigger>
{/each}
</ColorPicker.SwatchGroup>
{/if}
</PickerShell.Body>
<ColorPicker.Footer>
<ColorPicker.Clear />
<ColorPicker.Cancel />
<ColorPicker.Close />
</ColorPicker.Footer>
</ColorPicker.Content>
</ColorPicker.Portal>
</ColorPicker>
</div>

@ -1,283 +0,0 @@
<script lang="ts">
/**
* Per-column insert affordance — a round "+" overlay on each column's BOTTOM
* border, shown ONLY while the cursor is over that column. Click → the same
* block-type grid the grip uses → APPENDS into that column via `insertBlockInColumn`
* (now path-aware: `insertBlockAtPath` at the column's end).
*
* Positioning: an absolutely-positioned overlay measured FRAME-relative
* (`[data-words]`), like Words' drop-line seam — so it never makes the canvas a
* containing block (which would break the seam's frame-relative anchor). The
* menu is PORTALED (`DropdownMenu.Portal`) so the trigger's centring transform
* doesn't become the menu's containing block (the grip's positioning bug).
*
* `data-words-external-tool` marks the "+" + menu as internal to the editor's
* focus scope, so opening them doesn't trip the blur→refocus loop.
*/
import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
import { ActiveEidos } from '$uix/eidos';
import { Box } from '$uix/eidos/components/box';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { Plus } from '$uix/eidos/components/icon';
import { blockToInsert, insertableBlockTypes, type PalabrasBlockType } from './palabras-block-types';
import { insertBlockInColumn } from './palabras-block-actions';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
const eidos = ActiveEidos.require();
const uid = $props.id();
const ts = (ref: string) => eidos.langs.ts(ref);
const insertLabel = $derived(
ts('#?components.palabras.action.insert-in-column|Insertar bloque en la columna')
);
// Types offered inside a column: everything EXCEPT columns (no columns-in-columns).
const colTypes = insertableBlockTypes().filter((t) => t.id !== 'columns');
type Inserter = {
key: string;
left: number;
top: number;
columnsIdx: number;
colIdx: number;
};
let inserters = $state<Inserter[]>([]);
// The button is shown ONLY for the column the cursor is currently over.
let hoveredKey = $state<string | null>(null);
// Top-level index of the columns block that is the ACTIVE block, else -1. When
// the columns block itself is SELECTED (you clicked it to move/delete the whole
// thing), its empty columns' "+" must NOT show — "no debería aparecer aún".
const activeColumnsIdx = $derived.by(() => {
const loc = api.activeBlockLocation;
if (loc && loc.path.length === 1 && (loc.node as { type?: string }).type === 'columns') {
return loc.path[0];
}
return -1;
});
// Grace timer: a short delay before HIDING, so a brief overshoot while reaching
// for the button (which hangs off the column's bottom edge) doesn't snatch it
// away — "desaparece antes de que pueda pulsarlo".
// Grace timer via the ActiveUix Timer service (key-addressed: re-scheduling
// REPLACES the pending hide via `{ replace: true }` — the engine throws on a
// duplicate key otherwise; cancel is by key) — never a raw `setTimeout`.
const HIDE_KEY = `palabras-col-inserter-hide-${uid}`;
function scheduleHide() {
eidos.timers.schedule(
HIDE_KEY,
400,
() => {
hoveredKey = null;
},
{ replace: true }
);
}
function cancelHide() {
eidos.timers.cancel(HIDE_KEY);
}
function frame(): HTMLElement | null {
return (content?.closest('[data-words]') as HTMLElement | null) ?? null;
}
// The inserter key (`<columnsIdx>-<colIdx>`) for a column ELEMENT — re-derived
// from its live position, NOT a stored element reference (which goes stale when
// the engine re-renders the columns). Mirrors `remeasure`'s key, so the hovered
// column resolves to the SAME key the rendered "+" carries.
function columnKey(colEl: HTMLElement): string | null {
const colsBlock = colEl.closest('[data-words-block="columns"]') as HTMLElement | null;
if (!colsBlock) return null;
const tops = Array.from(content.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
);
const columnsIdx = tops.indexOf(colsBlock);
const colSibs = Array.from(colsBlock.children).filter(
(c): c is HTMLElement =>
c instanceof HTMLElement && c.getAttribute('data-words-node') === 'column'
);
const colIdx = colSibs.indexOf(colEl);
return columnsIdx >= 0 && colIdx >= 0 ? `${columnsIdx}-${colIdx}` : null;
}
function remeasure() {
const fr = frame();
if (!fr) {
inserters = [];
return;
}
const frameRect = fr.getBoundingClientRect();
const tops = Array.from(content.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
);
const out: Inserter[] = [];
content.querySelectorAll('[data-words-block="columns"]').forEach((cb) => {
const colsBlock = cb as HTMLElement;
const columnsIdx = tops.indexOf(colsBlock);
if (columnsIdx < 0) return;
const cols = Array.from(colsBlock.children).filter(
(c): c is HTMLElement =>
c instanceof HTMLElement && c.getAttribute('data-words-node') === 'column'
);
cols.forEach((col, colIdx) => {
// EMPTY columns ONLY. Once a column has blocks, each block carries its
// own grip whose menu handles everything (insert / move / delete / …),
// so a second column-level button would be redundant — "no puede haber
// dos botones al mismo tiempo activos". The "+" is purely the affordance
// to seed the FIRST block into an empty column.
const hasBlock = Array.from(col.children).some(
(c) => c instanceof HTMLElement && c.hasAttribute('data-words-id')
);
if (hasBlock) return;
const cr = col.getBoundingClientRect();
// CENTRED in the empty column — NOT on the bottom border. The bottom
// position forced the cursor all the way down to reach it, dragging the
// gutter grip down with it; a centred "+" is reached with little travel.
const left = Math.round(cr.left - frameRect.left + cr.width / 2);
const top = Math.round(cr.top - frameRect.top + cr.height / 2);
out.push({ key: `${columnsIdx}-${colIdx}`, columnsIdx, colIdx, left, top });
});
});
inserters = out;
}
// Re-measure when the document changes...
$effect(() => {
void api.html;
untrack(() => remeasure());
});
// ...and on scroll / resize (positions are viewport-derived).
$effect(() => {
const offScroll = dom.listen(dom.getDocument(content), 'scroll', () => remeasure(), {
capture: true
});
const offResize = dom.listen(dom.getWindow(content), 'resize', () => remeasure());
return () => {
offScroll?.();
offResize?.();
};
});
// Show a column's "+" ONLY while the cursor is over that column (the deferred
// "los + deben desaparecer al no estar activos"). The menu is portaled +
// independent, so hiding the trigger while its menu is open is harmless.
$effect(() => {
// Listen on `content` (the contenteditable) — a stable prop that is always
// an ancestor of the columns, so pointermove over a column bubbles here. (An
// earlier version listened on `frame()` = `[data-words]`, which could be null
// at effect-setup time and then never re-attached.)
if (!content) return;
// Listen on the CANVAS (parent of both the contenteditable AND the button
// overlays), so pointermove over the button itself also fires here — letting
// us KEEP it shown while the cursor is on it (the button is a sibling of
// `content`, so a `content` listener never sees those moves).
const canvas = (content.closest('[data-palabras-canvas]') as HTMLElement | null) ?? content;
const onMove = (e: Event) => {
const t = (e as PointerEvent).target as HTMLElement | null;
// Over the button itself → keep it shown (cancel any pending hide).
if (t?.closest('[data-palabras-column-inserter]')) {
cancelHide();
return;
}
const x = (e as PointerEvent).clientX;
const y = (e as PointerEvent).clientY;
// GEOMETRY, not `closest()`: the round button straddles the column's
// BOTTOM border, half of it hanging below. A `closest()` test loses the
// column the moment the cursor crosses that border. Treat the column rect
// EXPANDED downward by the button's reach as "still hovering the column".
let key: string | null = null;
for (const el of content.querySelectorAll('[data-words-column]')) {
if (!(el instanceof HTMLElement)) continue;
const r = el.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom + 18) {
key = columnKey(el);
break;
}
}
if (key) {
cancelHide();
hoveredKey = key;
} else {
// Grace period before hiding (don't snatch the button mid-reach).
scheduleHide();
}
};
const onLeave = () => scheduleHide();
const offMove = dom.listen(canvas, 'pointermove', onMove);
const offLeave = dom.listen(canvas, 'pointerleave', onLeave);
return () => {
offMove?.();
offLeave?.();
cancelHide();
};
});
// On close, land the caret in the NEW block. NOT `content.focus()` — that
// restores the STALE pre-insert DOM caret (often the block AFTER the columns),
// and the caret-follower then bounces the active block there ("se va a la
// siguiente fila"). `restoreCaret()` writes the MODEL selection — which the
// engine's `insertBlockInColumn` landed inside the new block — so the caret,
// the active block and the grip all agree on the new block. Deferred one frame
// so the freshly-inserted block is mounted before we write to it.
function onCloseAutoFocus(e: Event) {
e.preventDefault();
dom.requestFrame(() => api.restoreCaret(), content);
}
function pick(ins: Inserter, bt: PalabrasBlockType) {
// Image inserts a PLACEHOLDER (`{ type: 'image', src: '' }`); the URL / alt are
// set later from the properties panel. No file picker on insert.
insertBlockInColumn(api, ins.columnsIdx, ins.colIdx, blockToInsert(bt, ts));
}
</script>
{#each inserters as ins (ins.key)}
<Box
data-palabras-column-inserter
data-visible={ins.key === hoveredKey && ins.columnsIdx !== activeColumnsIdx ? '' : undefined}
style="left: {ins.left}px; top: {ins.top}px"
>
<DropdownMenu>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-column-inserter-button
data-words-external-tool
aria-label={insertLabel}
>
{#snippet icon()}<Plus />{/snippet}
{insertLabel}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="start"
preventScroll={false}
trapFocus={false}
onCloseAutoFocus={onCloseAutoFocus}
data-palabras-handle-menu
data-words-external-tool
data-palabras-insert-grid
>
{#each colTypes as bt (bt.id)}
{@const Icon = bt.icon}
<DropdownMenu.Item onSelect={() => pick(ins, bt)}>
<Icon />
<span>{ts(bt.label)}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Box>
{/each}

@ -1,40 +0,0 @@
/**
* Reusable drag state for the floating popovers. Returns a reactive `transform`
* (apply to the popover root) + pointer handlers (wire to the drag handle, e.g.
* the header). Buttons inside the handle opt out so they stay clickable. Each
* call owns independent state, so panel + appearance drag separately; remounting
* the popover resets the position.
*/
export function createDrag() {
const pos = $state({ x: 0, y: 0 });
let from: { px: number; py: number; ox: number; oy: number } | null = null;
return {
get transform() {
return `translate(${pos.x}px, ${pos.y}px)`;
},
down(e: PointerEvent) {
if ((e.target as HTMLElement).closest('button')) return;
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* synthetic / unsupported pointer — still tracks via move */
}
from = { px: e.clientX, py: e.clientY, ox: pos.x, oy: pos.y };
},
move(e: PointerEvent) {
if (!from) return;
pos.x = from.ox + (e.clientX - from.px);
pos.y = from.oy + (e.clientY - from.py);
},
up(e: PointerEvent) {
if (!from) return;
from = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
} catch {
/* */
}
}
};
}

@ -1,199 +0,0 @@
<script lang="ts">
/**
* Block grip — a drag-handle that opens the properties panel.
*
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at ANY
* nesting depth. A top-level block gets its grip in the left RAIL; a block INSIDE a
* column gets its grip at the block's own left edge (floating out of the rail into
* the canvas).
*
* Two behaviours from the same trigger, by the browser's click-vs-drag:
* - CLICK → opens the properties PANEL directly. Every per-block action — move /
* insert / delete — now lives in the panel itself (its footer + header), so the
* grip no longer opens an intermediate menu: one click instead of two.
* - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink). Drag
* is enabled ONLY for top-level blocks — the drop sink is top-level.
*
* The trigger carries `data-words-external-tool` so the provider's
* `isInsideWordsTool` treats focus landing here as internal (no editor churn).
*
* Grip placement: PINNED to the active block (always ⋮). It moves only when the
* active block changes (click / Arrow nav / breadcrumb) — never on hover, which
* was erratic.
*/
import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import { findBlockById, type ProviderSnippetProps } from '$soma/components/words';
import { Box } from '$uix/eidos/components/box';
import { Button } from '$uix/eidos/components/button';
import { EllipsisVertical } from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
// `panelOpen` / `onClose` stay in the props TYPE (the parent's call is
// unchanged) but are deliberately not destructured — the grip no longer
// needs them: every action moved into the panel. (`dom` IS used — the
// grip re-measures via `dom.observeResize`.)
let {
api,
content,
dom,
onOpen
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
panelOpen?: boolean;
onOpen: (id: string) => void;
onClose: () => void;
} = $props();
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks keep
// their handle flush in the rail at left:0). Must be > the handle's own width so
// it clears the block's text, and the columns `gap` (palabras.css) must exceed it.
const HANDLE_REACH = 30;
// The grip carries its block's full id-path + numeric path + position-in-parent,
// captured at placement. `draggable` is true only for top-level blocks (drag sink
// is top-level). `top`/`left` are host-relative (the slot lives in the rail).
type Grip = {
top: number;
left: number;
id: string;
path: readonly number[];
draggable: boolean;
};
let grip = $state<Grip | null>(null);
/** Full materialized id-path of a block element: the `/`-joined chain of every
* `data-words-id` ancestor (root → block). Matches `findBlockById`'s addressing. */
function idPathOf(target: HTMLElement, host: HTMLElement): string {
const ids: string[] = [];
let el: HTMLElement | null = target.closest('[data-words-id]');
while (el && host.contains(el)) {
const id = el.getAttribute('data-words-id');
if (id) ids.unshift(id);
el = el.parentElement?.closest('[data-words-id]') ?? null;
}
return ids.join('/');
}
function host(): HTMLElement | null {
return (content?.closest('[data-palabras-doc]') as HTMLElement | null) ?? null;
}
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
);
}
/** Find a block element (top-level OR nested) by its full id-path. */
function findElByIdPath(idPath: string): HTMLElement | null {
const h = host();
if (!h || !idPath) return null;
for (const el of content.querySelectorAll('[data-words-id]')) {
if (el instanceof HTMLElement && idPathOf(el, h) === idPath) return el;
}
return null;
}
/** Top-level index of a block (or of its top-level ancestor) — `path` fallback. */
function topIndexOf(block: HTMLElement): number {
let el: HTMLElement = block;
while (el.parentElement && el.parentElement !== content) el = el.parentElement;
return topLevelBlocks().indexOf(el);
}
// Build the grip for a block: position (host-relative), full id-path, numeric path,
// and whether it's draggable (top-level only).
function gripFor(block: HTMLElement, h: HTMLElement): Grip {
// A LIST is gripped as ONE unit — never an individual item. Climb from a list
// item to its list block (the grip targets the whole list, at its left edge).
if (block.getAttribute('data-words-node') === 'list-item') {
const listBlock = block.closest('[data-words-block]');
if (listBlock instanceof HTMLElement) block = listBlock;
}
const hr = h.getBoundingClientRect();
const br = block.getBoundingClientRect();
const id = idPathOf(block, h);
const loc = findBlockById(api.document, id);
const path = loc?.path ?? [topIndexOf(block)];
const isTop = path.length <= 1;
return {
top: br.top - hr.top + br.height / 2,
// Top-level → flush in the rail. Nested → just left of the block, floating
// out of the rail into the canvas.
left: isTop ? 0 : Math.round(br.left - hr.left - HANDLE_REACH),
id,
path,
draggable: isTop
};
}
// PINNED to the active block — the grip shows ⋮ and tracks the active block. It
// re-measures on active-change / doc-change (the reactive deps below) AND on layout
// shifts those deps DON'T capture: a content RESIZE (a block growing, or — the
// recurring culprit — the heading FONT swapping in after first paint and changing
// its height) and the webfonts settling. Without these the grip is pinned to a
// STALE rect and drifts off the block until the next active-change.
$effect(() => {
const h = host();
const activeId = api.activeBlockId;
void api.html;
if (!h || !content) return;
let disposed = false;
const place = () => {
if (disposed) return;
const el = activeId ? findElByIdPath(activeId) : null;
grip = el ? gripFor(el, h) : null;
};
untrack(place);
// Re-place whenever the editor content reflows (block height / font swap) and
// once the webfonts have settled (the swap lands after the first measure).
const offResize = dom.observeResize(content, () => untrack(place));
dom.getDocument(h).fonts?.ready?.then(() => untrack(place));
return () => {
disposed = true;
offResize?.();
};
});
// CLICK → open the properties panel on this block.
function openProperties() {
if (!grip) return;
api.setActiveBlock(grip.id);
onOpen(grip.id);
}
const eidos = ActiveEidos.require();
const propertiesLabel = $derived(
eidos.langs.ts('#?components.palabras.action.properties|Propiedades del bloque')
);
// HTML5 drag — top-level blocks ONLY. Sets the canonical MIME + effectAllowed.
function onDragStart(e: DragEvent) {
if (!grip || !grip.draggable || !e.dataTransfer) return;
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
e.dataTransfer.effectAllowed = 'move';
}
</script>
{#if grip}
<!-- Positioned slot: owns the gutter/inline placement (host-relative inline). -->
<Box data-palabras-handle-slot style="top: {grip.top}px; left: {grip.left}px">
<!-- The grip = a draggable framework <Button>. CLICK opens the properties panel
directly (its footer + header hold every per-block action — no menu). DRAG
(top-level only) reorders. `data-words-external-tool` keeps focus landing
here inside the editor's focus scope. -->
<Button
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-handle
data-nested={grip.path.length > 1 ? '' : undefined}
data-words-external-tool
aria-label={propertiesLabel}
title={propertiesLabel}
draggable={grip.draggable}
ondragstart={onDragStart}
onclick={openProperties}
>
{#snippet icon()}<EllipsisVertical />{/snippet}
</Button>
</Box>
{/if}

@ -1,37 +0,0 @@
<script lang="ts">
/**
* Panel dismissal. Render-less. While the properties panel is open this
* listens for `Escape` or a pointer-down OUTSIDE the panel (and outside a
* block grip) and calls `onClose`. Mounted only while the panel is open, so
* the listeners exist exactly while they're needed.
*/
import type { ActiveDom } from '$adom';
let { dom, onClose }: { dom: ActiveDom; onClose: () => void } = $props();
$effect(() => {
const doc = dom.getDocument();
const onKey = (e: Event) => {
if ((e as KeyboardEvent).key === 'Escape') onClose();
};
const onDown = (e: Event) => {
const t = e.target as HTMLElement | null;
if (
t?.closest('[data-palabras-panel-card]') ||
t?.closest('[data-palabras-appearance]') ||
t?.closest('[data-palabras-handle]') ||
// The ColorPicker portals its picker outside the popover; clicking
// inside it must NOT dismiss the inspector.
t?.closest('[data-color-picker-content]')
)
return;
onClose();
};
const offKey = dom.listen(doc, 'keydown', onKey);
const offDown = dom.listen(doc, 'pointerdown', onDown, { capture: true });
return () => {
offKey?.();
offDown?.();
};
});
</script>

@ -1,986 +0,0 @@
<script lang="ts">
/**
* Generated block panel — the framework `<FloatPanel>` anchored to the active
* block (opens to its right, centred on its mid-height; draggable from the
* header; closes on click-outside / Esc). Its body is THREE fixed tabs —
* `Contenido · Diseño · Avanzado` (open on Contenido) — whose fields come from
* the block's schema (`PALABRAS_PANEL_REGISTRY`). Height fits the content up to
* a max (then the body scrolls).
*
* Field edits write the active block via `updateBlockAtPath` (structural) or
* `setBlockVisualAtPath` (style, `field.visual`). The `textarea` field edits
* the block's TEXT (its inline `children`). Controls are FRAMEWORK components.
*/
import {
sanitizeWordsUrl,
type ProviderSnippetProps,
type WordsSpacing
} from '$soma/components/words';
import { CssField } from '$uix/eidos/components/css-field';
import { Switch } from '$uix/eidos/components/switch';
import { Slider } from '$uix/eidos/components/slider';
import { NumberField } from '$uix/eidos/components/number-field';
import { Tabs } from '$uix/eidos/components/tabs';
import { Accordion } from '$uix/eidos/components/accordion';
import { TextArea } from '$uix/eidos/components/textarea';
import { Field } from '$uix/eidos/components/field';
import { Button } from '$uix/eidos/components/button';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import {
Trash2,
Upload,
ArrowBigUp,
ArrowBigDown,
Plus,
RotateCcw
} from '$uix/eidos/components/icon';
import { getPanelSchema, crumbLabel, PALABRAS_FONTS } from './panel-schema';
import {
schemeAllowsField,
schemeColorFor,
schemeExtraFields,
schemePaletteValues,
schemeSizeFor,
schemeSubControls
} from './palabras-scheme';
import ImageAdjustments from '$uix/eidos/components/image-adjustments';
import type {
ImageAdjustmentKey,
ImageAdjustmentValues
} from '$uix/eidos/components/image-adjustments';
import type { PalabrasScheme } from './types';
import { blockTypeIcon, insertableBlockTypes, blockToInsert } from './palabras-block-types';
import type { PalabrasBlockType } from './palabras-block-types';
import PalabrasSegmented from './palabras-segmented.svelte';
import PalabrasColorRow from './palabras-color-row.svelte';
import PalabrasSpacing from './palabras-spacing.svelte';
import PalabrasTypography from './palabras-typography.svelte';
import { pickImageFile } from './palabras-image-file';
import { FloatPanel } from '$uix/eidos/components/float-panel';
import { deleteBlockAndReanchor } from './palabras-block-actions';
import type { PalabrasFieldDef, PalabrasSectionDef } from './types';
import { ActiveEidos } from '$uix/eidos';
let {
api,
scheme,
content,
open,
onClose
}: {
api: ProviderSnippetProps;
scheme: PalabrasScheme;
content: HTMLElement | null;
open: boolean;
onClose: () => void;
} = $props();
// DOM access goes through ActiveDom (mockable + iframe-safe) — never bare
// `querySelector` / `getComputedStyle` / `ownerDocument`.
const eidos = ActiveEidos.require();
const dom = eidos.dom;
/** Resolve a `#?components.palabras.*|fallback` ref (reactive to locale). */
const ts = (ref: string) => eidos.langs.ts(ref);
/** Resolve the labels of a pills field's options (values stay raw). */
const tsOptions = (field: PalabrasFieldDef) =>
(field.options ?? []).map((o) => ({ ...o, label: ts(o.label) }));
const loc = $derived(api.activeBlockLocation);
// The node's KIND: the block `type` for blocks, the structural kind
// (table-cell / table-row / column) for the TYPELESS ancestry nodes the
// breadcrumb / Esc ladder can activate — the ancestry's last entry names
// every node. Schema, icon and scheme gating all resolve from this.
const kind = $derived(loc?.ancestry.at(-1)?.kind ?? (loc?.node.type as string | undefined));
const schema = $derived(getPanelSchema(kind));
// Structural nodes take block-array commands badly (move/insert operate on
// sibling BLOCK lists, not on `cells` / `rows` / `columns`) — the footer's
// reorder + insert actions are for blocks only.
const isStructural = $derived(kind === 'table-cell' || kind === 'table-row' || kind === 'column');
// The block's DEFINING icon (per engine type) for the header — for every block.
const HeadIcon = $derived(blockTypeIcon(kind));
const canDelete = $derived(!!loc && loc.path.length === 1);
// The active block's ELEMENT — resolved by its STABLE `data-words-id` (the same
// way WordsActivate stamps the active marker). `api.activeBlockId` is a
// materialized path (`cols/col/child`); the element carries only its LAST
// segment as `data-words-id`. FloatPanel seeds its open position from this
// (`side='right'` + `align='center'` → right of the block, centred on its
// mid-height, viewport-clamped).
const anchorEl = $derived.by(() => {
void open;
const idPath = api.activeBlockId;
if (!content || !idPath) return null;
const segments = String(idPath).split('/').filter(Boolean);
const localId = segments[segments.length - 1];
if (!localId) return null;
return dom.query<HTMLElement>(`[data-words-id="${CSS.escape(localId)}"]`, content);
});
// FloatPanel is a FREE window once open (drag + resize). We OWN its position:
// on open — and whenever the active block changes — we anchor it to the RIGHT of
// the block, centred on its mid-height, viewport-clamped. A drag within one block
// is preserved (we only reposition when the block id changes).
//
// NEVER clear `panelPos` while the panel is mounted. Setting it to `undefined` on
// close snapped the FloatPanel to 0,0 (left edge) for the whole exit animation —
// the panel visibly "redrew on the left" then faded. On close we only re-arm the
// anchoring (posBlock = null) and leave the last position so the close animates
// in place; the next open re-anchors from the block.
const PANEL_W = 304;
const PANEL_H = 380;
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
let posBlock: string | null = null;
$effect(() => {
if (!open) {
posBlock = null; // re-arm anchoring for the next open; keep panelPos (no close-jump)
return;
}
const el = anchorEl;
const win = dom.getWindow(content);
const id = api.activeBlockId;
if (!el || !win || !id) return;
if (id === posBlock) return; // already anchored for this block — don't fight a drag
posBlock = id;
const r = el.getBoundingClientRect();
// Clamp with the panel's REAL height — it sizes to its content (auto, up to
// the CSS max), so the PANEL_H default is only the pre-mount fallback.
// Clamping with a stale 380 let a taller panel anchor low enough to run
// off the bottom of the viewport (the "cut footer" bug).
const panelEl = dom.query<HTMLElement>('[data-palabras-panel]');
const panelH = panelEl?.getBoundingClientRect().height || PANEL_H;
const gap = 16;
const pad = 16;
panelPos = {
x: Math.min(Math.max(r.right + gap, pad), win.innerWidth - PANEL_W - pad),
y: Math.min(Math.max(r.top + r.height / 2 - panelH / 2, pad), win.innerHeight - panelH - pad)
};
});
// The panel GROWS with its content (height auto): opening a taller tab /
// section can push the footer past the viewport bottom. Watch its size and
// nudge it back up when it overflows (never fights a user drag — it only
// acts on actual overflow).
$effect(() => {
if (!open) return;
const el = dom.query<HTMLElement>('[data-palabras-panel]');
const win = dom.getWindow(content);
if (!el || !win) return;
return dom.observeResize(el, () => {
const r = el.getBoundingClientRect();
const overflow = r.bottom - (win.innerHeight - 16);
if (overflow > 0 && panelPos) {
panelPos = { x: panelPos.x, y: Math.max(16, panelPos.y - overflow) };
}
});
});
// Three fixed tabs (Contenido open by default). Their fields come from the
// block's schema, FILTERED by the scheme's `editable` whitelist (a type
// declared there offers ONLY those fields; undeclared → full panel), then
// EXTENDED with the scheme's designer-declared `fields` (ESQUEMA.md §8b:
// registry → −editable → +fields).
const allowsField = (field: PalabrasFieldDef) => schemeAllowsField(scheme, kind, field.key);
const extraFields = $derived(schemeExtraFields(scheme, kind));
const contenidoFields = $derived([
...(schema?.contenido ?? []).filter(allowsField),
...extraFields.filter((f) => f.tab === 'contenido')
]);
const disenoSections = $derived.by(() => {
const sections = (schema?.diseño ?? [])
.map((section) => ({ ...section, fields: section.fields.filter(allowsField) }))
.filter((section) => section.fields.length > 0);
const extra = extraFields.filter((f) => f.tab === 'diseño');
return extra.length > 0
? [...sections, { title: '#?components.palabras.section.scheme|Esquema', fields: extra }]
: sections;
});
const avanzadoFields = $derived([
...(schema?.avanzado ?? []).filter(allowsField),
...extraFields.filter((f) => (f.tab ?? 'avanzado') === 'avanzado')
]);
let tab = $state('contenido');
// ── Reset to defaults — per Diseño accordion section + per whole block. Clears
// the VISUAL overrides (back to the block's default styling); never touches the
// content (text / level). A reset is "active" only when there ARE overrides. ────
const TYPO_PROPS = [
'fontFamily',
'fontStyle',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'wordSpacing',
'textWrap',
'maxWidth',
'strikethrough',
'tabularNumbers'
] as const;
/** The visual prop keys a section's reset clears (the `typography` field expands
* to its many props; every other visual field contributes its own key). */
function sectionKeys(section: PalabrasSectionDef): string[] {
return section.fields.flatMap((f) =>
f.type === 'typography' ? [...TYPO_PROPS] : f.visual ? [f.key] : []
);
}
function isModified(keys: readonly string[]): boolean {
const n = loc?.node as Record<string, unknown> | undefined;
return !!n && keys.some((k) => n[k] != null);
}
function resetKeys(keys: readonly string[]) {
if (keys.length) commitVisualPatch(Object.fromEntries(keys.map((k) => [k, undefined])));
}
/** All visual keys across the Diseño sections — drives the component-level reset. */
const allDesignKeys = $derived([...new Set(disenoSections.flatMap(sectionKeys))]);
// Diseño accordion open state. Seeded from each section's `defaultCollapsed`
// (Disposición opens by default); re-seeded when the block TYPE changes so a new
// block gets its defaults, while preserving the user's open/close within a type.
let designOpen = $state<string[]>([]);
let designSeedType: string | undefined;
$effect(() => {
const type = kind;
if (type === designSeedType) return;
designSeedType = type;
// Single-accordion: open ONLY the first non-collapsed section (Disposición).
designOpen = disenoSections
.filter((s) => !s.defaultCollapsed)
.slice(0, 1)
.map((s) => s.title);
});
/** Plain text of the block's inline `children` — for the Texto textarea. */
function blockText(): string {
const ch = (loc?.node as { children?: readonly unknown[] } | undefined)?.children;
if (!Array.isArray(ch)) return '';
return ch
.map((n) => {
const node = n as { type?: string; text?: string; children?: { text?: string }[] };
if (node.type === 'text') return node.text ?? '';
if (node.type === 'link' && Array.isArray(node.children))
return node.children.map((t) => t.text ?? '').join('');
return '';
})
.join('');
}
/** Write the block's text — replaces the inline `children` with one text node. */
function writeBlockText(value: string) {
const path = loc?.path;
if (!path) return;
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: path,
patch: { children: [{ type: 'text', text: value }] }
});
}
/** Raw stored value of a prop, or undefined. */
function valueOf(key: string): unknown {
return loc?.node?.[key];
}
/** Effective value for display: stored value, else the field's schema default. */
function effective(field: PalabrasFieldDef): unknown {
return loc?.node?.[field.key] ?? field.default;
}
/** Numeric value (or default) for a `number` field — `undefined` when unset. */
function numOrUndef(field: PalabrasFieldDef): number | undefined {
const v = loc?.node?.[field.key];
if (typeof v === 'number') return v;
return typeof field.default === 'number' ? field.default : undefined;
}
/** Numeric value for a `slider` field (defaults applied; 0 fallback). */
function sliderValue(field: PalabrasFieldDef): number {
const v = numOrUndef(field);
return typeof v === 'number' ? v : 0;
}
function write(key: string, value: string | number | boolean | undefined) {
const path = loc?.path;
if (!path) return;
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [key]: value } });
}
/** Write a field's value, routing by `field.visual`: a STYLE override goes to
* `setBlockVisualAtPath` (the engine's STYLE_KEYS), a STRUCTURAL prop to
* `updateBlockAtPath`. The single panel now hosts both kinds. */
function commit(
field: PalabrasFieldDef,
value: string | number | boolean | WordsSpacing | undefined
) {
const path = loc?.path;
if (!path) return;
if (field.visual) {
api.applyCommand({
type: 'setBlockVisualAtPath',
blockPath: path,
visual: { [field.key]: value }
});
} else {
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: path,
patch: { [field.key]: value }
});
}
}
/** Apply a MULTI-key visual patch (the typography control writes several STYLE_KEYS
* at once). `undefined` values clear their prop back to the block default. */
function commitVisualPatch(patch: Record<string, unknown>) {
const path = loc?.path;
if (!path) return;
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch });
}
/** The active block's typographic visual subset — fed to <PalabrasTypography>. */
function typoValue() {
const n = loc?.node as
| {
fontFamily?: string;
fontStyle?: 'normal' | 'italic' | 'oblique';
strikethrough?: boolean;
fontSize?: number;
fontWeight?: number;
lineHeight?: number;
letterSpacing?: string;
wordSpacing?: string;
textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';
maxWidth?: string;
tabularNumbers?: boolean;
}
| undefined;
return n ?? {};
}
/** The active block's DEFAULT font-size (px) — the SCHEME's value for the
* element the block resolves to (heading level → h{n}, else the base
* text). Feeds the Tamaño control so it rests at the scheme's size
* instead of an empty `auto`. This is the real inherited value — the
* old `getComputedStyle` proxy is gone (F3.2). */
const blockDefaultPx = $derived(
schemeSizeFor(scheme, kind, loc?.node as Record<string, unknown> | undefined)
);
/** Display colour for a `color` field: the stored override hex, else the
* SCHEME's value for the block's element (the real inherited value). */
function effectiveColor(field: PalabrasFieldDef): string | undefined {
const o = loc?.node?.[field.key];
if (typeof o === 'string') return o;
return schemeColorFor(
scheme,
kind,
loc?.node as Record<string, unknown> | undefined,
field.key === 'background' ? 'background' : 'color'
);
}
/** Active pill value (string). The custom pill (e.g. `HC`) wins when its
* `customWhen` prop is overridden on the block; otherwise the stored value. */
function pillsActive(field: PalabrasFieldDef): string | undefined {
if (field.customOption != null && field.customWhen && loc?.node?.[field.customWhen] != null) {
return String(field.customOption);
}
const v = effective(field);
return v == null ? undefined : String(v);
}
function onPills(field: PalabrasFieldDef, v: string | undefined) {
// Deselecting (re-click the active item) clears the prop → restores the default.
if (v == null) {
commit(field, undefined);
return;
}
// The custom (HC) pill is an INDICATOR — picking it is a no-op.
if (field.customOption != null && v === String(field.customOption)) return;
const opt = field.options?.find((o) => String(o.value) === v);
if (opt) {
commit(field, opt.value);
// Picking a REAL option clears the override that drives the custom pill
// (choosing H2 drops the size override → back to H2's own default size).
if (field.customWhen) commitVisualPatch({ [field.customWhen]: undefined });
}
}
function onNumberChange(field: PalabrasFieldDef, v: number | undefined) {
// clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store
// any finite number INCLUDING 0 (filters: 0 is a real value, e.g. greyscale).
let out: number | undefined;
if (v == null || !Number.isFinite(v)) out = undefined;
else if (field.clearWhenZero && v <= 0) out = undefined;
else out = v;
commit(field, out);
}
// ── ImageAdjustments ↔ engine model mapping (ESQUEMA.md §12.3). The
// component speaks −100..100 tone (0 neutral) / deg / px; the model
// speaks percentages (100 neutral) with its own prop names. ──
const ADJ_TO_MODEL: Readonly<Record<ImageAdjustmentKey, string>> = {
brightness: 'brightness',
contrast: 'contrast',
saturation: 'saturate',
temperature: 'temperature',
hue: 'hueRotate',
blur: 'blur',
grayscale: 'grayscale',
sepia: 'sepia'
};
/** Tone keys: component value v ↔ model percentage 100 + v. */
const ADJ_TONE = new Set<ImageAdjustmentKey>(['brightness', 'contrast', 'saturation']);
const ADJ_ALL = Object.keys(ADJ_TO_MODEL) as ImageAdjustmentKey[];
function adjustmentsValue(): ImageAdjustmentValues {
const n = loc?.node as Record<string, unknown> | undefined;
const out: Partial<Record<ImageAdjustmentKey, number>> = {};
for (const key of ADJ_ALL) {
const raw = n?.[ADJ_TO_MODEL[key]];
if (typeof raw !== 'number') continue;
out[key] = ADJ_TONE.has(key) ? raw - 100 : raw;
}
return out;
}
function commitAdjustments(v: ImageAdjustmentValues) {
const path = loc?.path;
if (!path) return;
// Neutral values CLEAR their model prop (an untouched image carries
// no filter at all — same doctrine as the engine's imageFilterCss).
const patch: Record<string, number | undefined> = {};
for (const key of ADJ_ALL) {
const val = v[key];
if (val === undefined || val === 0) patch[ADJ_TO_MODEL[key]] = undefined;
else patch[ADJ_TO_MODEL[key]] = ADJ_TONE.has(key) ? 100 + val : val;
}
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch });
}
function onUpload() {
const doc = dom.getDocument(content);
pickImageFile(doc, (file) => {
void api.uploadImage(file, (src, altGuess) => {
write('src', src);
if (altGuess && valueOf('alt') == null) write('alt', altGuess);
});
});
}
/** Display value for a `length` field: a number renders as `Npx`, a string
* verbatim, undefined as empty (placeholder shows). CssField owns parsing, the
* unit whitelist (`field.units` → `allowedUnits`), `auto` keyword + invalid UI. */
function lengthValue(field: PalabrasFieldDef): string | undefined {
const v = loc?.node?.[field.key];
if (typeof v === 'number') return `${v}px`;
if (typeof v === 'string') return v;
return undefined;
}
/** Image-src sanitizer: allows inline `data:image/…` (uploads/SVG) + delegates
* the rest to `sanitizeWordsUrl` (http/https/relative; rejects javascript: etc).
* Returns the safe string (`''` clears), or `undefined` when rejected. */
function sanitizeImageSrc(raw: string): string | undefined {
const t = raw.trim();
if (t === '') return '';
if (/^data:image\//i.test(t)) return t;
return sanitizeWordsUrl(t);
}
/** `src` field commit (on blur): sanitize, then write or flag invalid. */
function onSrcCommit(field: PalabrasFieldDef, input: HTMLInputElement) {
const safe = sanitizeImageSrc(input.value);
if (safe !== undefined) {
input.removeAttribute('data-invalid');
write(field.key, safe);
} else {
input.setAttribute('data-invalid', '');
}
}
function deleteActive() {
const path = loc?.path;
if (!path || path.length !== 1 || !content) return;
void deleteBlockAndReanchor(api, content, path[0]);
// Close the panel on delete — the block it was inspecting is gone.
onClose();
}
// ── Footer actions (move / insert) — path-aware, the same the gutter grip used.
// They act on the ACTIVE block (`loc`), so the panel is now the single home for
// every per-block action (delete stays in the header). Index / count come from
// the active block element's siblings, matching how the grip computed them.
const sibInfo = $derived.by(() => {
const el = anchorEl;
if (!el?.parentElement) return { index: -1, count: 0 };
const sibs = Array.from(el.parentElement.children).filter(
(c): c is HTMLElement => c instanceof HTMLElement && c.hasAttribute('data-words-id')
);
return { index: sibs.indexOf(el), count: sibs.length };
});
const canMoveUp = $derived(sibInfo.index > 0);
const canMoveDown = $derived(sibInfo.index >= 0 && sibInfo.index < sibInfo.count - 1);
function doMoveUp() {
if (!loc || !canMoveUp) return;
api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'up' });
}
function doMoveDown() {
if (!loc || !canMoveDown) return;
api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'down' });
}
// Insertable types (columns are a top-level layout primitive — never offered when
// the active block is nested). Insert a fresh block as a sibling RIGHT AFTER the
// active one, then follow it (its id is assigned up front; the engine keeps it).
const insertTypes = $derived(
loc && loc.path.length > 1
? insertableBlockTypes().filter((t) => t.id !== 'columns')
: insertableBlockTypes()
);
function doInsertType(bt: PalabrasBlockType) {
// With a block active: insert as its next sibling. At DOCUMENT level (no
// active block — the Documento crumb): append at the end of the document,
// the way OUT of a nested level (cell / column) to root-level insertion.
const blockPath = loc
? [...loc.path.slice(0, -1), loc.path[loc.path.length - 1] + 1]
: [api.document.children.length];
const id = crypto.randomUUID();
api.applyCommand({
type: 'insertBlockAtPath',
blockPath,
block: { ...blockToInsert(bt, ts), id }
});
const parentSegs = loc ? (api.activeBlockId ?? '').split('/').slice(0, -1) : [];
api.setActiveBlock([...parentSegs, id].join('/'));
}
// VISUAL.md R8 — a pills row STACKS (label above, full-width control) when its
// options can't fit the control column: more than 4 options, or text-only
// pills whose summed labels run long. Icon/glyph pills stay compact.
function stackPills(field: PalabrasFieldDef): boolean {
const opts = field.options ?? [];
if (opts.length > 4) return true;
if (opts.some((o) => o.icon || o.glyph)) return false;
const chars = opts.reduce((n, o) => n + (o.label.split('|').pop() ?? o.label).length, 0);
return chars > 18;
}
</script>
{#snippet fieldControl(field: PalabrasFieldDef)}
{@const disabled = field.gatedBy ? !!valueOf(field.gatedBy) : false}
{#if field.type === 'pills'}
<div
data-palabras-field
data-stack={stackPills(field) ? '' : undefined}
data-disabled={disabled ? '' : undefined}
>
<span data-palabras-field-label>{ts(field.label)}</span>
<PalabrasSegmented
value={pillsActive(field)}
options={tsOptions(field)}
{disabled}
deselectable={!field.required}
ariaLabel={ts(field.label)}
onValueChange={(s) => onPills(field, s)}
/>
</div>
{:else if field.type === 'toggle'}
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{ts(field.label)}</span>
<Switch
size="xs"
{disabled}
checked={!!valueOf(field.key)}
onCheckedChange={(v) => commit(field, v || undefined)}
aria-label={ts(field.label)}
/>
</div>
{:else if field.type === 'number'}
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{ts(field.label)}</span>
<NumberField
size="xs"
value={numOrUndef(field)}
min={field.min}
max={field.max}
step={field.step ?? 1}
{disabled}
onValueChange={(v) => onNumberChange(field, v)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
{:else if field.type === 'slider'}
<div data-palabras-field data-stack data-slider data-disabled={disabled ? '' : undefined}>
<div data-palabras-slider-head>
<span data-palabras-field-label>{ts(field.label)}</span>
<span data-palabras-slider-value>{sliderValue(field)}{field.unit ?? ''}</span>
</div>
<Slider
size="xs"
{disabled}
value={[sliderValue(field)]}
min={field.min ?? 0}
max={field.max ?? 100}
step={field.step ?? 1}
onValueChange={(v) => commit(field, v[0])}
aria-label={ts(field.label)}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>
{:else if field.type === 'length'}
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{ts(field.label)}</span>
<CssField
size="xs"
value={lengthValue(field)}
allowedUnits={field.units ? [...field.units] : undefined}
keywords={['auto']}
allowNegative={false}
min={field.min}
max={field.max}
step={field.step}
{disabled}
onValueChange={(v) => commit(field, v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder={field.placeholder ? ts(field.placeholder) : 'auto'} />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
{:else if field.type === 'textarea'}
<div data-palabras-field data-stack>
<span data-palabras-field-label>{ts(field.label)}</span>
<TextArea
size="xs"
value={blockText()}
onValueChange={(v) => writeBlockText(v)}
autosize
minRows={2}
maxRows={6}
>
<TextArea.Input placeholder={field.placeholder ? ts(field.placeholder) : undefined} />
</TextArea>
</div>
{:else if field.type === 'text'}
<div data-palabras-field data-stack={field.wide ? '' : undefined}>
<span data-palabras-field-label>{ts(field.label)}</span>
<Field size="xs" variant="surface">
<Field.Control>
<Field.Input
type="text"
value={String(valueOf(field.key) ?? '')}
placeholder={field.placeholder ? ts(field.placeholder) : undefined}
oninput={field.upload
? (e) => e.currentTarget.removeAttribute('data-invalid')
: (e) => commit(field, e.currentTarget.value)}
onchange={field.upload ? (e) => onSrcCommit(field, e.currentTarget) : undefined}
/>
{#if field.upload}
<Field.Suffix>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label={ts('#?components.palabras.action.upload|Subir imagen')}
title={ts('#?components.palabras.action.upload|Subir imagen')}
onclick={onUpload}
>
{#snippet icon()}<Upload />{/snippet}
</Button>
</Field.Suffix>
{/if}
</Field.Control>
</Field>
</div>
{:else if field.type === 'spacing'}
<!-- The control OWNS its header row (label + the single link toggle on the
trailing edge — the ratified mockup) — no external label span. -->
<div data-palabras-field data-stack>
<PalabrasSpacing
label={ts(field.label)}
value={effective(field) as WordsSpacing | undefined}
{disabled}
onValueChange={(s) => commit(field, s)}
/>
</div>
{:else if field.type === 'typography'}
<PalabrasTypography
value={typoValue()}
fonts={scheme.fonts ?? PALABRAS_FONTS}
defaultFontSize={blockDefaultPx}
{disabled}
onPatch={commitVisualPatch}
/>
{:else if field.type === 'adjustments'}
{@const enabled = schemeSubControls(scheme, kind, 'adjustments')}
<!-- No field label / extra frame: the composed control brings its own
per-slider labels — wrapping it read as a box-inside-a-box. -->
<ImageAdjustments
size="sm"
data-palabras-adjustments
adjustments={enabled === 'all' ? ADJ_ALL : (enabled as ImageAdjustmentKey[])}
value={adjustmentsValue()}
onValueChange={commitAdjustments}
/>
{:else if field.type === 'color'}
<PalabrasColorRow
label={ts(field.label)}
current={effectiveColor(field)}
presets={schemePaletteValues(scheme)}
onPick={(hex) => commit(field, hex)}
/>
{/if}
{/snippet}
<!-- The insert-block menu — shared by the block footer (insert AFTER the active
block) and the document state (APPEND at the end); `doInsertType` resolves
the destination from `loc`. -->
{#snippet insertMenu()}
<DropdownMenu>
<DropdownMenu.Trigger variant="ghost" size="xs" color="primary" data-words-external-tool>
{#snippet icon()}<Plus />{/snippet}
{ts('#?components.palabras.action.insert|Insertar bloque')}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="top"
align="center"
preventScroll={false}
data-palabras-handle-menu
data-palabras-insert-grid
data-words-external-tool
>
{#each insertTypes as bt (bt.id)}
{@const Icon = bt.icon}
<DropdownMenu.Item onSelect={() => doInsertType(bt)}>
<Icon />
<span>{ts(bt.label)}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
{/snippet}
<FloatPanel
{open}
onOpenChange={(v) => !v && onClose()}
bind:position={panelPos}
anchor={anchorEl}
side="right"
align="center"
offset={16}
draggable
closeOnInteractOutside
variant="surface"
color="neutral"
defaultSize={{ width: PANEL_W, height: PANEL_H }}
>
<FloatPanel.Content data-palabras-panel data-words-external-tool>
{#if loc && schema}
<FloatPanel.Header closable={false}>
<span data-palabras-panel-head-main>
<span data-palabras-panel-icon style:color={schema.dotToken}><HeadIcon /></span>
<FloatPanel.Title>{ts(schema.label)}</FloatPanel.Title>
</span>
<FloatPanel.Controls>
<FloatPanel.Action
aria-label={ts(
'#?components.palabras.action.reset-block|Restablecer estilo del bloque'
)}
title={ts(
'#?components.palabras.action.reset-block-title|Restablecer todo a los valores por defecto'
)}
disabled={!isModified(allDesignKeys)}
onclick={() => resetKeys(allDesignKeys)}
>
<RotateCcw />
</FloatPanel.Action>
<FloatPanel.Action
aria-label={ts('#?components.palabras.action.delete|Eliminar bloque')}
disabled={!canDelete}
onclick={deleteActive}
>
<Trash2 />
</FloatPanel.Action>
</FloatPanel.Controls>
</FloatPanel.Header>
<!-- The ancestry AT the point of use (VISUAL.md G6): from a paragraph
inside a cell, Celda / Fila / Tabla are one click away RIGHT HERE —
no trip to the breadcrumb above the document. Ancestor crumbs
activate their node (the panel follows); the current node is inert
ink; the document crumb is context, not an action. -->
{#if loc && loc.ancestry.length > 1}
<nav
data-palabras-panel-crumbs
aria-label={ts('#?components.palabras.crumb.path|Ruta del bloque')}
>
{#each loc.ancestry as node, i (node.idPath)}
{#if i > 0}<span data-palabras-panel-crumb-sep aria-hidden="true">›</span>{/if}
{#if i === loc.ancestry.length - 1}
<span data-palabras-panel-crumb data-current>
{crumbLabel(node.kind, node.index, ts)}
</span>
{:else}
<!-- Documento activates the DOCUMENT level (no block): the panel
switches to its document state, whose insert appends at the
end — the way OUT of a nested cell / column. -->
<button
type="button"
data-palabras-panel-crumb
onclick={() => api.setActiveBlock(node.kind === 'document' ? null : node.idPath)}
>
{crumbLabel(node.kind, node.index, ts)}
</button>
{/if}
{/each}
</nav>
{/if}
<FloatPanel.Body>
<Tabs
value={tab}
onValueChange={(v) => (tab = v)}
variant="segmented"
size="sm"
fitted
data-palabras-tabs
>
<Tabs.List aria-label={ts('#?components.palabras.action.sections|Secciones del bloque')}>
<Tabs.Trigger value="contenido"
>{ts('#?components.palabras.tab.content|Contenido')}</Tabs.Trigger
>
<Tabs.Trigger value="diseño"
>{ts('#?components.palabras.tab.design|Diseño')}</Tabs.Trigger
>
<Tabs.Trigger value="avanzado"
>{ts('#?components.palabras.tab.advanced|Avanzado')}</Tabs.Trigger
>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="contenido">
<div data-palabras-panel-fields>
{#each contenidoFields as field (field.key)}
{@render fieldControl(field)}
{/each}
</div>
</Tabs.Content>
<Tabs.Content value="diseño">
{#if disenoSections.length}
<Accordion
type="single"
collapsible
bind:value={designOpen}
variant="ghost"
size="sm"
data-palabras-design
>
{#each disenoSections as section (section.title)}
<Accordion.Item value={section.title}>
<Accordion.Header level={3}>
<Accordion.Trigger>{ts(section.title)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-palabras-panel-fields>
{#if isModified(sectionKeys(section))}
<div data-palabras-section-reset-row>
<Button
variant="ghost"
size="xs"
iconOnly
data-palabras-section-reset
aria-label="{ts(
'#?components.palabras.action.reset-section|Restablecer'
)} {ts(section.title)}"
title={ts(
'#?components.palabras.action.reset-section-title|Restablecer la sección a los valores por defecto'
)}
onclick={() => resetKeys(sectionKeys(section))}
>
{#snippet icon()}<RotateCcw />{/snippet}
</Button>
</div>
{/if}
{#each section.fields as field (field.key)}
{@render fieldControl(field)}
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion>
{:else}
<div data-palabras-panel-fields>
<p data-palabras-tab-empty>{ts('#?components.palabras.panel.pending|Pendiente')}</p>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="avanzado">
<div data-palabras-panel-fields>
{#if avanzadoFields.length}
{#each avanzadoFields as field (field.key)}
{@render fieldControl(field)}
{/each}
{:else}
<p data-palabras-tab-empty>{ts('#?components.palabras.panel.pending|Pendiente')}</p>
{/if}
</div>
</Tabs.Content>
</Tabs>
</FloatPanel.Body>
<!-- Footer — per-block actions in ONE place (the gutter no longer opens a
menu): Mover arriba · Insertar bloque · Mover abajo. The insert opens
the SAME block-type grid the gutter's submenu used
(`data-palabras-insert-grid` + `insertableBlockTypes`). Delete lives in
the header. Structural nodes (cell / row / column) don't reorder as
blocks — no footer for them. -->
{#if !isStructural}
<div data-palabras-panel-foot>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={!canMoveUp}
aria-label={ts('#?components.palabras.action.move-up|Mover arriba')}
title={ts('#?components.palabras.action.move-up|Mover arriba')}
onclick={doMoveUp}
>
{#snippet icon()}<ArrowBigUp />{/snippet}
</Button>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={!canMoveDown}
aria-label={ts('#?components.palabras.action.move-down|Mover abajo')}
title={ts('#?components.palabras.action.move-down|Mover abajo')}
onclick={doMoveDown}
>
{#snippet icon()}<ArrowBigDown />{/snippet}
</Button>
{@render insertMenu()}
</div>
{/if}
{:else if !loc}
<!-- DOCUMENT level (Documento crumb / no active block): a real state, not
a dead end — its insert APPENDS at the end of the document, the way
OUT of a nested cell / column to root-level insertion. -->
<FloatPanel.Header closable={false}>
<span data-palabras-panel-head-main>
<FloatPanel.Title>
{ts('#?components.palabras.crumb.document|Documento')}
</FloatPanel.Title>
</span>
</FloatPanel.Header>
<FloatPanel.Body>
<p data-palabras-panel-empty>
{ts(
'#?components.palabras.panel.document|Inserta bloques al final del documento, o activa un bloque para editarlo'
)}
</p>
</FloatPanel.Body>
<div data-palabras-panel-foot>
{@render insertMenu()}
</div>
{:else}
<FloatPanel.Body>
<p data-palabras-panel-empty>
{ts('#?components.palabras.panel.no-props|Este elemento no tiene propiedades editables')}
</p>
</FloatPanel.Body>
{/if}
</FloatPanel.Content>
</FloatPanel>

@ -1,69 +0,0 @@
<script lang="ts">
/**
* Reusable segmented control for the palabras panel — a single-select
* `ToggleGroup` with the demo's appearance (`solid` + `attached`, `sm`).
* Each option renders as an ICON (icon-only, the label becomes the accessible
* name + tooltip) when it carries an `icon`, otherwise as its text label.
*
* Every `pills` field routes through here (alignment with icons, fit with
* text, …). Palabras-scoped on purpose: it bundles panel-specific defaults,
* it is NOT a new framework primitive — the framework `ToggleGroup` stays
* untouched and we just compose it.
*/
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import type { PalabrasFieldOption } from './types';
let {
value,
options,
disabled = false,
deselectable = true,
ariaLabel,
onValueChange
}: {
/** Currently selected option value (stringified). */
value: string | undefined;
readonly options: readonly PalabrasFieldOption[];
disabled?: boolean;
/** When `false`, a selection is REQUIRED: re-pressing the active option does
* not clear it (radio-like). @default true */
deselectable?: boolean;
ariaLabel?: string;
/** Fired with the picked option's stringified value, or `undefined` when the
* active item is re-clicked (deselected, only when `deselectable`) — lets the
* field clear / restore. */
onValueChange: (value: string | undefined) => void;
} = $props();
</script>
<ToggleGroup
selectionMode="single"
variant="solid"
color="primary"
size="xs"
attached
{disabled}
{deselectable}
aria-label={ariaLabel}
value={value == null ? [] : [value]}
onValueChange={(v) => onValueChange(v[0])}
data-palabras-segmented
>
{#each options as opt (opt.value)}
{@const Icon = opt.icon}
<ToggleGroup.Item
value={String(opt.value)}
iconOnly={!!Icon || !!opt.glyph}
aria-label={opt.label}
title={opt.label}
>
{#if Icon}
<Icon />
{:else if opt.glyph}
<span data-palabras-glyph style={opt.glyphStyle}>{opt.glyph}</span>
{:else}
{opt.label}
{/if}
</ToggleGroup.Item>
{/each}
</ToggleGroup>

@ -1,158 +0,0 @@
<script lang="ts">
/**
* Margin / box-spacing control for the palabras panel — ONE attached group of
* four px cells (Arriba · Derecha · Abajo · Izquierda, the CSS order) with
* their captions BELOW and a single LINK toggle at the end (the reference
* inspector pattern: Elementor / Figma).
*
* LINKED (default while no per-side key is set and both axis keys agree):
* editing any cell writes BOTH axis keys (`block` + `inline`) so the four
* sides share the value. UNLINKED: each cell writes its own per-side key
* (`blockStart` / `blockEnd` / `inlineStart` / `inlineEnd`). The engine
* resolves `blockStart ?? block`, etc.
*
* Link state is DATA-DERIVED — toggling writes data to flip it, so switching
* blocks resets it automatically (no stale local state).
*
* Palabras-scoped: NOT a framework primitive. The cells are native inputs on
* the panel's own input tokens (same precedent as `data-palabras-text`);
* values are raw px numbers (the engine's spacing model).
*/
import { Button } from '$uix/eidos/components/button';
import { Link2, Link2Off } from '$uix/eidos/components/icon';
import type { WordsSpacing } from '$soma/components/words';
let {
label,
value,
disabled = false,
onValueChange
}: {
/** Field label — the control OWNS its header row (label + link toggle). */
label: string;
/** Current spacing (raw px numbers, per axis and/or per side). */
value: WordsSpacing | undefined;
disabled?: boolean;
/** Fired with the FULL next spacing, or `undefined` when every side is cleared. */
onValueChange: (value: WordsSpacing | undefined) => void;
} = $props();
// Linked while NO per-side key is set and the two axis keys agree (both may
// be unset). Purely data-derived so block switches reset it for free.
const linked = $derived(
value?.blockStart == null &&
value?.blockEnd == null &&
value?.inlineStart == null &&
value?.inlineEnd == null &&
(value?.block ?? null) === (value?.inline ?? null)
);
// Resolved per-side values shown in each cell: per-side ?? axis.
const top = $derived(value?.blockStart ?? value?.block);
const right = $derived(value?.inlineEnd ?? value?.inline);
const bottom = $derived(value?.blockEnd ?? value?.block);
const left = $derived(value?.inlineStart ?? value?.inline);
function parse(raw: string): number | undefined {
const n = parseFloat(raw);
return Number.isFinite(n) && n >= 0 ? n : undefined;
}
/** Merge a patch into the current spacing, drop cleared (non-number) keys, and
* emit `undefined` when nothing remains (so the prop clears). */
function emit(patch: Partial<Record<keyof WordsSpacing, number | undefined>>) {
const merged: Record<string, number> = {};
for (const [k, v] of Object.entries({ ...value, ...patch })) {
if (typeof v === 'number') merged[k] = v;
}
onValueChange(Object.keys(merged).length ? (merged as WordsSpacing) : undefined);
}
const SIDE_KEYS = ['blockStart', 'inlineEnd', 'blockEnd', 'inlineStart'] as const;
function setSide(key: (typeof SIDE_KEYS)[number], raw: string) {
const n = parse(raw);
if (linked) {
emit({
block: n,
inline: n,
blockStart: undefined,
blockEnd: undefined,
inlineStart: undefined,
inlineEnd: undefined
});
} else {
emit({ [key]: n });
}
}
function toggleLink() {
if (linked) {
// Freeze the current resolved sides into per-side keys.
emit({
blockStart: top,
blockEnd: bottom,
inlineStart: left,
inlineEnd: right,
block: undefined,
inline: undefined
});
} else {
// Re-link on the first defined side (reference inspectors' convention).
const v = top ?? right ?? bottom ?? left;
emit({
block: v,
inline: v,
blockStart: undefined,
blockEnd: undefined,
inlineStart: undefined,
inlineEnd: undefined
});
}
}
const CELLS = $derived([
{ key: 'blockStart' as const, caption: 'Arriba', value: top },
{ key: 'inlineEnd' as const, caption: 'Derecha', value: right },
{ key: 'blockEnd' as const, caption: 'Abajo', value: bottom },
{ key: 'inlineStart' as const, caption: 'Izquierda', value: left }
]);
</script>
<div data-palabras-spacing data-disabled={disabled ? '' : undefined} role="group">
<!-- Header row: the field label + the SINGLE link toggle on the trailing
edge (the ratified mockup). -->
<div data-palabras-spacing-head>
<span data-palabras-field-label>{label}</span>
<Button
variant="ghost"
size="xs"
iconOnly
{disabled}
data-palabras-spacing-link
aria-pressed={linked}
aria-label={linked ? 'Desvincular lados' : 'Vincular lados'}
title={linked ? 'Vinculado — pulsa para separar' : 'Separado — pulsa para vincular'}
onclick={toggleLink}
>
{#snippet icon()}{#if linked}<Link2 />{:else}<Link2Off />{/if}{/snippet}
</Button>
</div>
{#each CELLS as cell (cell.key)}
<input
data-palabras-spacing-cell
type="text"
inputmode="numeric"
placeholder="0"
{disabled}
value={cell.value == null ? '' : String(cell.value)}
aria-label={cell.caption}
onchange={(e) => setSide(cell.key, e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
{/each}
{#each CELLS as cell (cell.key)}
<span data-palabras-spacing-side>{cell.caption}</span>
{/each}
</div>

@ -1,447 +0,0 @@
<script lang="ts">
/**
* Typography control bundle for the palabras panel — drives a TEXT block's
* typographic visual overrides (`setBlockVisualAtPath` keys: fontFamily /
* fontStyle / strikethrough / fontSize / fontWeight / lineHeight / letterSpacing /
* wordSpacing / textWrap / maxWidth / tabularNumbers).
*
* Every numeric field is a `<CssField>` whose `allowedUnits` constrain the value
* type PER PROPERTY (Tamaño→px, Interlineado→em, Tracking→px·em, Medida→ch·rem·px).
* Interlineado is unitless line-height under the hood: the field edits in `em`
* (which equals the unitless multiplier on a single block) and we store the bare
* number — so the engine's numeric `lineHeight` is untouched.
*
* EXCLUSION MODEL — one CSS property = one owner. The preset Select and the manual
* CssField drive the SAME prop: picking a preset writes the scale value, editing
* the field writes a raw value, and the Select shows the matching step (or a
* placeholder while a manual value owns the prop). One stored value, never two.
*
* Palabras-scoped (NOT a framework primitive): composes the framework Select /
* CssField / Switch / ToggleGroup, like PalabrasSpacing does.
*/
import { Select } from '$uix/eidos/components/select';
import { CssField } from '$uix/eidos/components/css-field';
import { Switch } from '$uix/eidos/components/switch';
import PalabrasSegmented from './palabras-segmented.svelte';
import type { PalabrasFontGroup, PalabrasFieldOption } from './types';
/** The typographic subset of a block's visual overrides. */
type Typo = {
fontFamily?: string;
fontStyle?: 'normal' | 'italic' | 'oblique';
strikethrough?: boolean;
fontSize?: number;
fontWeight?: number;
lineHeight?: number;
letterSpacing?: string;
wordSpacing?: string;
textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';
maxWidth?: string;
tabularNumbers?: boolean;
};
let {
value,
fonts,
defaultFontSize,
disabled = false,
onPatch
}: {
/** Current typographic visual values of the active block. */
value: Typo;
/** Grouped font options for the Familia dropdown (theme + schema fonts). */
fonts: readonly PalabrasFontGroup[];
/** The block's default font-size (px) — Tamaño rests here while `fontSize` is
* not overridden (i.e. the selected heading level's own size). */
defaultFontSize?: number;
disabled?: boolean;
/** Merge-patch into the block's visual; an `undefined` value clears the prop. */
onPatch: (patch: Partial<Typo>) => void;
} = $props();
// ── Theme scales — mirror generated/base.css (--leading-* / --tracking-* / --measure-*). ──
const LEADING_DEFAULT = '1.5'; // --leading-normal (the theme's resting leading).
const LEADING: PalabrasFieldOption[] = [
{ value: '1', label: 'Sólido' },
{ value: '1.15', label: 'Ceñido' },
{ value: '1.3', label: 'Justo' },
{ value: '1.5', label: 'Normal' },
{ value: '1.65', label: 'Cómodo' },
{ value: '2', label: 'Suelto' }
];
const TRACKING_DEFAULT = '0'; // --tracking-normal.
const TRACKING: PalabrasFieldOption[] = [
{ value: '-0.04em', label: 'Estrecho' },
{ value: '-0.02em', label: 'Ceñido' },
{ value: '0', label: 'Normal' },
{ value: '0.02em', label: 'Amplio' },
{ value: '0.05em', label: 'Ancho' }
];
const MEASURE: PalabrasFieldOption[] = [
{ value: '54ch', label: 'Estrecho' },
{ value: '66ch', label: 'Normal' },
{ value: '78ch', label: 'Ancho' }
];
// Self-demonstrating pills (VISUAL.md R4): each option shows `Aa` rendered in
// its own style — nothing to translate, nothing to clip. The label stays the
// accessible name + tooltip.
const STYLE_OPTS: PalabrasFieldOption[] = [
{ value: 'normal', label: 'Normal', glyph: 'Aa' },
{ value: 'italic', label: 'Cursiva', glyph: 'Aa', glyphStyle: 'font-style: italic' },
{ value: 'oblique', label: 'Oblicua', glyph: 'Aa', glyphStyle: 'font-style: oblique 14deg' }
];
// Tachada is an INDEPENDENT toggle (combines with any font-style), rendered as a
// single pill that matches the font-style pills' look.
const STRIKE_OPT: PalabrasFieldOption[] = [
{
value: 'strike',
label: 'Tachada',
glyph: 'Aa',
glyphStyle: 'text-decoration-line: line-through'
}
];
const WRAP_OPTS: PalabrasFieldOption[] = [
{ value: 'wrap', label: 'Normal' },
{ value: 'nowrap', label: 'Sin corte' },
{ value: 'balance', label: 'Equilibrar' },
{ value: 'pretty', label: 'Pulido' }
];
const WEIGHTS = [
{ value: 100, label: 'Fino' },
{ value: 200, label: 'Extrafino' },
{ value: 300, label: 'Ligero' },
{ value: 400, label: 'Normal' },
{ value: 500, label: 'Medio' },
{ value: 600, label: 'Semibold' },
{ value: 700, label: 'Negrita' },
{ value: 800, label: 'Extranegrita' },
{ value: 900, label: 'Negro' }
];
const WEIGHT_DEFAULT = '__default__';
// Preset Select selection: the matching theme step (or the default while unset);
// empty when a MANUAL value doesn't land on a step (the CssField owns the prop then).
const leadingSel = $derived.by(() => {
const v = value.lineHeight == null ? LEADING_DEFAULT : String(value.lineHeight);
return LEADING.some((o) => o.value === v) ? [v] : [];
});
const trackingSel = $derived.by(() => {
const v = value.letterSpacing ?? TRACKING_DEFAULT;
return TRACKING.some((o) => o.value === v) ? [v] : [];
});
const measureSel = $derived.by(() => {
const v = value.maxWidth;
return v && MEASURE.some((o) => o.value === v) ? [v] : [];
});
// Effective field values: the override, else the block default.
const sizeEffective = $derived(value.fontSize ?? defaultFontSize);
const leadingNum = $derived(value.lineHeight ?? Number(LEADING_DEFAULT));
const allFonts = $derived(fonts.flatMap((g) => g.fonts));
const fontValue = $derived(value.fontFamily ? [value.fontFamily] : []);
const weightValue = $derived(value.fontWeight != null ? [String(value.fontWeight)] : []);
const wrapValue = $derived(value.textWrap ? [value.textWrap] : []);
// ── Patch helpers (one prop each; `undefined`/falsy clears to the block default). ──
const setStr = (key: 'letterSpacing' | 'wordSpacing' | 'maxWidth', s: string | undefined) =>
onPatch({ [key]: s || undefined } as Partial<Typo>);
function pickFamily(v: string | undefined) {
onPatch({ fontFamily: v || undefined });
}
function pickWeight(v: string | undefined) {
onPatch({ fontWeight: v && v !== WEIGHT_DEFAULT ? Number(v) : undefined });
}
function pickStyle(v: string | undefined) {
onPatch({ fontStyle: (v as Typo['fontStyle']) || undefined });
}
function pickWrap(v: string | undefined) {
onPatch({ textWrap: (v as Typo['textWrap']) || undefined });
}
function pickLeading(v: string | undefined) {
onPatch({ lineHeight: v ? Number(v) : undefined });
}
function setSize(n: number | undefined) {
onPatch({ fontSize: n && n > 0 ? n : undefined });
}
/** Tamaño CssField (px) → numeric fontSize. */
function setSizeStr(s: string | undefined) {
const n = s ? parseFloat(s) : NaN;
setSize(Number.isFinite(n) ? n : undefined);
}
/** Interlineado CssField (em) → unitless numeric lineHeight (em ≡ multiplier here). */
function setLeadingStr(s: string | undefined) {
const n = s ? parseFloat(s) : NaN;
onPatch({ lineHeight: Number.isFinite(n) && n > 0 ? n : undefined });
}
/** Visible trigger text for a Select — the matched option's LABEL. (Select.Value
* shows the raw VALUE while its menu/labels are unmounted — i.e. when the value is
* set programmatically rather than picked — so we render the label ourselves.) */
function labelOf(
opts: readonly { value: string | number | boolean; label: string }[],
sel: string[]
): string | undefined {
return opts.find((o) => String(o.value) === sel[0])?.label;
}
</script>
<!-- Preset dropdown (canonical theme steps). Empty selection → placeholder, shown
while a MANUAL CssField value owns the prop. -->
{#snippet presetSelect(
sel: string[],
opts: readonly PalabrasFieldOption[],
onPick: (v: string | undefined) => void,
placeholder: string
)}
<Select
selectionMode="single"
size="xs"
variant="surface"
value={sel}
{disabled}
onValueChange={(v) => onPick(v[0])}
>
<Select.Trigger>
<span data-palabras-select-text data-empty={sel.length ? undefined : ''}
>{labelOf(opts, sel) ?? placeholder}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each opts as o (o.value)}
<Select.Item value={String(o.value)} label={o.label}>
<span>{o.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
{/snippet}
<div data-palabras-typography data-disabled={disabled ? '' : undefined}>
<!-- Fuente (font family) — one grid row: label | grouped dropdown (theme +
schema fonts; each option previews its own typeface). -->
<div data-palabras-field>
<span data-palabras-field-label>Fuente</span>
<Select
selectionMode="single"
size="xs"
variant="surface"
value={fontValue}
{disabled}
onValueChange={(v) => pickFamily(v[0])}
>
<Select.Trigger>
<span data-palabras-select-text data-empty={fontValue.length ? undefined : ''}
>{labelOf(allFonts, fontValue) ?? 'Por defecto'}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each fonts as group, gi (group.label)}
<Select.Group>
<Select.GroupHeading>{group.label}</Select.GroupHeading>
{#each group.fonts as f (f.value)}
<Select.Item value={f.value} label={f.label}>
<span style:font-family={f.value}>{f.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Group>
{#if gi < fonts.length - 1}<Select.Separator />{/if}
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<!-- Estilo — font-style (single-select) + an independent Tachada (strike) pill,
both rendered as text pills in the same style. -->
<div data-palabras-field>
<span data-palabras-field-label>Estilo</span>
<div data-palabras-type-style>
<PalabrasSegmented
value={value.fontStyle}
options={STYLE_OPTS}
{disabled}
ariaLabel="Estilo de fuente"
onValueChange={pickStyle}
/>
<PalabrasSegmented
value={value.strikethrough ? 'strike' : undefined}
options={STRIKE_OPT}
{disabled}
ariaLabel="Tachada"
onValueChange={(v) => onPatch({ strikethrough: v ? true : undefined })}
/>
</div>
</div>
<!-- Peso — one grid row: label | weight dropdown. -->
<div data-palabras-field>
<span data-palabras-field-label>Peso</span>
<Select
selectionMode="single"
size="xs"
variant="surface"
value={weightValue}
{disabled}
onValueChange={(v) => pickWeight(v[0])}
>
<Select.Trigger>
<span data-palabras-select-text data-empty={weightValue.length ? undefined : ''}
>{labelOf(WEIGHTS, weightValue) ?? 'Por defecto'}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
<Select.Item value={WEIGHT_DEFAULT} label="Por defecto">
<span>Por defecto</span>
<Select.ItemIndicator />
</Select.Item>
{#each WEIGHTS as w (w.value)}
<Select.Item value={String(w.value)} label={w.label}>
<span style:font-weight={w.value}>{w.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<!-- Tamaño — one grid row: label | manual px field. -->
<div data-palabras-field>
<span data-palabras-field-label>Tamaño</span>
<CssField
size="xs"
steppers="stacked"
value={sizeEffective != null ? `${sizeEffective}px` : undefined}
allowedUnits={['px']}
allowNegative={false}
min={0}
max={200}
{disabled}
onValueChange={setSizeStr}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Interlineado — label | (preset dropdown · manual em field) pair in the
control column (unitless under the hood). -->
<div data-palabras-field>
<span data-palabras-field-label>Interlineado</span>
<div data-palabras-type-pair>
{@render presetSelect(leadingSel, LEADING, pickLeading, 'Personalizado')}
<CssField
size="xs"
steppers="stacked"
value={`${leadingNum}em`}
allowedUnits={['em']}
allowNegative={false}
min={0.5}
max={3}
step={0.05}
{disabled}
onValueChange={setLeadingStr}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="1.5em" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Tracking — label | (preset dropdown · manual px·em field) pair. -->
<div data-palabras-field>
<span data-palabras-field-label>Tracking</span>
<div data-palabras-type-pair>
{@render presetSelect(
trackingSel,
TRACKING,
(v) => setStr('letterSpacing', v),
'Personalizado'
)}
<CssField
size="xs"
steppers="stacked"
value={value.letterSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('letterSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Espaciado — word-spacing (no preset); one grid row: label | manual field. -->
<div data-palabras-field>
<span data-palabras-field-label>Espaciado</span>
<CssField
size="xs"
steppers="stacked"
value={value.wordSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('wordSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Medida (measure) — label | (preset dropdown · manual ch·rem·px field) pair. -->
<div data-palabras-field>
<span data-palabras-field-label>Medida</span>
<div data-palabras-type-pair>
{@render presetSelect(measureSel, MEASURE, (v) => setStr('maxWidth', v), 'Sin límite')}
<CssField
size="xs"
steppers="stacked"
value={value.maxWidth}
allowedUnits={['ch', 'rem', 'px']}
allowNegative={false}
{disabled}
onValueChange={(v) => setStr('maxWidth', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Ajuste (Wrap) — one grid row: label | dropdown. -->
<div data-palabras-field>
<span data-palabras-field-label>Ajuste</span>
{@render presetSelect(wrapValue, WRAP_OPTS, pickWrap, 'Normal')}
</div>
<!-- Cifras tabulares — fixed-width figures (switch on the control edge). -->
<div data-palabras-field>
<span data-palabras-field-label>Cifras tabulares</span>
<Switch
size="xs"
color="primary"
checked={!!value.tabularNumbers}
{disabled}
onCheckedChange={(c) => onPatch({ tabularNumbers: c || undefined })}
/>
</div>
</div>

@ -1,154 +0,0 @@
<script lang="ts">
/**
* `<palabras>` — the semantic-panel editor (eidos).
*
* Reuses the Words block engine + soma `Words.Provider` (document model,
* selection, contenteditable). The editor is a clean FULL-WIDTH surface; the
* properties panel is a floating window TRIGGERED by a block grip (⋮⋮) and
* dismissed with Esc / click-away — never a permanent docked sidebar. Chrome
* is composed from our UIX (Flex · Card · Breadcrumb · ToggleGroup · Switch).
*
* `schemeStyle` (the look) and `saveas` (export format) are the two palabras
* knobs; everything else flows through to the provider. Pilot block: heading.
*/
import { untrack } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import * as Words from '$soma/components/words';
import { Card } from '$uix/eidos/components/card';
import { Flex } from '$uix/eidos/components/flex';
import WordsActivate from './palabras-activate.svelte';
import WordsBlockDrag from './palabras-block-drag.svelte';
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
import PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte';
import PalabrasPanel from './palabras-panel.svelte';
import { resolvePalabrasScheme, schemeCssVars } from './palabras-scheme';
import type { PalabrasProps } from './types';
let {
value = $bindable(),
schemeStyle = 'default',
saveas = 'json',
...headlessProps
}: PalabrasProps = $props();
const eidos = ActiveEidos.require();
const dom = eidos.dom;
// The document's contract (fonts / scale / palette / editable / styles),
// from a registered name or an inline object. Projected as CSS vars on
// the doc root — the document renders from the SCHEME, never from the
// host page's theme (back-office doctrine).
const scheme = $derived(resolvePalabrasScheme(schemeStyle));
const schemeVars = $derived(schemeCssVars(scheme));
const schemeName = $derived(typeof schemeStyle === 'string' ? schemeStyle : 'custom');
// Rich-text contenteditable is client-only (the engine stamps content ids with
// `crypto.randomUUID()` → SSR ≠ client markup). Defer to the client.
let mounted = $state(false);
$effect(() => {
untrack(() => {
mounted = true;
});
});
let contentEl = $state<HTMLDivElement | null>(null);
// The doc row (rail + canvas). Passed to WordsBlockDrag as its drop zone so a
// straight-down drag over the rail still fires dragover (the rail is a
// separate column from content; listening only on content forced a rightward
// veer to land the drop).
let docEl = $state<HTMLDivElement | null>(null);
// The properties panel is TRIGGERED (block grip) and dismissed (Esc /
// click-away) — hidden by default so the editor is a clean full-width surface.
let panelOpen = $state(false);
</script>
{#if mounted}
<Words.Provider
{...headlessProps}
bind:value
data-palabras
data-scheme-style={schemeName}
data-saveas={saveas}
>
{#snippet children(api)}
<Flex direction="column" rowGap={4} data-palabras-shell>
<PalabrasBreadcrumb {api} onOpen={() => (panelOpen = true)} />
<div data-palabras-stage>
<!-- `data-no-emerge` opts out of the Card's entrance animation. The
residual transform from `card-emerge` would otherwise make the
Card a containing block, breaking the Words drop-line seam's
frame-relative anchor. -->
<Card size="lg" data-palabras-doc-card data-no-emerge>
<div data-palabras-doc bind:this={docEl} style={schemeVars}>
<!-- Left RAIL — a real column (its OWN div with width), not padding.
Holds the block grip; the canvas (content) sits to its right. -->
<div data-palabras-rail>
{#if contentEl}
<PalabrasHandle
{api}
content={contentEl}
{dom}
{panelOpen}
onOpen={(id) => {
api.setActiveBlock(id);
panelOpen = true;
}}
onClose={() => {
panelOpen = false;
}}
/>
{/if}
</div>
<div data-palabras-canvas>
<Words.Content bind:ref={contentEl} />
<Words.Placeholder />
{#if contentEl}
<WordsActivate
{api}
content={contentEl}
{dom}
{panelOpen}
onPanelClose={() => (panelOpen = false)}
onActivate={(_id, type) => {
// An atomic image can't be edited inline — the inspector IS
// its interaction, so a click opens it.
if (type === 'image') {
panelOpen = true;
}
}}
/>
<!-- Drop sink for block-reorder drags. `dropZone={docEl}` makes
it listen on the whole row (rail + canvas) so a straight-down
drag over the rail still fires dragover; geometry comes from
`content`. Source is the grip in PalabrasHandle. -->
<WordsBlockDrag {api} content={contentEl} {dom} dropZone={docEl} />
<!-- A "+" over each EMPTY column → the block-type grid → inserts into
that column (Words' `insertBlockInColumn`). -->
<PalabrasColumnInserter {api} content={contentEl} {dom} />
{/if}
</div>
</div>
</Card>
<!-- The SINGLE properties panel — a floating <FloatPanel> anchored to
the active block. Structure + style live in ONE panel (Tipografía
direct for text, the rest in the accordion). Owns its show/hide,
portal and dismissal (click-outside / Esc). -->
<PalabrasPanel
{api}
{scheme}
content={contentEl}
open={panelOpen}
onClose={() => (panelOpen = false)}
/>
</div>
<p data-palabras-help>
haz clic en un bloque y pulsa su tirador <kbd>⋮</kbd> para ver sus propiedades · arrastra
la cabecera para moverlo · clic fuera o <kbd>Esc</kbd> para cerrar
</p>
</Flex>
{/snippet}
</Words.Provider>
{:else}
<div data-palabras data-scheme-style={schemeName} aria-busy="true"></div>
{/if}

@ -1,66 +1,14 @@
<script lang="ts">
// palabras — pilot demo. Proves the spine: a clean-slate editor on the Words
// block engine, chrome composed from our UIX (Flex · Card · Breadcrumb ·
// ToggleGroup). Click a heading, then change its level from the panel.
import Palabras from '$uix/eidos/components/palabras';
import type { PalabrasScheme } from '$uix/eidos/components/palabras';
import type { WordsDocument } from '$soma/components/words';
import { defaultWordsSchema } from '$soma/components/words';
import { createWordsEmbedSpec } from '$soma/components/words/plugins/embed';
// App-boot plugin registration (F4.5 open-plugin proof): pasting a
// YouTube/Vimeo/X/CodePen URL creates an embed block instead of a link.
if (!defaultWordsSchema.has('embed')) defaultWordsSchema.register(createWordsEmbedSpec());
// The document's SCHEME (F3.2) — an inline example of the full contract:
// admitted fonts, type scale, palette (ColorPicker presets), per-element
// editable whitelist (image offers ONLY these fields), element base styles.
// Partial: everything else fills from the solid default. The document
// renders from THIS — not from the docs-shell theme around it.
// The demo shows the FULL panel (no `editable` whitelist here — that
// capability is covered by tests; a curtailed demo reads as broken).
const esquema: PalabrasScheme = {
sizes: { h1: 34, h2: 27 },
palette: ['#1a1a2e', '#e94560', '#0f3460', '#16c79a', '#f5f5f5'],
styles: { quote: { color: '#0f3460', border: '3px solid #e94560', padding: 12 } },
// Designer-declared extra field (no code): persists as a free block prop.
fields: {
heading: [{ key: 'urgente', type: 'toggle', label: 'Urgente', tab: 'contenido' }]
}
};
let value = $state<WordsDocument>({
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Cómo migrar sin downtime' }] },
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'La estrategia depende del volumen de datos y de la ventana de mantenimiento disponible.'
}
]
},
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Estrategias' }] },
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Haz clic en un encabezado y cambia su nivel desde el panel de la derecha. Pulsa Esc para subir un nivel; haz clic en una miga del breadcrumb para saltar.'
}
]
}
]
});
// palabras — the visual layer was DELETED by user order (2026-07-15) to be
// rebuilt from scratch in ratified phases. The engine (soma/words), the
// scheme contract and the panel registry are intact; this route hosts the
// component again as soon as phase 1 of the rebuild lands.
</script>
<main style="padding: 2rem; max-inline-size: 64rem; margin-inline: auto;">
<h1 style="margin-block-end: 0.5rem;">palabras — piloto</h1>
<p style="margin-block-end: 1.5rem; color: var(--color-content-secondary);">
Fase 1: panel generado desde esquema sobre el bloque <code>heading</code>, montado con nuestras
piezas (Card · Breadcrumb · Flex · ToggleGroup).
<h1 style="margin-block-end: 0.5rem;">palabras — en reconstrucción</h1>
<p style="color: var(--color-content-secondary);">
La capa visual del componente fue eliminada para reconstruirse desde cero por fases
ratificadas. El motor, el contrato del esquema y el registro de paneles siguen intactos.
</p>
<Palabras bind:value schemeStyle={esquema} />
</main>

Loading…
Cancel
Save

Powered by TurnKey Linux.