uix(chat): v2 · jump-to-message highlight en chat-message

Tercer y último remate de v2 (batch elegido por el usuario). chat-message
gana el prop `emphasized` → attr `data-emphasized`: al saltar a una cita
(reply.onJump → scrollToIndex) la app enciende `emphasized` ~1.4s y la fila
objetivo hace un flash suave que se desvanece.

VISUAL PURO — sin evento de sema (desvía a propósito del signal-emphasize
-target del plan): las apps de referencia hacen un flash SILENCIOSO; un
signal que pitara por defecto sería molesto. Cumple 2-de-3 (soma escribe el
attr, eidos lo lee). El pulso = toggle de dos estados + transition (cero
@keyframes, R-4.5); reduced-motion = instantáneo.

Morfo attr + soma prop/provider (devuelve el booleano al resolver, no '')
+ eidos recipe (wash color-mix 14% de toda la fila) + demo componente
(botón flash) + escaparate (reply → jump → emphasize). next-features §7
marca los 3 remates shipped.

Verificado (Playwright): flash on → bg tintado → auto-clear a 1.4s;
escaparate intacto. audit PASS, eidos-lint invalid=0, recipe-contract
30/30, check en baseline (76).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 95ec83e6f8
commit 8fe9d1faaa

@ -163,8 +163,11 @@ component README §Gaps):
`chat-composer`; pairs with the future `words`/`palabras` integration.
- **Threads UI** — the side-panel composition over the already-shipped
`Feed.Thread` semantics.
- **Jump-to-message highlight** — `signal-emphasize-target` on
`chat-message` (the morfo event was deliberately left out of v1).
- ~~**Jump-to-message highlight** — `signal-emphasize-target` on
`chat-message` (the morfo event was deliberately left out of v1).~~
**✅ SHIPPED 2026-07-19** — `emphasized` prop → `data-emphasized` row flash
(visual-only, no sema event: reference apps flash silently; the app scrolls
via a reply's `onJump` then toggles `emphasized` for ~1.4s).
- ~~**Read-by list** — "seen by N" avatar row (Teams/Messenger pattern) on
`chat-message`.~~ **✅ SHIPPED 2026-07-19** — `ChatMessage.ReadBy` part
(the app composes `AvatarGroup`; the sender's `onMessageSeen` feeds it).

@ -66,10 +66,11 @@ Declared in `lib/recipes/base.ts`: row layout (`gap`, `row-gap-run`,
status (`status-color[-read|-failed]`, `status-size`), reply (`reply-*`
incl. logical accent bar), reaction chips (`reaction-*` incl. pressed
palette, `reaction-shadow`, `reaction-overlap`), `mentioned-bg/accent`,
actions pill (`actions-bg/border/shadow/radius/padding` + `gap/offset`).
Recipe-contract compliant: logical axes, size-bundle coordinates,
state-layer hovers, `--opacity-muted` while sending, outline focus, no
local keyframes.
actions pill (`actions-bg/border/shadow/radius/padding` + `gap/offset`),
read-by row (`read-by-gap/font-size/color/margin`), jump-highlight
(`emphasize-bg/radius/duration`). Recipe-contract compliant: logical axes,
size-bundle coordinates, state-layer hovers, `--opacity-muted` while
sending, outline focus, no local keyframes.
## Comparativa
@ -122,6 +123,14 @@ Telegram tras el veredicto del usuario sobre la v1):
- `sending` atenúa la burbuja con `--opacity-muted` (token, no literal).
- **Bidi por mensaje**: `unicode-bidi: plaintext` en la burbuja — cada
mensaje mantiene la dirección de SU texto en una UI RTL.
- **Jump-to-message highlight (v2)**: `data-emphasized` (prop `emphasized`)
pinta un wash suave en TODA la fila con una `transition` (el toggle de dos
estados + transición ES el pulso — cero @keyframes, R-4.5). VISUAL PURO
(sin evento sema): las apps de referencia hacen un flash silencioso; un
`signal` que pitara por defecto sería molesto. Reduced-motion = instantáneo.
- **read-by / typing-avatars (v2)** son slots decorativos (`aria-hidden`)
donde la app compone `AvatarGroup`; el indicador Status ya transmite "read"
a AT.
- **Iconos = lucide del ecosistema, NUNCA SVG a mano** (regla de composición):
`SmilePlus` (añadir reacción), `Send`, `Paperclip`, `X`, `ArrowDown`,
`Reply`. Improvisar paths a mano fue el error de la v1 (se veían cutres).

@ -59,6 +59,25 @@
color: inherit;
}
/* Jump-to-message highlight — a soft row flash. The app toggles
`data-emphasized` (set true, clear ~1.4s later); the transition fades it in
and back out. No local keyframes (R-4.5): the two-state toggle + a
transition IS the pulse. */
[data-chat-message] {
border-radius: var(--chat-message-emphasize-radius);
transition: background-color var(--chat-message-emphasize-duration) var(--ease-out);
}
[data-chat-message][data-emphasized] {
background-color: var(--chat-message-emphasize-bg);
}
@media (prefers-reduced-motion: reduce) {
[data-chat-message] {
transition: none;
}
}
/* ── Columns ─────────────────────────────────────────────────────────── */
[data-chat-message-gutter] {

@ -944,6 +944,9 @@
--chat-message-read-by-font-size: var(--size-xs-font-size);
--chat-message-read-by-color: var(--color-content-muted);
--chat-message-read-by-margin: var(--space-0-5);
--chat-message-emphasize-bg: color-mix(in srgb, var(--color-primary-solid) 14%, transparent);
--chat-message-emphasize-radius: var(--radius-md);
--chat-message-emphasize-duration: var(--duration-moderate);
--chat-message-reply-padding-inline: var(--space-2);
--chat-message-reply-padding-block: var(--space-1);
--chat-message-reply-accent-size: var(--space-0-5);

@ -794,6 +794,12 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'read-by-font-size': 'var(--size-xs-font-size)',
'read-by-color': 'var(--color-content-muted)',
'read-by-margin': 'var(--space-0-5)',
// ── Jump-to-message highlight (row flash, fades via transition) ───
// A clearly perceptible soft tint (a2 surface is near-invisible over
// the muted log canvas) — same color-mix idiom as field segment-active.
'emphasize-bg': 'color-mix(in srgb, var(--color-primary-solid) 14%, transparent)',
'emphasize-radius': 'var(--radius-md)',
'emphasize-duration': 'var(--duration-moderate)',
// ── Reply (quoted preview) ───────────────────────────────────────
'reply-padding-inline': 'var(--space-2)',
'reply-padding-block': 'var(--space-1)',

@ -79,6 +79,17 @@ export const chatMessageMorfo = {
values: ['sending', 'sent', 'delivered', 'read', 'failed'],
value: v.propRef('delivery'),
severity: 'optional'
},
{
// Jump-to-message highlight (v2): a transient flash when the
// app scrolls this row into view (e.g. from a reply's onJump).
// Visual-only by design — reference apps flash silently, so no
// sema event; the app toggles `emphasized` (set true, clear
// after ~1.4s) and eidos fades the highlight in/out. NOT
// `data-highlighted` (reserved for virtual focus).
attr: 'data-emphasized',
value: v.propRef('emphasized'),
severity: 'optional'
}
],
aria: [

@ -56,6 +56,7 @@ history prepends (see Feed's README).
| `delivery` | `'sending' \| 'sent' \| 'delivered' \| 'read' \| 'failed'` | — | Own-message optimistic state. Emits `data-delivery` + label. |
| `ariaLabel` | `string` | — | Article name ("Ada · 14:32") — recommended for grouped rows. |
| `posinset` | `number` | — | Forwarded to the composed Feed.Article. |
| `emphasized`| `boolean` | `false` | Jump-to-message flash: set true briefly (clear after ~1.4s) to highlight this row. Emits `data-emphasized`; visual-only (silent). |
| `onRetry` | `() => void` | — | Fired by `retry()` (snippet prop), guarded to `failed`. |
Snippet props: `{ delivery, retry }`.

@ -52,6 +52,7 @@ function createRootOpts() {
delivery: state<ChatMessageDelivery | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined),
posinset: state<number | undefined>(undefined),
emphasized: state<boolean | undefined>(undefined),
onRetry: state<(() => void) | undefined>(undefined)
};
}

@ -17,6 +17,7 @@ interface ChatMessageOpts
delivery: ChatMessageDelivery | undefined;
ariaLabel: string | undefined;
posinset: number | undefined;
emphasized: boolean | undefined;
onRetry: (() => void) | undefined;
}> {}
@ -52,7 +53,8 @@ export class ChatMessageProvider {
props: {
delivery: () => this.opts.delivery.current,
ariaLabel: () => this.opts.ariaLabel.current,
statusLabel: () => this.statusLabel.current
statusLabel: () => this.statusLabel.current,
emphasized: () => this.opts.emphasized.current
},
events: {
// State lives in the app (delivery is a prop); the handlers

@ -16,6 +16,7 @@
delivery,
ariaLabel,
posinset,
emphasized,
onRetry = () => {},
children,
child,
@ -31,6 +32,7 @@
delivery: readableActive(() => delivery),
ariaLabel: readableActive(() => ariaLabel),
posinset: readableActive(() => posinset),
emphasized: readableActive(() => emphasized),
onRetry: readableActive(() => onRetry)
});

@ -47,6 +47,12 @@ export type ChatMessageProps = WithChild<
* not match feed order).
*/
posinset?: number;
/**
* Jump-to-message highlight (v2). Set `true` briefly (then clear after
* ~1.4s) to flash this row — e.g. after scrolling to it from a reply's
* `onJump`. Mirrors `data-emphasized`; visual-only (silent).
*/
emphasized?: boolean;
/** Called when `retry()` is invoked on a failed message. */
onRetry?: () => void;
children?: Snippet<[ChatMessageSnippetProps]>;

@ -53,6 +53,21 @@
let showActions = $state(true);
let showReadBy = $state(false);
const readers = ['Ada', 'Linus', 'Grace'];
// Jump-to-message highlight: flash the row, auto-clear after 1.4s (the app
// owns the timing — here via uix.timers, never a raw setTimeout).
let emphasized = $state(false);
function pulseEmphasis() {
emphasized = true;
uix.timers.schedule(
null,
1400,
() => {
emphasized = false;
},
{ meta: { source: 'chat-message-demo' } }
);
}
let text = $state('¿Vemos el plan del bloque de mensajería esta tarde?');
let retryCount = $state(0);
let lastJump = $state(0);
@ -189,6 +204,7 @@
{edited}
{mentioned}
{size}
{emphasized}
ariaLabel="Ada · 14:32"
onRetry={() => (retryCount += 1)}
>
@ -426,6 +442,12 @@
></span
>
</label>
<label data-uix-control>
<span data-uix-control-label>emphasized</span>
<button data-uix-chip data-active={emphasized} onclick={pulseEmphasis}>
flash (jump)
</button>
</label>
<label data-uix-control>
<span data-uix-control-label>text</span>
<input type="text" bind:value={text} />

@ -172,9 +172,21 @@
});
}
// Jump-to-message highlight: scroll to the target and flash it (auto-clear).
let emphasizedId = $state<string | null>(null);
function jumpTo(id: string) {
const index = rows.findIndex((r) => r.id === id);
if (index >= 0) logApi?.scrollToIndex(index, { align: 'center' });
if (index < 0) return;
logApi?.scrollToIndex(index, { align: 'center' });
emphasizedId = id;
uix.timers.schedule(
null,
1400,
() => {
emphasizedId = null;
},
{ meta: { source: 'demo-chat', kind: 'jump-highlight' } }
);
}
</script>
@ -229,6 +241,7 @@
direction={row.self ? 'out' : 'in'}
{run}
edited={row.edited ?? false}
emphasized={row.id === emphasizedId}
posinset={v.index + 1}
ariaLabel={`${row.author} · ${fmtTime(row.at)}`}
delivery={row.self ? row.delivery : undefined}

Loading…
Cancel
Save

Powered by TurnKey Linux.