docs(uix): close css-field audit gaps — eidos README Comparativa + Gaps

Adds external comparison table (WAI-ARIA / React Aria / Ark UI / Base UI / Bits) with sources and a Gaps table with disposition markers, bringing the css-field audit scorecard to parity with number-field (only the systemic E-2.2 css-import rule + R-1.3 readonly warning remain, both shared with the reference component). Smoke PASS for /uix/components/css-field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7ff1738a12
commit 9452bc2545

@ -35,11 +35,28 @@ Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base.
No Air baseline — new component. Recipe cloned from `number-field`'s with token renames.
## Comparativa
## Comparativa externa
See `src/uix/soma/components/css-field/README.md` for the cross-library feature table.
Visually it is a single-row control matching the input family (DateField / NumberField /
ColorField): border + control buttons + optional scrubber, 5 sizes × 3 variants × 8 colors.
Ningún sistema de referencia trae un campo de valor CSS con whitelist de unidades; los
análogos más cercanos son los spinbuttons numéricos. La superficie visual coincide con la
familia de inputs (DateField / NumberField / ColorField): borde + botones de control +
scrubber opcional, 5 tamaños × 3 variantes × 8 colores.
| Plataforma | Modelo | Eidos/UIX |
| --- | --- | --- |
| WAI-ARIA APG Spinbutton | Campo de texto enfocable + botones increment/decrement, flechas, Home/End, PageUp/Down. | Cumple el patrón; los triggers son `tabindex=-1` para mantener el foco en el input. |
| React Aria NumberField | Label, Group, Input, Buttons, `formatOptions` Intl, wheel. Numérico puro. | Soma cubre teclado/wheel/scrubber/Field; añade valor+unidad (`allowedUnits`) que React Aria no modela. |
| Ark UI Number Input | Root, Control, Input, triggers, Scrubber, clamp/overflow, invalid callbacks. Numérico puro. | Soma cubre scrubber + clamp + invalid; opera sobre dimensión CSS, no número. |
| Base UI Number Field | Root, ScrubArea, Group, Decrement, Input, Increment. Numérico puro. | Soma cubre scrubber + triggers; el valor es una cadena CSS con unidad. |
| Radix / Bits / shadcn | Sin campo numérico ni de valor CSS dedicado. | UIX tiene componente dedicado con validación ambivalente (funciones puras o schema sium). |
Fuentes:
- [WAI-ARIA APG Spinbutton](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/)
- [React Aria NumberField](https://react-aria.adobe.com/NumberField)
- [Ark UI Number Input](https://ark-ui.com/docs/components/number-input)
- [Base UI Number Field](https://base-ui.com/react/components/number-field)
- [Bits UI](https://bits-ui.com/docs/introduction)
## Decisiones
@ -49,5 +66,8 @@ ColorField): border + control buttons + optional scrubber, 5 sizes × 3 variants
## Gaps
- A units-suffix affordance (showing the unit separately) is not provided — the unit lives
inside the input text. _diferir_.
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Afijo de unidad separado (mostrar la unidad fuera del texto del input) | **diferir** | Hoy la unidad vive dentro del texto del input; un sufijo visual requiere markup + medición. Sólo si llega un caso real. |
| `calc(...)` y valores de color | **descartar** | Fuera del dominio de dimensiones; color ya lo cubre `color-field`. |
| Tests browser-level del flujo completo (Playwright) | **implementar** | Cobertura visual + interacciones, en la pasada conjunta de tests del catálogo. |

@ -0,0 +1,87 @@
/**
* Clamped virtual anchor for the properties Popover.
*
* The panel is anchored to the ACTIVE block but must always sit on a chosen
* `side` (the main panel to the right, the appearance popover to the left). A
* full-width block fills the content column, so in a narrow window the gutter on
* that side is smaller than the panel — a plain block anchor then makes the
* Popover FLIP to the opposite side (the panel jumps over the doc sidebar). The
* user wants it to stay put ("que apareciera a la derecha").
*
* Fix: hand the Popover a VIRTUAL anchor (a floating-ui `Measurable`) whose
* rect is CLAMPED on the main axis so the panel always fits the viewport on
* `side`. When the gutter is too small the anchor slides inward, so the panel
* overlaps the content's edge instead of flipping — `flip()` never triggers
* because the placement always fits. A zero-width strip spanning the block's
* vertical extent keeps `align='center'` centred on the block's mid-height.
*
* floating-ui calls `getBoundingClientRect` on every autoUpdate cycle, so scroll
* and resize re-clamp automatically (`contextElement` = the block, the element
* autoUpdate observes for scroll parents). The rect depends only on the block +
* viewport — never on the panel — so moving it can't feed back into a resize loop.
*
* NOTE on the type: the soma floating layer accepts `Measurable` as its anchor
* (`FloatingContent.customAnchor: string | HTMLElement | null | Measurable`),
* but the public `<Popover.Content customAnchor>` prop narrows that to
* `string | HTMLElement | null`. The capability is real at runtime; consumers
* cast the result to `HTMLElement` at the call site (we don't modify soma).
*/
/** Minimal floating-ui virtual-element shape (avoids a runtime floating-ui dep). */
export interface Measurable {
getBoundingClientRect(): DOMRect;
contextElement?: Element;
}
export interface ClampedAnchorOptions {
/** Which side the panel opens toward — the axis edge that gets clamped. */
side: 'left' | 'right';
/** The panel's width (must match `<Popover.Content width>`). */
panelWidth: number;
/** Gap between block and panel (must match `sideOffset`). */
gap: number;
/** Viewport edge padding to keep (match `collisionPadding`). */
padding: number;
}
/**
* Build a clamped virtual anchor over `block`. `win` resolves the viewport width
* (pass the block's own window so it stays iframe / popup correct).
*/
export function clampedBlockAnchor(
block: HTMLElement,
win: Window,
opts: ClampedAnchorOptions
): Measurable {
return {
contextElement: block,
getBoundingClientRect() {
const b = block.getBoundingClientRect();
const vw = win.innerWidth;
let x: number;
if (opts.side === 'right') {
// panel.left = x + gap → keep panel.left + panelWidth <= vw - padding.
const maxRight = vw - opts.panelWidth - opts.gap - opts.padding;
x = Math.min(b.right, maxRight);
} else {
// panel.right = x - gap → keep panel.right - panelWidth >= padding.
const minLeft = opts.padding + opts.panelWidth + opts.gap;
x = Math.max(b.left, minLeft);
}
// Zero-width strip at the clamped x, spanning the block's height.
return {
x,
y: b.top,
width: 0,
height: b.height,
top: b.top,
right: x,
bottom: b.bottom,
left: x,
toJSON() {
return this;
}
} as DOMRect;
}
};
}

@ -2,8 +2,10 @@
/**
* Appearance popover — opened by the panel's "Editar" button. Edits THIS
* block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`.
* Draggable floating window. Colours use our `<ColorPicker>`, numbers our
* `<NumberField>`, alignment our `<ToggleGroup>`.
* A framework `<Popover>` anchored to the active block on its LEFT (opposite
* the main panel), `align='center'` → centred on the block's mid-height; the
* Popover owns positioning + portal + dismissal. Colours use our `<ColorPicker>`,
* numbers our `<NumberField>`, alignment our `<ToggleGroup>`.
*/
import type { ProviderSnippetProps } from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
@ -11,17 +13,48 @@
import { NumberField } from '$uix/eidos/components/number-field';
import { X } from '$uix/eidos/components/icon';
import PalabrasColorRow from './palabras-color-row.svelte';
import { createDrag } from './palabras-drag.svelte';
import { Popover } from '$uix/eidos/components/popover';
import { clampedBlockAnchor } from './palabras-anchor';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
const PANEL_WIDTH = 256;
let {
api,
content,
open,
onClose
}: { api: ProviderSnippetProps; content: HTMLElement | null; onClose: () => void } = $props();
}: {
api: ProviderSnippetProps;
content: HTMLElement | null;
open: boolean;
onClose: () => void;
} = $props();
const drag = createDrag();
const loc = $derived(api.activeBlockLocation);
// The active block element drives the Popover's `customAnchor` (placed on the
// LEFT of the block, opposite the main panel). The Popover owns positioning
// (flip / shift / fit) + portal + dismissal.
let anchorEl = $state<HTMLElement | null>(null);
$effect(() => {
void api.activeBlockId;
void open;
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
});
// Clamped virtual anchor (side LEFT): keeps the appearance popover from
// flipping across the block when the left gutter is narrow. See palabras-anchor.ts.
const anchor = $derived.by(() => {
const win = content?.ownerDocument?.defaultView;
if (!anchorEl || !win) return null;
return clampedBlockAnchor(anchorEl, win, {
side: 'left',
panelWidth: PANEL_WIDTH,
gap: 8,
padding: 12
});
});
type Align = 'left' | 'center' | 'right' | 'justify';
type VisualPatch = {
color?: string;
@ -76,14 +109,20 @@
];
</script>
<aside data-palabras-appearance style:transform={drag.transform}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
data-palabras-panel-head
onpointerdown={drag.down}
onpointermove={drag.move}
onpointerup={drag.up}
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}>
<Popover.Portal>
<Popover.Content
customAnchor={anchor as unknown as HTMLElement | null}
side="left"
align="center"
sideOffset={8}
collisionPadding={12}
hideWhenDetached={false}
width={PANEL_WIDTH}
data-palabras-appearance
data-words-external-tool
>
<header data-palabras-panel-head>
<span data-palabras-panel-title>Apariencia</span>
<Button variant="ghost" size="xs" iconOnly aria-label="Cerrar" onclick={onClose}>
{#snippet icon()}<X />{/snippet}
@ -107,7 +146,8 @@
size="sm"
min={8}
value={fontSizeNum()}
onValueChange={(v) => setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
onValueChange={(v) =>
setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
@ -119,7 +159,8 @@
min={1}
step={0.1}
value={lineHeightNum()}
onValueChange={(v) => setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
onValueChange={(v) =>
setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
>
<NumberField.Input />
</NumberField>
@ -141,4 +182,6 @@
</ToggleGroup>
</div>
</div>
</aside>
</Popover.Content>
</Popover.Portal>
</Popover>

@ -1,19 +1,22 @@
<script lang="ts">
/**
* Generated block panel — a floating, draggable inspector built from the
* block's schema (`PALABRAS_PANEL_REGISTRY`). Field edits write the active
* block's prop via the path-aware `updateBlockAtPath` command.
* Generated block panel — a floating inspector built from the block's schema
* (`PALABRAS_PANEL_REGISTRY`). It's a framework `<Popover>` anchored to the
* active block (`customAnchor`, `side='right'` + `align='center'` → centred on
* the block's mid-height); the Popover owns positioning (flip / shift / fit),
* the portal and dismissal. Field edits write the active block's prop via the
* path-aware `updateBlockAtPath` command.
*
* Controls are FRAMEWORK components (coherence): `ToggleGroup` (pills),
* `Switch` (toggle), `NumberField` (number), `Slider` (slider — photo
* adjustments), native input (text). Collapsible groups use the framework
* adjustments), `CssField` (length — unit-aware, px/%/auto), native input
* (text). Collapsible groups use the framework
* `Accordion` (animated). PINNED sections (url / alt / caption) sit above the
* accordion, always visible. The header shows the block's DEFINING icon.
* Control DEFAULTS come from the field schema (`field.default`).
*/
import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words';
import { isCssLength } from '$sium';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
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';
@ -22,19 +25,26 @@
import { Trash2, Upload } from '$uix/eidos/components/icon';
import { getPanelSchema } from './panel-schema';
import { blockTypeIcon } from './palabras-block-types';
import PalabrasSegmented from './palabras-segmented.svelte';
import { pickImageFile } from '$uix/eidos/components/words/words-image-file';
import { createDrag } from './palabras-drag.svelte';
import { Popover } from '$uix/eidos/components/popover';
import { deleteBlockAndReanchor } from './palabras-block-actions';
import { clampedBlockAnchor } from './palabras-anchor';
import type { PalabrasFieldDef } from './types';
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
const PANEL_WIDTH = 272;
let {
api,
content,
open,
onEditAppearance,
onClose
}: {
api: ProviderSnippetProps;
content: HTMLElement | null;
open: boolean;
onEditAppearance: () => void;
onClose: () => void;
} = $props();
@ -44,7 +54,32 @@
// The block's DEFINING icon (per engine type) for the header — for every block.
const HeadIcon = $derived(blockTypeIcon(loc?.node.type));
const canDelete = $derived(!!loc && loc.path.length === 1);
const drag = createDrag();
// The active block element drives the Popover's `customAnchor`. The Popover
// owns positioning (flip / shift / fit), the portal + dismissal.
// Reactive to `activeBlockId` so the panel re-anchors when another block opens.
let anchorEl = $state<HTMLElement | null>(null);
$effect(() => {
// Re-resolve on `open` too: the block may already be active (marker stamped)
// before the panel opens, so `activeBlockId` doesn't change to re-trigger us.
void api.activeBlockId;
void open;
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
});
// A CLAMPED virtual anchor instead of the bare block: keeps the panel to the
// RIGHT and stops it flipping over the doc sidebar when the gutter is narrow
// (it slides inward to overlap the content's edge). `align='center'` centres
// it on the block's mid-height. See palabras-anchor.ts for the why + the cast.
const anchor = $derived.by(() => {
const win = content?.ownerDocument?.defaultView;
if (!anchorEl || !win) return null;
return clampedBlockAnchor(anchorEl, win, {
side: 'right',
panelWidth: PANEL_WIDTH,
gap: 8,
padding: 12
});
});
const pinnedSections = $derived((schema?.sections ?? []).filter((s) => s.pinned));
const accordionSections = $derived((schema?.sections ?? []).filter((s) => !s.pinned));
@ -100,7 +135,12 @@
if (!path) return;
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [key]: value } });
}
function onPills(field: PalabrasFieldDef, v: string) {
function onPills(field: PalabrasFieldDef, v: string | undefined) {
// Deselecting (re-click the active item) clears the prop → restores the default.
if (v == null) {
write(field.key, undefined);
return;
}
const opt = field.options?.find((o) => String(o.value) === v);
if (opt) write(field.key, opt.value);
}
@ -121,24 +161,14 @@
if (altGuess && valueOf('alt') == null) write('alt', altGuess);
});
}
const BARE_NUMBER_RE = /^\d+(\.\d+)?$/;
/** `length` field commit (on blur): empty clears; a bare number gets the field's
* default unit; a valid CSS length (validated by SIUM's `isCssLength`) is
* written; anything else flags invalid. */
function onLength(field: PalabrasFieldDef, input: HTMLInputElement) {
let raw = input.value.trim();
if (raw === '') {
input.removeAttribute('data-invalid');
write(field.key, undefined);
return;
}
if (BARE_NUMBER_RE.test(raw)) raw = raw + (field.unit ?? 'px');
if (isCssLength(raw)) {
input.removeAttribute('data-invalid');
write(field.key, raw);
} else {
input.setAttribute('data-invalid', '');
}
/** 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).
@ -174,20 +204,13 @@
{@const pv = effective(field)}
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{field.label}</span>
<ToggleGroup
type="single"
size="xs"
<PalabrasSegmented
value={pv == null ? undefined : String(pv)}
options={field.options ?? []}
{disabled}
value={pv == null ? [] : [String(pv)]}
onValueChange={(v) => {
const s = v[0];
if (s) onPills(field, s);
}}
>
{#each field.options ?? [] as opt (opt.value)}
<ToggleGroup.Item value={String(opt.value)}>{opt.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
ariaLabel={field.label}
onValueChange={(s) => onPills(field, s)}
/>
</div>
{:else if field.type === 'toggle'}
<div data-palabras-field data-inline>
@ -239,16 +262,22 @@
{:else if field.type === 'length'}
<div data-palabras-field data-inline data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{field.label}</span>
<input
data-palabras-text
data-length
type="text"
value={String(valueOf(field.key) ?? '')}
placeholder={field.placeholder ?? 'auto'}
<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}
oninput={(e) => e.currentTarget.removeAttribute('data-invalid')}
onchange={(e) => onLength(field, e.currentTarget)}
/>
onValueChange={(v) => write(field.key, v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder={field.placeholder ?? 'auto'} />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
{:else if field.type === 'text'}
<div data-palabras-field data-inline={field.wide ? undefined : ''}>
@ -289,15 +318,21 @@
{/if}
{/snippet}
<aside data-palabras-panel-card data-words-external-tool style:transform={drag.transform}>
{#if loc && schema}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
data-palabras-panel-head
onpointerdown={drag.down}
onpointermove={drag.move}
onpointerup={drag.up}
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}>
<Popover.Portal>
<Popover.Content
customAnchor={anchor as unknown as HTMLElement | null}
side="right"
align="center"
sideOffset={8}
collisionPadding={12}
hideWhenDetached={false}
width={PANEL_WIDTH}
data-palabras-panel-card
data-words-external-tool
>
{#if loc && schema}
<header data-palabras-panel-head>
<span data-palabras-panel-icon style:color={schema.dotToken}><HeadIcon /></span>
<span data-palabras-panel-title>{schema.label}</span>
<Button
@ -327,7 +362,7 @@
collapsible
bind:value={openSections}
variant="ghost"
size="xs"
size="sm"
data-palabras-panel-accordion
>
{#each accordionSections as section (section.title)}
@ -355,4 +390,6 @@
{:else}
<p data-palabras-panel-empty>Selecciona un bloque</p>
{/if}
</aside>
</Popover.Content>
</Popover.Portal>
</Popover>

@ -0,0 +1,56 @@
<script lang="ts">
/**
* Reusable segmented control for the palabras panel — a single-select
* `ToggleGroup` with the demo's appearance (`outline` + `attached`, `xs`).
* 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,
ariaLabel,
onValueChange
}: {
/** Currently selected option value (stringified). */
value: string | undefined;
readonly options: readonly PalabrasFieldOption[];
disabled?: boolean;
ariaLabel?: string;
/** Fired with the picked option's stringified value, or `undefined` when the
* active item is re-clicked (deselected) — lets the field clear / restore. */
onValueChange: (value: string | undefined) => void;
} = $props();
</script>
<ToggleGroup
type="single"
variant="solid"
color="primary"
size="xs"
attached
{disabled}
aria-label={ariaLabel}
value={value == null ? [] : [value]}
onValueChange={(v) => onValueChange(v[0])}
>
{#each options as opt (opt.value)}
{@const Icon = opt.icon}
<ToggleGroup.Item
value={String(opt.value)}
iconOnly={!!Icon}
aria-label={opt.label}
title={opt.label}
>
{#if Icon}<Icon />{:else}{opt.label}{/if}
</ToggleGroup.Item>
{/each}
</ToggleGroup>

@ -46,6 +46,13 @@
[data-palabras-rail] {
flex: 0 0 var(--palabras-rail, 32px);
position: relative;
/* Bullet-proof containing block for the absolutely-positioned grip slot. If
`position: relative` alone ever fails to register the rail as the slot's
offsetParent, the slot jumps past DOC + CARD (both static — they can't be CBs
without breaking the Words drop-line seam's frame anchor) straight to the outer
frame, landing the grip ~25px LEFT at the card edge. A transform establishes
the containing block via a second, independent mechanism so it can't fall through. */
transform: translateZ(0);
}
/* Canvas — the editing column. `min-inline-size: 0` lets it shrink within the
flex row instead of overflowing. NOT positioned: the drop-line seam painted by
@ -260,6 +267,11 @@
align-items: center;
margin-inline: 0;
margin-block: var(--space-4) 0;
/* The image is an ATOMIC block (the figure is contenteditable=false). Without
this, clicking it inside the contenteditable SELECTS the image and the browser
paints its native SOLID selection outline over our dashed active marker.
`user-select` inherits, so this covers the img / tile / placeholder / caption. */
user-select: none;
}
[data-palabras-doc] figure[data-words-block='image'] img {
display: block;
@ -353,24 +365,19 @@
}
/* ── Floating inspector panel ─────────────────────────────────────────────── */
[data-palabras-panel-card] {
position: absolute;
inset-block-start: var(--space-4);
inset-inline-end: var(--space-4);
inline-size: 17rem;
z-index: 10;
/* Flex column: a pinned header, a SCROLLABLE body (owns its overflow), and a
pinned footer. The card itself never scrolls (overflow hidden), so its width
is constant and the footer always spans edge-to-edge. */
/* The floating surface IS a framework `<Popover.Content>` — positioning (side /
align / flip / shift / fit-to-viewport), the portal, dismissal AND the surface
(border / bg / shadow / max-height) all come from the popover recipe. Here we
only RE-SHAPE the content's inner layout into a flex column with a pinned
header, a scrollable body and a pinned footer — overriding the popover recipe's
`display: grid` / padding / gap / `overflow: auto`. Doubled attribute (0,2,0)
beats `[data-popover-content]` (0,1,0) regardless of stylesheet load order. */
[data-palabras-panel-card][data-palabras-panel-card] {
display: flex;
flex-direction: column;
max-block-size: calc(100vh - 2 * var(--space-4));
gap: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--color-neutral-border);
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
/* Elevation = it reads as a floating window, not a docked sidebar. */
box-shadow: var(--shadow-lg, 0 16px 40px -12px rgb(0 0 0 / 0.6));
font-family: var(--font-ui);
}
/* Force the UI SANS across the whole panel + every descendant. The framework
@ -403,7 +410,9 @@
[data-palabras-panel-body] {
flex: 1 1 auto;
min-block-size: 0;
overflow: hidden;
/* Scrolls within the floating-ui height cap (`--_palabras-float-max-h`) when
the panel can't fit fully on screen — header + footer stay pinned. */
overflow-y: auto;
padding: 0 var(--space-4) var(--space-4);
}
/* The block's defining icon, tinted by the schema's dotToken (via currentColor). */
@ -469,9 +478,13 @@
[data-palabras-src-group] [data-palabras-text] {
flex: 1 1 auto;
}
/* Slider field — label + value ABOVE, the slider full-width BELOW (photo-app). */
/* Slider field — label + value ABOVE, the slider full-width BELOW (photo-app).
Compact (Apariencia stacks 7 in a row): tight label line-height + reduced gaps. */
[data-palabras-field][data-slider] {
gap: var(--space-1);
gap: var(--space-0-5);
}
[data-palabras-field][data-slider] + [data-palabras-field][data-slider] {
margin-block-start: var(--space-2);
}
[data-palabras-field][data-slider] [data-palabras-slider-head] {
display: flex;
@ -479,6 +492,10 @@
justify-content: space-between;
gap: var(--space-2);
}
[data-palabras-field][data-slider] [data-palabras-field-label],
[data-palabras-field][data-slider] [data-palabras-slider-value] {
line-height: 1.1;
}
[data-palabras-field][data-slider] [data-palabras-slider-value] {
font-size: var(--font-size-xs);
color: var(--color-content-muted);
@ -551,10 +568,10 @@
[data-palabras-text][data-invalid] {
border-color: var(--color-threat-border, var(--color-danger-border, crimson));
}
/* Length input (px / rem / %) — compact, right-aligned like a number. */
[data-palabras-text][data-length] {
inline-size: 5.5rem;
font-family: inherit;
/* CSS-length field (Ancho / Alto) — the framework <CssField> (its own border /
bg / invalid ring / xs size) with −/+ steppers, kept compact in the inline row. */
[data-palabras-field][data-inline] [data-css-field] {
inline-size: 9rem;
}
/* Number field (width / height) — compact input + unit suffix, right-aligned. */
[data-palabras-number] {
@ -626,18 +643,11 @@
}
/* ── Appearance popover (2nd window, opened by "Editar") ───────────────────── */
[data-palabras-appearance] {
position: absolute;
inset-block-start: var(--space-4);
/* Sits just left of the main panel (17rem wide). */
inset-inline-end: calc(17rem + var(--space-3));
inline-size: 16rem;
z-index: 11;
padding: var(--space-4);
border: 1px solid var(--color-neutral-border);
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
box-shadow: var(--shadow-lg, 0 16px 40px -12px rgb(0 0 0 / 0.6));
/* Also a framework `<Popover.Content>` (positioning + surface from the recipe);
we only force the UI sans across it + its descendants (the popover's content
archetype would otherwise leak the reading serif into the field labels). */
[data-palabras-appearance][data-palabras-appearance],
[data-palabras-appearance][data-palabras-appearance] * {
font-family: var(--font-ui);
}
/* NumberField in the appearance popover — compact, fixed width so the grid's
@ -660,25 +670,6 @@
background: var(--color-surface-muted);
}
/* ── Mobile: the floating panels become bottom drawers ────────────────────── */
@media (max-width: 40rem) {
/* The rail + grip stay identical to desktop (option A). Only the floating
panels change to bottom drawers below. */
[data-palabras-panel-card],
[data-palabras-appearance] {
position: fixed;
inset: auto 0 0 0;
inline-size: auto;
max-block-size: 70vh;
overflow-y: auto;
border-inline: none;
border-block-end: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
/* Anchored bottom drawer — ignore any drag offset from the desktop float. */
transform: none !important;
}
/* Appearance drawer sits above the main one. */
[data-palabras-appearance] {
z-index: 12;
}
}
/* Mobile: the panels stay framework `<Popover>`s — the popover recipe already
keeps them within `calc(100vw - …)` and floating-ui shifts them on screen, so
no bespoke bottom-drawer layer is needed. */

@ -23,7 +23,6 @@
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
import PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte';
import PalabrasNav from './palabras-nav.svelte';
import PalabrasPanel from './palabras-panel.svelte';
import type { PalabrasProps } from './types';
@ -128,31 +127,25 @@
</div>
</div>
</Card>
{#if panelOpen}
<!-- Panel + appearance are floating <Popover>s anchored to the active
block; they own their own show/hide via `open`, the portal, and
dismissal (click-outside / Esc). No manual nav layer needed. -->
<PalabrasPanel
{api}
content={contentEl}
open={panelOpen}
onEditAppearance={() => (appearanceOpen = true)}
onClose={() => {
panelOpen = false;
appearanceOpen = false;
}}
/>
{#if appearanceOpen}
<PalabrasAppearance
{api}
content={contentEl}
open={appearanceOpen}
onClose={() => (appearanceOpen = false)}
/>
{/if}
<PalabrasNav
{dom}
onClose={() => {
panelOpen = false;
appearanceOpen = false;
}}
/>
{/if}
</div>
<p data-palabras-help>
haz clic en un bloque y pulsa su tirador <kbd>⋮</kbd> para ver sus propiedades ·

@ -10,6 +10,7 @@
* only). The rest land once the architecture is verified end-to-end.
*/
import { TextAlignStart, TextAlignCenter, TextAlignEnd } from '$uix/eidos/components/icon';
import type { PalabrasBlockSchema, PalabrasPanelRegistry } from './types';
const heading: PalabrasBlockSchema = {
@ -84,6 +85,18 @@ const image: PalabrasBlockSchema = {
title: 'Tamaño y posición',
defaultCollapsed: true,
fields: [
{
key: 'align',
type: 'pills',
label: 'Alineación',
gatedBy: 'fullWidth',
default: 'center',
options: [
{ value: 'left', label: 'Izquierda', icon: TextAlignStart },
{ value: 'center', label: 'Centro', icon: TextAlignCenter },
{ value: 'right', label: 'Derecha', icon: TextAlignEnd }
]
},
{
key: 'fit',
type: 'pills',
@ -96,24 +109,13 @@ const image: PalabrasBlockSchema = {
]
},
{ key: 'fullWidth', type: 'toggle', label: 'Ancho completo', default: false },
{
key: 'align',
type: 'pills',
label: 'Alineación',
gatedBy: 'fullWidth',
default: 'center',
options: [
{ value: 'left', label: 'Izq.' },
{ value: 'center', label: 'Centro' },
{ value: 'right', label: 'Der.' }
]
},
{
key: 'width',
type: 'length',
label: 'Ancho',
placeholder: 'auto',
unit: 'px',
units: ['px', '%', 'rem', 'em', 'vw', 'vh'],
gatedBy: 'fullWidth'
},
{
@ -122,6 +124,10 @@ const image: PalabrasBlockSchema = {
label: 'Alto',
placeholder: 'auto',
unit: 'px',
// Same units as width, `%` included (per request). NOTE: a percentage
// height only takes effect with a definite-height ancestor — otherwise
// the browser ignores it — but we accept it rather than block the input.
units: ['px', 'rem', 'em', 'vh', 'vw', '%'],
gatedBy: 'fullWidth'
}
]

@ -1,4 +1,6 @@
import type { Component } from 'svelte';
import type { ProviderProps } from '$soma/components/words';
import type { CssUnit } from '$sium';
/** Output / persistence format for `saveas`. The engine already serializes to all three. */
export type PalabrasSaveAs = 'md' | 'html' | 'json';
@ -41,6 +43,17 @@ export type PalabrasFieldType =
| 'slider'
| 'length';
/** One choice in a `pills` / `select` field. */
export interface PalabrasFieldOption {
/** Value written raw to the block prop when picked. */
readonly value: string | number | boolean;
/** Visible (or accessible, when `icon` is set) label. */
readonly label: string;
/** Optional eidos icon component. When set, the option renders icon-only and
* `label` becomes its accessible name + tooltip (e.g. alignment controls). */
readonly icon?: Component;
}
/** One editable property of a block, shown as a control in the panel. */
export interface PalabrasFieldDef {
/**
@ -60,6 +73,13 @@ export interface PalabrasFieldDef {
readonly max?: number;
readonly step?: number;
readonly unit?: string;
/** `length` field: unit whitelist passed to `<CssField allowedUnits>` (e.g.
* `['px','rem']`). The first entry is the default unit for a bare number.
* Absent → every CSS unit allowed. Alto (height) lists every absolute-resolving
* unit (px/rem/em/vh/vw) but omits `%`: a percentage height is inert without a
* definite-height ancestor, whereas px/rem/em/vh resolve to a real height.
* Width keeps `%` (a percentage width resolves against the content column). */
readonly units?: readonly CssUnit[];
/** Disable this control when the active block's `node[gatedBy]` is truthy — e.g.
* width / height / align gated by `fullWidth` (the engine ignores them then). */
readonly gatedBy?: string;
@ -74,10 +94,7 @@ export interface PalabrasFieldDef {
* height where 0 is meaningless. Filters leave this off (0 is a real value). */
readonly clearWhenZero?: boolean;
/** Choices for `pills` / `select`. The `value` is written raw to the prop. */
readonly options?: readonly {
readonly value: string | number | boolean;
readonly label: string;
}[];
readonly options?: readonly PalabrasFieldOption[];
/** Read-only display (e.g. an `'Auto'` chip). */
readonly readOnly?: boolean;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.