feat(eidos): Lote A closes — popover + banner compose IconButton; clean drawer orphans

DIM 1 composition (component-coherence audit), close family:

- popover-close + banner-close now compose the canonical <IconButton>/<Button>
  (via soma child snippet for popover; direct for the eidos-only banner) instead
  of bespoke chrome. They inherit Button's variant/size/color/focus-ring; the
  aria-label routes through langs ("Cerrar" / "Descartar"). Consistent with the
  already-migrated dialog/drawer closes (solid/primary default; consumer overrides
  for subtle).
- Removed the redundant [data-popover-close] / [data-banner-close] chrome from CSS
  and the now-orphaned --popover-close-* / --popover-transition-* recipe tokens;
  regenerated base.css. banner keeps only `margin-inline-start: auto` for end
  placement, scoped under [data-banner] so it out-ranks [data-button] (composing
  IconButton flips CSS load order — the equal-specificity bare selector loses).
- Cleaned the 15 orphaned --drawer-close-* recipe tokens the earlier drawer-close
  migration left behind, and fixed the component-visual-attrs manifest: drawer +
  popover closes delegate icon-only to IconButton, so they stamp no wrapper attr.

eidos suite 9->8 failures (the 8 remaining are inherited: spin-field + palabras +
icon, outside this work). svelte-check clean for all touched files. Verified in
browser. Excludes words/palabras/chronos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent ebc742e73b
commit 0a43225ec2

@ -709,10 +709,22 @@ time-picker + morfo: **65/65**. Navegador: langs ES + transición de los 4 input
### Pendiente (orden sugerido)
1. **DIM 1 composición — Lote A** (Button-consumer): closes dialog/drawer/popover/
banner/toast → `IconButton`+`SvgX`; calendar/range prev/next → `IconButton`+
`SvgChevron`; **code-block copy → componer `<Clipboard>`** (cierra también su
hardcode de langs). Borrar el chrome `--*-control-*`/`--*-close-*` redundante.
1. **DIM 1 composición — Lote A** (Button-consumer) — **EN CURSO**:
- ✅ **popover-close** + **banner-close** → `IconButton`+`SvgX` (slices completos:
wrapper + types + borrado de chrome CSS + eliminación de tokens recipe huérfanos
+ regen; verificados en navegador, consistentes con dialog/drawer-close).
- ✅ **drawer-close**: limpiados los 15 tokens recipe huérfanos que la migración
previa dejó + arreglado el manifiesto de `component-visual-attrs` (drawer +
popover componen IconButton → no estampan `data-icon-only`, sin entrada). Suite
eidos 9→8 fallas (las 8 restantes son heredadas: spin-field, palabras, icon).
- **Gotcha confirmado** ([[iconbutton-lote0]]): migrar a IconButton voltea el orden
de carga CSS → `[data-button]{margin:0}` (igual especificidad, carga después) pisa
el positioning del consumidor. Fix: subir especificidad con ancestro
(`[data-banner] [data-banner-close]`).
- ⏳ **Pendiente del Lote A**: **toast-close** (19 refs CSS — chrome más complejo),
**float-panel-close** (chrome COMPARTIDO con `action`+`stage-trigger` → migrar los
tres o extraer con cuidado), **calendar/range prev/next** → `IconButton`+`SvgChevron`
(+ borrar `--calendar-control-*`), **code-block copy** → componer `<Clipboard>`.
2. **DIM 1 — Lote B** (estructural): 6 `<select>` nativos → `<Select>` (calendar
month/year, range-calendar month/year, color-field/color-picker format).
3. **Barrido de langs — cola** (deferidos arriba): avatar-group conteo, date-range-picker

@ -31,11 +31,11 @@ const VISUAL_ATTRS = [
],
['checkbox/checkbox.svelte', ['data-size', 'data-variant', 'data-color']],
['checkbox/checkbox-group.svelte', ['data-columns']],
// The close composes <IconButton>, which owns the icon-only layout — the
// wrapper only wires the dialog-specific floating `data-position`.
// The close composes <IconButton>, which owns the icon-only layout. dialog
// adds the floating `data-position`; drawer + popover stamp no wrapper-specific
// visual attr (they fully delegate to IconButton) so they have no entry.
['dialog/dialog-close.svelte', ['data-position']],
['dialog/dialog-content.svelte', ['data-size', 'data-position', 'data-sheet']],
['drawer/drawer-close.svelte', ['data-icon-only']],
['drawer/drawer-content.svelte', ['data-size']],
['editable/editable.svelte', ['data-size', 'data-variant', 'data-color']],
['field/field.svelte', ['data-size', 'data-variant', 'data-orientation', 'data-color']],
@ -44,7 +44,6 @@ const VISUAL_ATTRS = [
['meter/meter.svelte', ['data-size', 'data-shape']],
['number-field/number-field.svelte', ['data-size', 'data-variant', 'data-color']],
['pagination/pagination.svelte', ['data-size']],
['popover/popover-close.svelte', ['data-icon-only']],
['popover/popover-content.svelte', ['data-size']],
['progress/progress.svelte', ['data-size', 'data-shape']],
['radio-group/radio-group.svelte', ['data-size', 'data-variant', 'data-color', 'data-columns']],

@ -1,50 +1,38 @@
<script lang="ts">
/**
* Eidos `<Banner.Close>` — visually-styled dismiss button. Pure
* eidos-only part: meets the four rules in
* `eidos/components/README.md` (no behavior, no aria contract, no
* event-target role, no keyboard). The consumer wires `onclick` to
* their own visibility state — Banner does not own the dismissal.
*
* Defaults to a localized "Dismiss" `aria-label` (via langs) so consumers
* don't have to remember, but the consumer can override.
* Eidos `<Banner.Close>` — the banner dismiss control. Composes the canonical
* `<IconButton>` (eidos-only part — Banner has no soma close; the consumer
* wires `onclick` to their own visibility state). Inherits Button's chrome,
* focus ring and disabled treatment; only the positioning
* (`margin-inline-start: auto`) stays in `banner.css`. Default `variant='ghost'
* color='neutral'` — a subtle dismiss. The glyph defaults to ✕.
*/
import { ActiveEidos } from '$uix/eidos';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgX } from '$uix/eidos/components/svg';
import type { BannerCloseProps } from './types';
let {
'aria-label': ariaLabel,
class: className,
variant = 'ghost',
size,
color = 'neutral',
children,
...restProps
}: BannerCloseProps = $props();
const eidos = ActiveEidos.require();
// Accessible name routes through langs — explicit prop wins, else the
// localized default. No hardcoded English.
// Accessible name routes through langs — explicit prop wins, else localized.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?common.buttons.dismiss|Dismiss'));
</script>
<button
type="button"
<IconButton
{...restProps}
{variant}
size={size ?? 'sm'}
{color}
aria-label={resolvedLabel}
class={className}
data-banner-close=""
>
{#if children}{@render children()}{:else}<svg
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path
d="M3 3l10 10M13 3L3 13"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>{/if}
</button>
{#if children}{@render children()}{:else}<SvgX />{/if}
</IconButton>

@ -141,30 +141,12 @@
/* ── Close button (eidos-only part) ────────────────────────────────── */
[data-banner-close] {
display: inline-flex;
align-items: center;
justify-content: center;
/* Positioning only — `Banner.Close` composes the canonical `<IconButton>`, so
its chrome (size, bg, border, hover, focus ring) comes from button.css. The
`margin-inline-start: auto` pushes the dismiss to the end of the banner row.
Scoped under `[data-banner]` (0,2,0) so it out-ranks `[data-button]`'s own
`margin` (0,1,0) — composing IconButton flips the CSS load order, so an
equal-specificity bare selector would lose the tie. */
[data-banner] [data-banner-close] {
margin-inline-start: auto;
inline-size: var(--space-6);
block-size: var(--space-6);
padding: 0;
background: transparent;
color: inherit;
border: 0;
border-radius: var(--radius-sm, var(--space-1));
cursor: pointer;
opacity: var(--opacity-subtle);
transition: opacity var(--duration-fast) ease, background-color var(--duration-fast) ease;
}
[data-banner-close]:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
[data-banner-close]:focus-visible {
outline: var(--border-width-medium) solid currentColor;
outline-offset: 1px;
opacity: 1;
}

@ -2,6 +2,7 @@ import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { ChipVariant } from '$uix/eidos/lib/types';
import type { IconButtonProps } from '$uix/eidos/components/icon-button';
/**
* Banner intent vocabulary. Mirrors the canonical UIX `ColorRole` set
@ -49,8 +50,15 @@ export type BannerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
children?: Snippet;
};
export type BannerCloseProps = Omit<HTMLAttributes<HTMLButtonElement>, 'children'> & {
/** Accessible label for the dismiss button. @default 'Dismiss' */
/**
* `Banner.Close` composes the canonical `<IconButton>`, so it inherits its
* full prop surface (variant / size / color / onclick / disabled / …). It only
* relaxes the two IconButton requirements: `aria-label` (defaults to a
* localized "Dismiss") and `children` (the glyph, defaults to ✕).
*/
export type BannerCloseProps = Omit<IconButtonProps, 'aria-label' | 'children'> & {
/** Accessible label for the dismiss button. @default localized 'Dismiss' */
'aria-label'?: string;
/** Glyph override (defaults to the ✕). */
children?: Snippet;
};

@ -1,31 +1,50 @@
<script lang="ts">
/**
* Eidos `Popover.Close` — passes through to headless. Optional auto-X
* icon when no children are provided (matches the air baseline +
* Dialog/Drawer pattern). The recipe styles a square button with
* focus ring.
* Eidos `Popover.Close` — composes the canonical button primitives via soma's
* `child` snippet (no bespoke close chrome — it inherits Button's variant /
* focus-ring / disabled):
*
* - no text children → icon close: an `<IconButton>` with the ✕ glyph and a
* localized "Close" `aria-label`.
* - text children → text close: a `<Button>` with the label inline.
*
* A `child` snippet bypasses both primitives.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Popover from '$soma/components/popover';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgX } from '$uix/eidos/components/svg';
import type { PopoverCloseProps } from './types';
let { children, ...rest }: PopoverCloseProps = $props();
let {
variant,
size,
color,
'aria-label': ariaLabel,
child: outerChild,
children,
...rest
}: PopoverCloseProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(size ?? 'md');
// Accessible name routes through langs — explicit prop wins, else localized.
const closeLabel = $derived(ariaLabel ?? eidos.langs.ts('#?common.buttons.close|Close'));
</script>
<Popover.Close {...rest} data-icon-only={!children ? '' : undefined}>
{#if children}
{@render children()}
{:else}
<svg
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
{/if}
<Popover.Close {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else if children}
<Button {...props} {variant} size={resolvedSize} {color} aria-label={ariaLabel ?? undefined}>
{@render children()}
</Button>
{:else}
<IconButton {...props} {variant} size={resolvedSize} {color} aria-label={closeLabel}>
<SvgX />
</IconButton>
{/if}
{/snippet}
</Popover.Close>

@ -224,45 +224,9 @@
}
/* ── Close button ──────────────────────────────────────────────────────
* Default sizing is auto-fit (text labels like "Close" or "Dismiss" fit
* naturally). For icon-only close buttons (× glyph), the consumer sets
* `data-icon-only` on `<Popover.Close>` to switch to a fixed square. */
[data-popover-close] {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: var(--popover-close-size);
padding-inline: var(--space-3);
border: var(--popover-close-border-width) solid var(--popover-close-border);
border-radius: var(--popover-close-radius);
background: var(--popover-close-bg);
color: var(--popover-close-color);
box-shadow: none;
font: inherit;
font-size: var(--popover-close-font-size);
cursor: pointer;
transition:
background var(--popover-transition-duration) var(--popover-transition-ease),
border-color var(--popover-transition-duration) var(--popover-transition-ease),
color var(--popover-transition-duration) var(--popover-transition-ease),
box-shadow var(--popover-transition-duration) var(--popover-transition-ease);
}
[data-popover-close][data-icon-only] {
inline-size: var(--popover-close-size);
block-size: var(--popover-close-size);
padding: 0;
}
[data-popover-close]:hover {
background: var(--popover-close-bg-hover);
border-color: var(--popover-close-border-hover);
}
[data-popover-close]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
* `Popover.Close` composes the canonical `<IconButton>` / `<Button>` (eidos
* wrapper), so all chrome — size, border, bg, hover, focus ring — comes from
* `button.css`. No bespoke `[data-popover-close]` recipe here. */
/* ── data-last-action exit tinting (mirrors Dialog/Drawer) ──────────────
* Prewrite writes `data-last-action` BEFORE state flips to 'closed'.

@ -1,6 +1,7 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type { ButtonVariant, ButtonSize, ButtonProps } from '$uix/eidos/components/button';
import type {
ProviderProps,
TriggerProps,
@ -26,7 +27,17 @@ export type PopoverTriggerProps = TriggerProps;
export type PopoverArrowProps = ArrowProps;
export type PopoverTitleProps = TitleProps;
export type PopoverDescriptionProps = DescriptionProps;
export type PopoverCloseProps = CloseProps;
export type PopoverCloseProps = CloseProps & {
/**
* Visual variant of the underlying button. The close composes the canonical
* `<Button>` / `<IconButton>`, so it inherits the shared chrome + focus ring.
*/
variant?: ButtonVariant;
/** Button size of the close control. @default 'md' */
size?: ResponsiveProp<ButtonSize>;
/** Hierarchical color accent — defers to Button's canonical default. */
color?: ButtonProps['color'];
};
export type PopoverAnchorProps = AnchorProps;
export type PopoverOverlayProps = OverlayProps & {
/**

@ -1174,21 +1174,6 @@
--drawer-description-font-size: var(--font-size-sm);
--drawer-description-line-height: var(--leading-ui);
--drawer-description-color: var(--color-content-secondary);
--drawer-close-size: var(--control-height-sm);
--drawer-close-px: var(--space-2);
--drawer-close-radius: var(--radius-md);
--drawer-close-bg: transparent;
--drawer-close-bg-hover: color-mix(in srgb, var(--color-neutral-track) 72%, transparent);
--drawer-close-color: var(--color-content-secondary);
--drawer-close-color-hover: var(--color-content-primary);
--drawer-close-border: transparent;
--drawer-close-border-width: var(--border-width);
--drawer-close-transition-duration: var(--duration-fast);
--drawer-close-transition-ease: var(--ease-default);
--drawer-close-focus-ring-offset: 1px;
--drawer-close-focus-ring-width: var(--focus-ring-width);
--drawer-close-focus-ring-color: var(--focus-ring-color);
--drawer-close-disabled-opacity: 0.5;
--icon-size: var(--icon-size-md);
--icon-stroke-width: var(--icon-stroke-width-md);
--image-size-xs: 64px;
@ -1819,17 +1804,6 @@
--popover-color: var(--color-content-primary);
--popover-arrow-width: var(--space-5);
--popover-arrow-height: var(--space-2);
--popover-transition-duration: var(--duration-fast);
--popover-transition-ease: var(--ease-default);
--popover-close-size: var(--control-height-xs);
--popover-close-font-size: var(--font-size-sm);
--popover-close-radius: var(--radius-md);
--popover-close-border-width: var(--border-width);
--popover-close-bg: transparent;
--popover-close-bg-hover: color-mix( in srgb, var(--color-neutral-track) 72%, var(--popover-bg) );
--popover-close-color: var(--color-content-secondary);
--popover-close-border: color-mix(in srgb, var(--color-border-default) 72%, transparent);
--popover-close-border-hover: var(--color-border-strong);
--progress-height-xs: 2px;
--progress-height-sm: 4px;
--progress-height-md: 6px;

@ -938,22 +938,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'description-font-family': 'var(--font-ui)',
'description-font-size': 'var(--font-size-sm)',
'description-line-height': 'var(--leading-ui)',
'description-color': 'var(--color-content-secondary)',
'close-size': 'var(--control-height-sm)',
'close-px': 'var(--space-2)',
'close-radius': 'var(--radius-md)',
'close-bg': 'transparent',
'close-bg-hover': 'color-mix(in srgb, var(--color-neutral-track) 72%, transparent)',
'close-color': 'var(--color-content-secondary)',
'close-color-hover': 'var(--color-content-primary)',
'close-border': 'transparent',
'close-border-width': 'var(--border-width)',
'close-transition-duration': 'var(--duration-fast)',
'close-transition-ease': 'var(--ease-default)',
'close-focus-ring-offset': '1px',
'close-focus-ring-width': 'var(--focus-ring-width)',
'close-focus-ring-color': 'var(--focus-ring-color)',
'close-disabled-opacity': '0.5'
'description-color': 'var(--color-content-secondary)'
// `close-*` tokens removed: Drawer.Close composes the canonical
// <IconButton>/<Button>, so its chrome lives in button.css.
},
icon: {
size: 'var(--icon-size-md)',
@ -1805,18 +1792,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
// Arrow size — canonical density-aware tokens (NOT soma's hardcoded 10×5).
// width ≈ 2.5× height for a balanced triangle; both scale with density/scaling.
'arrow-width': 'var(--space-5)',
'arrow-height': 'var(--space-2)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'close-size': 'var(--control-height-xs)',
'close-font-size': 'var(--font-size-sm)',
'close-radius': 'var(--radius-md)',
'close-border-width': 'var(--border-width)',
'close-bg': 'transparent',
'close-bg-hover': 'color-mix( in srgb, var(--color-neutral-track) 72%, var(--popover-bg) )',
'close-color': 'var(--color-content-secondary)',
'close-border': 'color-mix(in srgb, var(--color-border-default) 72%, transparent)',
'close-border-hover': 'var(--color-border-strong)'
'arrow-height': 'var(--space-2)'
// `close-*` + `transition-*` tokens removed: Popover.Close composes the
// canonical <IconButton>/<Button>, so its chrome lives in button.css.
},
progress: {
'height-xs': '2px',

Loading…
Cancel
Save

Powered by TurnKey Linux.