feat(eidos): port LinkPreview (Radix HoverCard equivalent)
#128. Eidos wrapper + types + recipe + canonical-depth demo. Trigger
renders as `<a>` (navigational anchor); on hover/focus after `openDelay`
(default 700ms) the Content panel reveals with SafePolygon bridging
trigger and content so the pointer can travel without dismissing.
Non-modal, no-op on touch (link follows directly).
Reference parity:
| Feature | Radix HoverCard | bits-ui LinkPreview | ark-ui HoverCard | this port |
|---|---|---|---|---|
| open with bind: | ✓ | ✓ | ✓ | ✓ |
| openDelay (700ms) / closeDelay (300ms) | ✓ | ✓ | ✓ | ✓ |
| disabled | ✗ | ✓ | ✓ | ✓ |
| Trigger as `<a>` | ✓ | ✓ | ✗ (`<button>`) | ✓ |
| Portal + Arrow | ✓ | ✓ | ✓ | ✓ |
| Floating layer (side/align/offsets/collision) | ✓ | ✓ | ✓ | ✓ |
| customAnchor | ✗ | ✓ | ✗ | ✓ |
| ContentStatic | ✗ | ✓ | ✗ | ⚠ deferred |
| lazyMount | ✗ | ✗ | ✓ | ⚠ deferred |
Naming choice: `LinkPreview` (matches air + bits-ui), not `HoverCard`.
Trigger semantic is navigational anchor — for a button-triggered
hover-card use `<Popover>` with `openDelay` or pass `child` snippet
to bypass the default `<a>`.
Variant set: only `size: 'xs'..'xl'` (no ControlVariant — LinkPreview
is a surface, not actionable). Defer color/intent per the canon rule
that surfaces are uncolored.
Parts shipped: Provider, Trigger, Portal, Content, Arrow.
Recipe: `link-preview.css` with anchor underline (subtle, intensifies
on hover/open), content panel chrome (border + surface bg + shadow),
arrow fill matching panel bg, slide+fade entrance per `data-side`,
reduced-motion skip.
Demo: 6 tabs (Live/API/Morfo/Sema/Recipe/A11y) at canonical depth per
DEMO_AUTHORING_GUIDE — Live wires every Provider+Content prop with
the right chip set, eidos+soma snippets generated, all 6 ARIA
contract rows + keyboard + pointer tables in A11y tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
/*
|
|
|
|
|
* LinkPreview recipe — Radix HoverCard equivalent. The trigger is an
|
|
|
|
|
* `<a>` (navigational anchor); on hover (after `openDelay`, default
|
|
|
|
|
* 700ms) the Content panel reveals next to the anchor with a SafePolygon
|
|
|
|
|
* bridging trigger and content so the pointer can travel without
|
|
|
|
|
* dismissing.
|
|
|
|
|
*
|
|
|
|
|
* Soma owns the floating math, dismissal, open/close timing and
|
|
|
|
|
* `data-state` / `data-side`. Eidos paints:
|
|
|
|
|
* [data-link-preview-trigger] → anchor styling (underline + hover/open color)
|
|
|
|
|
* [data-link-preview-content] → floating panel chrome (border, bg, padding)
|
|
|
|
|
* [data-link-preview-arrow] → arrow fill matching the panel bg
|
|
|
|
|
*
|
|
|
|
|
* Unlike Popover/Dialog, the Trigger is intentionally NOT a Button —
|
|
|
|
|
* link previews are anchors, so the recipe styles the `<a>` directly
|
|
|
|
|
* (underline + accent color, not bordered button chrome).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/* ── Trigger ─────────────────────────────────────────────────────────── */
|
|
|
|
|
[data-link-preview-trigger] {
|
|
|
|
|
color: var(--color-primary-text, var(--color-content-primary));
|
|
|
|
|
text-decoration-line: underline;
|
|
|
|
|
text-decoration-thickness: 1px;
|
|
|
|
|
text-underline-offset: 0.18em;
|
|
|
|
|
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
|
|
|
|
|
text-decoration-skip-ink: auto;
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
outline: none;
|
|
|
|
|
transition:
|
|
|
|
|
color var(--duration-fast) var(--ease-default),
|
|
|
|
|
text-decoration-color var(--duration-fast) var(--ease-default);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-trigger]:hover,
|
|
|
|
|
[data-link-preview-trigger][data-state='open'] {
|
|
|
|
|
color: var(--color-primary-solid, var(--color-content-primary));
|
|
|
|
|
text-decoration-color: currentColor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-trigger]:focus-visible {
|
|
|
|
|
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
|
|
|
outline-offset: var(--focus-ring-offset);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Content ─────────────────────────────────────────────────────────── */
|
|
|
|
|
/*
|
|
|
|
|
* Size only affects PHYSICAL dimensions (padding, min-width, font-size).
|
|
|
|
|
* Typography family / weight / line-height stay constant across sizes —
|
|
|
|
|
* size and intrinsic type are independent concerns.
|
|
|
|
|
*/
|
|
|
|
|
[data-link-preview-content] {
|
|
|
|
|
--_link-preview-py: var(--space-3);
|
|
|
|
|
--_link-preview-px: var(--space-4);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-sm);
|
|
|
|
|
--_link-preview-min-width: 14rem;
|
|
|
|
|
--_link-preview-max-width: min(28rem, calc(100vw - var(--space-4)));
|
|
|
|
|
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
min-inline-size: var(--_link-preview-min-width);
|
|
|
|
|
max-inline-size: var(--_link-preview-max-width);
|
|
|
|
|
padding: var(--_link-preview-py) var(--_link-preview-px);
|
|
|
|
|
border: 1px solid var(--color-border-default);
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
background: var(--color-surface-overlay);
|
|
|
|
|
color: var(--color-content-primary);
|
feat(eidos): depth adoption — elevated components consume the channel (shadow + halo)
The Fase 2 rim halo now reaches real UI, not just the showcase. Each elevated
component's shadow signal (recipe tokens in recipes/base.ts + the few direct
box-shadow uses) now composes `var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`.
Reaches: popover, dialog, drawer, dropdown/context/navigation-menu, menubar,
select, tooltip, card, combobox, command, link-preview, words.
Shadow signal only — z-index stays component-managed (the z bands are finer than
the 5 planos), so zero stacking risk. Verified: dialog keeps its own z-index 71
and composes drop shadow + oklab halo. Retuning a plane now retunes every
component on it.
Verified: npm run check 0 errors; eidos 183/186 (3 pre-existing `words` failures,
unrelated). Regenerated generated/base.css. THEMING 29 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
|
feat(eidos): port LinkPreview (Radix HoverCard equivalent)
#128. Eidos wrapper + types + recipe + canonical-depth demo. Trigger
renders as `<a>` (navigational anchor); on hover/focus after `openDelay`
(default 700ms) the Content panel reveals with SafePolygon bridging
trigger and content so the pointer can travel without dismissing.
Non-modal, no-op on touch (link follows directly).
Reference parity:
| Feature | Radix HoverCard | bits-ui LinkPreview | ark-ui HoverCard | this port |
|---|---|---|---|---|
| open with bind: | ✓ | ✓ | ✓ | ✓ |
| openDelay (700ms) / closeDelay (300ms) | ✓ | ✓ | ✓ | ✓ |
| disabled | ✗ | ✓ | ✓ | ✓ |
| Trigger as `<a>` | ✓ | ✓ | ✗ (`<button>`) | ✓ |
| Portal + Arrow | ✓ | ✓ | ✓ | ✓ |
| Floating layer (side/align/offsets/collision) | ✓ | ✓ | ✓ | ✓ |
| customAnchor | ✗ | ✓ | ✗ | ✓ |
| ContentStatic | ✗ | ✓ | ✗ | ⚠ deferred |
| lazyMount | ✗ | ✗ | ✓ | ⚠ deferred |
Naming choice: `LinkPreview` (matches air + bits-ui), not `HoverCard`.
Trigger semantic is navigational anchor — for a button-triggered
hover-card use `<Popover>` with `openDelay` or pass `child` snippet
to bypass the default `<a>`.
Variant set: only `size: 'xs'..'xl'` (no ControlVariant — LinkPreview
is a surface, not actionable). Defer color/intent per the canon rule
that surfaces are uncolored.
Parts shipped: Provider, Trigger, Portal, Content, Arrow.
Recipe: `link-preview.css` with anchor underline (subtle, intensifies
on hover/open), content panel chrome (border + surface bg + shadow),
arrow fill matching panel bg, slide+fade entrance per `data-side`,
reduced-motion skip.
Demo: 6 tabs (Live/API/Morfo/Sema/Recipe/A11y) at canonical depth per
DEMO_AUTHORING_GUIDE — Live wires every Provider+Content prop with
the right chip set, eidos+soma snippets generated, all 6 ARIA
contract rows + keyboard + pointer tables in A11y tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
font-family: var(--font-ui);
|
|
|
|
|
font-size: var(--_link-preview-font-size);
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
line-height: var(--leading-body);
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-size='xs'] {
|
|
|
|
|
--_link-preview-py: var(--space-2);
|
|
|
|
|
--_link-preview-px: var(--space-3);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-xs);
|
|
|
|
|
--_link-preview-min-width: 11rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-size='sm'] {
|
|
|
|
|
--_link-preview-py: var(--space-2);
|
|
|
|
|
--_link-preview-px: var(--space-3);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-sm);
|
|
|
|
|
--_link-preview-min-width: 12rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-size='md'] {
|
|
|
|
|
--_link-preview-py: var(--space-3);
|
|
|
|
|
--_link-preview-px: var(--space-4);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-sm);
|
|
|
|
|
--_link-preview-min-width: 14rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-size='lg'] {
|
|
|
|
|
--_link-preview-py: var(--space-4);
|
|
|
|
|
--_link-preview-px: var(--space-5);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-md);
|
|
|
|
|
--_link-preview-min-width: 18rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-size='xl'] {
|
|
|
|
|
--_link-preview-py: var(--space-5);
|
|
|
|
|
--_link-preview-px: var(--space-6);
|
|
|
|
|
--_link-preview-font-size: var(--font-size-md);
|
|
|
|
|
--_link-preview-min-width: 22rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The floating wrapper is `position: fixed` but un-stacked. Lift it
|
|
|
|
|
* above the page chrome so the panel actually paints over content. */
|
|
|
|
|
[data-floating-wrapper]:has(> [data-link-preview-content]) {
|
|
|
|
|
z-index: var(--popover-content-z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Arrow ──────────────────────────────────────────────────────────── */
|
|
|
|
|
[data-link-preview-arrow] {
|
|
|
|
|
fill: var(--color-surface-overlay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── State transitions per anchored side ────────────────────────────── */
|
|
|
|
|
/*
|
|
|
|
|
* Soma's Floating layer writes `data-side` (top/right/bottom/left) on
|
|
|
|
|
* Content based on the resolved placement. The preview slides into
|
|
|
|
|
* place FROM the trigger edge.
|
|
|
|
|
*/
|
|
|
|
|
[data-link-preview-content][data-state='open'][data-side='top'] {
|
|
|
|
|
animation: link-preview-in-top var(--duration-normal) var(--ease-out) both;
|
|
|
|
|
}
|
|
|
|
|
[data-link-preview-content][data-state='closed'][data-side='top'] {
|
|
|
|
|
animation: link-preview-out-top var(--duration-fast) var(--ease-default) both;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-state='open'][data-side='bottom'] {
|
|
|
|
|
animation: link-preview-in-bottom var(--duration-normal) var(--ease-out) both;
|
|
|
|
|
}
|
|
|
|
|
[data-link-preview-content][data-state='closed'][data-side='bottom'] {
|
|
|
|
|
animation: link-preview-out-bottom var(--duration-fast) var(--ease-default) both;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-state='open'][data-side='right'] {
|
|
|
|
|
animation: link-preview-in-right var(--duration-normal) var(--ease-out) both;
|
|
|
|
|
}
|
|
|
|
|
[data-link-preview-content][data-state='closed'][data-side='right'] {
|
|
|
|
|
animation: link-preview-out-right var(--duration-fast) var(--ease-default) both;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[data-link-preview-content][data-state='open'][data-side='left'] {
|
|
|
|
|
animation: link-preview-in-left var(--duration-normal) var(--ease-out) both;
|
|
|
|
|
}
|
|
|
|
|
[data-link-preview-content][data-state='closed'][data-side='left'] {
|
|
|
|
|
animation: link-preview-out-left var(--duration-fast) var(--ease-default) both;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes link-preview-in-top {
|
|
|
|
|
from { opacity: 0; transform: translateY(4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-out-top {
|
|
|
|
|
to { opacity: 0; transform: translateY(4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-in-bottom {
|
|
|
|
|
from { opacity: 0; transform: translateY(-4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-out-bottom {
|
|
|
|
|
to { opacity: 0; transform: translateY(-4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-in-right {
|
|
|
|
|
from { opacity: 0; transform: translateX(-4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-out-right {
|
|
|
|
|
to { opacity: 0; transform: translateX(-4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-in-left {
|
|
|
|
|
from { opacity: 0; transform: translateX(4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes link-preview-out-left {
|
|
|
|
|
to { opacity: 0; transform: translateX(4px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
[data-link-preview-content] {
|
|
|
|
|
animation: none !important;
|
|
|
|
|
}
|
|
|
|
|
}
|