Ubica el switcher de idioma por modo (spec del usuario):
- menubar → al final de la barra (antes de la papelera).
- flotante/drawer → en las migas, tras la miga «Documento».
palabras-breadcrumb: prop `langSwitcher` (suprime el switcher en menubar, donde
la barra ya lo tiene). FIX de UX: `setActiveLang` limpia el bloque activo (el id
viejo no vive en el nuevo doc) → el breadcrumb se ocultaba y el switcher
desaparecia tras el primer cambio. Ahora, si es multilenguaje, el breadcrumb
muestra siempre al menos «Documento» + switcher (crumbs sinteticos).
CSS sobrio (tokens del chrome, 0 hex): segmentado con punto de completitud por
idioma, forma select para >3. Demo = multilenguaje es/en.
Verificado (Playwright): flotante=1 switcher (miga, persiste, cambia el
contenido es↔en), menubar=1 (barra, sin duplicado), 0 errores de consola.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El `<Command>` eidos estampa data-depth="overlay" + data-list-surface, que
renderizaba su propio panel de paleta ⌘K (bg + borde + sombra + radio,
identico al del Popover) ANIDADO dentro del panel del picker — un panel dentro
de otro, evidente en modo oscuro. Ahora el contexto de busqueda viene del
Command.Provider HEADLESS de soma (sin chrome); Input/Empty siguen siendo las
sub-partes eidos estiladas.
Verificado en claro Y oscuro con capturas: [data-command] transparente
(0 borde, sin sombra) en ambos temas, un solo panel; busqueda 'rocket'->🚀 OK;
teclado/pick/tonos OK; check baseline 76.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nuevo componente emoji-picker y rediseño del escaparate de mensajeria.
emoji-picker (ruta 9 fases):
- Compone Popover (open/close/foco/portal) + Command (busqueda + grid + nav de
teclado + a11y combobox/listbox, su "grid mode" documentado) + dos ToggleGroup
(categorias + tono de piel). Delegated: sin eventos propios (D.1); el grid y
las celdas doble-registran Command.List/Item, conservando la a11y pero con
contrato visual propio (gana especificidad con selectores de 2 niveles, sin
tocar attrs [data-command-*] ajenos — eidos-lint limpio).
- Datos: $libs/emoji, dataset vendorizado de emojibase (MIT) horneado por
scripts/generate-emoji-data.ts (precedente lucide: JSON crudo en static/ +
script generador). ~1906 emoji con nombre CLDR + keywords, 316 con tono de
piel single-person. Grid acotado (categoria activa o resultados de busqueda),
sin virtualizacion (refinamiento v2). Helpers puros + test (12/12).
- morfo delegated (7 partes + header/footer de layout); soma (root + trigger +
content + grid + option + preview, provider-test 4/4); eidos (panel
empaquetado, receta con tokens --emoji-picker-*); langs es/en; demo 5 tabs
(excepcion delegated documentada); READMEs dobles.
Escaparate del bloque (web/routes/uix/demos/chat) — rehecho rico y correcto:
- Avatares por-autor con color solido (Avatar del ecosistema): antes gris
neutro indefinido, ahora teal/ambar/verde/indigo/primary bien contrastados.
- Dos columnas: chat-list (sidebar con badges + typing-en-lista) + sala.
- Conversacion rica: separador de dia, run agrupado, reaccion, reply citado,
recibos de lectura, typing con avatar, y el emoji-picker integrado en el
composer (inserta en el draft).
- Solape de burbujas (transitorio, estimateSize=48 con mensajes ~90-126px)
mitigado subiendo el estimate a 76.
Auditoria de tamanos/espacios: recipes del bloque limpios (todo con tokens
--space-*/--size-*/--color-*); unico literal (punto tono-capable) tokenizado.
Verificacion: check baseline 76 (0 nuevos) · component:audit emoji-picker PASS ·
eidos-lint invalid=0 · recipe-contract 30/30 · morfo:vocabulary 0 · emoji lib
12/12 · provider-test 4/4 · Playwright PASS (busqueda 'rocket'->rocket+
astronautas, click->onSelect+cierra, teclado, tono oscuro 👋->👋🏿, picker abre
en el composer). Capturas claro miradas.
next-features §7 marca emoji-picker SHIPPED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
palabras-lang-switcher.svelte (eidos): swap del idioma ACTIVO de un documento
multilenguaje. Reflejo del switcher de field-langs: radiogroup segmentado (≤3
idiomas) o Popover con radiogroup (mas), cada opcion con punto de completitud
(langFilled) y teclado de radiogroup (flechas/Home/End). Lee la `api`; el motor
posee el estado.
Api: expone `languageSpecs` (specs crudos con label/dir) para que el switcher
honre etiquetas explicitas; LangCode/LangSpec/PalabrasLangValues re-exportados
del barrel soma. Compila 0 errores.
PENDIENTE (chunk 3): CSS + ubicacion (menubar al final · migas tras Documento) +
demo + verificacion en navegador.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soporte multilenguaje del CONTENIDO a la field-langs, todo detras de la prop
`languages` (retrocompatible: sin ella, `value` sigue siendo PalabrasDocument).
Contrato:
- value: PalabrasDocument | Record<LangCode, PalabrasDocument>
- languages: LangSpec[] (presencia = modo multi; 1o = activo por defecto)
- requiredLangs: LangCode[] (validez; renombrado por colision con `required`)
- activeLang bindable + onLangChange; onValueChange sobre la union.
Provider:
- historia POR IDIOMA (langHistories): cambiar de idioma nunca cruza pilas de
undo; el activo es this.history, los inactivos en el mapa; seed en construccion
desde un snapshot del value map.
- writeExternalValue escribe el mapa ENTERO (raw) en multi; proxy-safe.
- syncExternalState resuelve el doc activo del value (doc|map).
- api: multilingual, languages, activeLang, setActiveLang, missingRequiredLangs,
langFilled; requiredLangs alimenta isInvalid.
Tests: 5 nuevos (modo+default · switch+writeback · historia por idioma ·
requiredLangs→validez · mono retrocompatible). 58/58 provider. check 0 en palabras.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documenta el resultado (el solver de contraste era innecesario: el morph HEREDA
el contraste de donantes §40-compliant) en los docs de diseño/código:
- docs/rfcs/rfc-color-engine.md: §15 nueva (resolución Stage 2 con las 3 bancas
de evidencia + decisiones D1–D6), anotación de fases §11 (Phase 0 = tipos
diferidos D5; Phase 3 = base→seeds NO se hace, D2), footer + §14.
- src/arts/color/README.md: CONTRAST_PAIRS en la API + nota "contraste heredado,
no resuelto" con puntero a RFC §15 y reference §40.
- src/uix/eidos/COLOR_ENGINE_RFC.md (stub): deja de sobreafirmar que el tipo
ColorScaleSeed existe (está diferido, D5) + puntero a §15.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up de 5aa8090ba. AA_TEXT_WCAG y STRONG_TEXT_STEP no tenían consumidor
externo: sus valores ya viajan en cada par (policy.wcag / policy.apcaCapStep),
así que exportarlos aparte duplicaba la fuente → consts internas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
syncExternalState comparaba `externalDocument !== current.document`: bajo un
binding `$state` (bind:value en el demo) el externo es un PROXY y el interno el
objeto crudo → Svelte avisa (state_proxy_equality_mismatch) en cada tecla, y
ademas el fast-path NUNCA cortocircuitaba (proxy !== raw es siempre true → el
deep-compare O(documento) corria igual, anulando el fix de perf del base64).
Cacheo `publishedExternal` = la vista del store (proxiada) al escribir, y comparo
proxy↔proxy. writeExternalValue: fuera el guard `!==` (asignar el mismo objeto es
no-op en Svelte) + cachea el read-back; publishState e init ruteados por el.
Verificado (Playwright, tecleando): 0 state_proxy warnings; el round-trip propio
cortocircuita de verdad. 53/53 provider verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Asimetria: t() (engine) y ts() (mono) devuelven el |fallback en SILENCIO,
pero ts() de engine iba via resolveValue, que emitia REFERENCE_FALLBACK_USED
(WARN) en cada miss-con-fallback → consola ruidosa en DEV para el patron
`#?key|fallback` (i18n gradual: fallback inline, claves de catalogo despues).
El |fallback es el default deliberado del autor, no una condicion avisable.
Gate del emit a `depth > 0`: solo avisa cuando una cadena de referencia
ANIDADA cae al fallback (ref interna rota), no en el lookup de nivel superior.
Alinea engine-ts con t() y mono-ts. Sin test que fijara el warn; 132/132 verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firma del usuario: dirección estética = SOBRIO-DOCUMENTAL (con firma-en-el-
momento) — DG-13. Consolidada en DESIGN.md §0.
FD.3 (spec visual) resultó YA REALIZADO al reconstruir el chrome limpio y
hacer el canvas scheme-driven; verificado en navegador (claro + oscuro):
- estructura: lienzo full-width, grip en carril superpuesto, panel flotante
con data-depth="overlay" (elevación del framework, cero z-index sueltos).
- medida del canvas: scheme-driven (--palabras-scheme-doc-measure, default
ancho completo; 66ch = preset del esquema).
- tokens del chrome: SIN sopa — palabras-chrome.css tiene 0 hex / 0 oklch /
0 ms crudos; color/espacio/duración por herencia de los tokens del framework.
- modo oscuro VERIFICADO: el chrome invierte por tokens (cero media-queries
hand-rolled); el documento conserva su esquema (doctrina back-office).
DESIGN.md: nuevo §11 (sistema visual realizado + verificado, pendiente de
firma) + §12 ABIERTO acotado (acento del tab activo · deuda menor de
tokenización · densidad/reduced-motion · FD.4 firma perceptual, requiere permiso).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
La ejecución refutó la premisa del plan: el morph HEREDA el contraste del texto
(steps 11/12 = curva-L del donante verbatim; ~invariante al croma bajo
gamut-mapping), así que toda escala desde donantes §40-compliant cumple por
construcción. Medido en 3 bancos (base, leave-one-out, 45 semillas OOD): 0 fallos
del gate duro text-strong·12, min WCAG 9.7:1. El solver de luminancia no tenía
nada que resolver → descartado por especulativo. D2 = base verbatim (sin
migración base→seeds).
- $color: CONTRAST_PAIRS (arts/color/contrast-contract.ts) — tabla §40 como datos
compartidos: floor duro text-strong·12, banda text·11 (tope relacional, sin nº
mágico), border·7 exento
- scripts/contrast-audit.ts: muere la PAIRS pre-veredicto (4.5 duro contra §40),
consume CONTRAST_PAIRS + banco de regresión morph-generado
- eidos/lib/contrast-invariant.test.ts: guard de CI (4 tests) que bloquea la herencia
- docs: reference.md §40, next-features.md §1, changelog.md §45, plan (OUTCOME)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Primer paso de la puerta FD (PLAN-EJECUCION §FD.1): reúne en un canon
citable las decisiones de diseño YA firmadas, hoy dispersas entre
continuar-palabras.md, comentarios de código y commits. Pura consolidación
(nada nuevo), cada entrada con su fuente. Hermana de INTERACCION.md (FD.2,
ratificada) y ESQUEMA.md (contrato del documento).
Secciones §0-§10 = lo decidido; §11 ABIERTO = lo que FD.3 (mood) y FD.4
(firma perceptual) todavia deben firmar — no se prejuzga aqui.
Pendiente: tu revision (verify de FD.1 = consolidacion fiel).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
La whitelist `editable` ya decidía si el mega-control Tipografía aparece
ENTERO (schemeAllowsField); faltaba apagar filas INDIVIDUALES con las
claves con punto (`typography.size`…). Refleja el patrón que ya existe
para `adjustments` de imagen.
- PalabrasTypography gana `enabled: 'all' | readonly string[]`. UN solo
mecanismo de visibilidad: `show(key)` unifica el `essential` existente
(inspector corto de la barra) con el `enabled` del esquema — no dos ejes
{#if} paralelos sobre las mismas filas.
- Vocabulario canónico (11 filas, nombres semánticos cortos que extienden
los `size`/`tracking` ya fijados en ESQUEMA, no los nombres CSS):
font·style·weight·size·leading·tracking·word·measure·case·wrap·tabular.
Documentado en ESQUEMA §6 (tabla clave→fila→prop) + apuntado desde types.ts.
- Ambos consumidores (panel flotante/acoplado + inspector de barra) pasan
`enabled={schemeSubControls(scheme, kind, 'typography')}`.
- Test: `typography.size,typography.leading` → sufijos `['size','leading']`.
Verificado Playwright: párrafo gateado a size+leading muestra SOLO Tamaño +
Interlineado; encabezado sin declarar muestra las 11 filas (retrocompatible).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El campo Familia del bubble mostraba el placeholder «Fuente» cuando el
rango no tenía override `fontFamily`, aunque el texto SÍ se pinta con la
fuente del esquema. Ahora muestra la fuente efectiva (nunca en blanco),
en paralelo con el tamaño efectivo ya existente (`effectiveSize`).
- `schemeFontFor(scheme, blockType, node)` en palabras-scheme.ts: la fuente
del elemento (`styles[el].fontFamily`) con fallback a la base `text`.
Casa con un `allFonts[].value` porque los estilos por defecto y las
opciones de fuente comparten las constantes SERIF/SANS/MONO.
- palabras-bubble.svelte: `effectiveFamily = currentFamily ?? schemeFontFor(...)`;
el Select de Familia usa `effectiveFamily` para value/data-empty/label.
- Tests: 2 casos de `schemeFontFor` (resolución elemento→text; override h1).
Verificado en Playwright: párrafo y encabezado muestran «Georgia»
(efectiva), sin `data-empty`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename + completa la utilidad de 013ef5afc:
- `DataUrlBlobUrls` → `DataUrlBlobCache` (nombre torpe: doble «Url» + plural);
fichero data-url-blobs.* → data-url-blob-cache.*.
- Añade `prune(keep)`: revoca + descarta los object URLs cuyo `blob:` URL ya no
está en `keep` (el set de `<img>` montados), liberando el blob de una imagen
borrada sin esperar a `dispose()`. Antes solo se revocaba todo al desmontar
(leak acotado a la sesión).
- palabras-image-blobs: tras el swap colecta los `img[src^="blob:"]` montados y
poda. Docs (imagenes-base64 / handoff) actualizados.
Verificado: 6 tests adom (incl. prune revoca-solo-no-keep) + check 0 errores;
Playwright: el tecleo NO se regresa por la query de poda (9.2ms). La lógica de
poda queda por unit test; el borrado end-to-end no se pudo conducir fiable en el
harness (mismo flake del menú/panel de imagen), cableado trivial sobre el effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Componente nuevo del bloque chat v2: el panel de lista de salas/DMs.
Compone el Listbox del ecosistema (doble registro id/ref compartido):
selección única + roving-tabindex + typeahead + role=option vienen de
Listbox; chat-list aporta la disposición de fila (avatar · título/preview ·
meta) y el estado visual data-unread (negrita + Badge compuesto).
- morfo: expression 'delegated', sin eventos propios (D.1 — el actor es el
Listbox compuesto); parts provider/item(data-unread)/avatar/title/preview/meta.
- soma: ChatListProvider crea ListboxProvider dentro (value string <-> string[]);
ChatListItemProvider per-instance (unread = boolean crudo); 4 clases de
contenido explicitas.
- eidos: rejilla avatar | (titulo/preview) | meta, avatar y meta abarcan las
dos filas; truncado; unread -> titulo en negrita. No neutraliza el arquetipo
item (la conversacion ES una opcion seleccionable). Sin @keyframes; ejes
logicos.
- demo: 5 salas, seleccion limpia unread, typing-en-lista (compone ChatTyping),
Badge de no-leidos; tabs live/system/api/morfo/a11y.
- READMEs dobles (eidos con Passive justification, delegated); next-features 7
marca chat-list SHIPPED.
Verificacion: check baseline 76 (0 nuevos) · component:audit PASS ·
eidos-lint invalid=0 · recipe-contract 30/30 · morfo:vocabulary 0 · Playwright
PASS (role=listbox, 5 salas, aria-selected, unread=2, badges=2, typing-en-lista,
click + teclado seleccionan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Corrige la conclusión de la investigación (la hipótesis DOM-src era errónea; el
CPU profile dio samePalabrasDocument = 34%). imagenes-base64.md §Causa REAL +
handoff §2026-07-19: fast-path d96e4cde7 (20→12ms) + blob 013ef5afc (12→8ms).
Lección anotada: perfilar ANTES de teorizar en atascos de perf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan de ejecucion para sesion aparte, VERIFICADO adversarialmente (workflow de
7 agentes contra codigo y docs) antes de escribirse — corrige 5 afirmaciones
de la respuesta que lo origino:
- "por delante de Radix (hex sRGB)": FALSO — el propio RFC §14 registra Radix
con APCA y P3 ("Radix style"); la posicion del repo es "on par"; nuestra
paleta enviada es sRGB-equivalente (oklch = siblings de hex).
- "el solver satisface la tabla §40": el UNICO floor duro ratificado es
text-strong·12 ≥ 4.5; text·11 quedo tier blando (decision D1); solid·9 esta
anclado exacto a la semilla; el script de auditoria aun codifica la tabla
PRE-veredicto (consumirla contradiria los veredictos → F0 = tabla como datos).
- "los tipos ya existen en parte": ColorScaleSeed/Source son solo prosa del
RFC; config-seeds imposibles hoy (solo applyColorScheme roles-only +
generatePalette full-33 como bancos).
- "792 hex en base.ts": exacto en total pero en DOS ficheros (288 base.ts +
504 color-scales.ts); 4 escalas ya autoradas desde semillas offline.
- "nada downstream cambia": nombres si (contract.ts), pero hay pins de VALOR
disenados para dispararse (hex literales, flip-set de polaridad,
generated/base.css byte-exacto) + persistencia rechaza version ≠ 1.
El plan: F0 tabla ratificada como datos + harness sobre output generado ·
F1 solver (biseccion contra sRGB gamut-mapeado, salida hex, desviacion minima
del donante) · F2 validacion en bancos de semilla + navegador · F3 (gated)
migracion base→seeds con re-ratificacion de pins y pase visual. 6 decisiones
de usuario ABIERTAS al frente (target text·11, semantica base+ΔE, modo
on-solid, colocacion/alcance, tipos Fase 0, normalizacion de la tabla).
next-features §1 enlaza el plan. (El hunk ajeno de chat-list en next-features
queda fuera del commit — danza clean-desde-HEAD.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Utilidad transversal + su primer consumidor (palabras). Win SECUNDARIO sobre el
fast-path de d96e4cde7 (no es el fix del atasco; ese era el deep-compare).
- `$adom.DataUrlBlobUrls` (NUEVO, `arts/adom/data-url-blobs.ts`): caché stateful
que resuelve `data:` URLs a `blob:` object URLs (decodifica base64/percent),
cachea por identidad, `dispose()` revoca. Realm same-origin. 5 tests.
DECISIÓN de ubicación (valorada): NO va en `<Image>` — ningún consumidor de
`<Image>` pasa base64 (image-picker/file-upload ya usan createObjectURL de un
File; el resto URLs reales) → sería abstracción prematura. Es una utilidad
reutilizable que `<Image>`/ImageProvider pueden adoptar si algún día aparece un
consumidor base64.
- `palabras-image-blobs.svelte` (NUEVO): tras cada render swapea el `src` de los
`<img data:…>` del contenteditable por el blob (el MODELO conserva el base64 →
persistencia; el DOM queda ligero). Re-run por `blocks`; el `<img>` memoizado
mantiene el blob entre teclas.
Medido (base64 de 2MB, con el fast-path ya puesto): tecleo 12ms/char → 8ms/char
(el `src` de MB en el DOM sí añadía ~2-4ms). check 0 errores; Playwright: `src`
= `blob:` tras cargar, tecleo 8.1ms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CAUSA REAL del «atasco» de imágenes base64 (hallada por CPU profile: 34% de las
muestras). El sync del `bind:value` (`syncExternalState`) comparaba el `value`
bindeado contra el doc interno con `samePalabrasDocument` — una comparación DEEP
O(documento) — EN CADA cambio. Como el editor escribe `value` con la MISMA
referencia del doc interno (publishState → `this.document`), esa comparación
recorría el string base64 (MB) por tecla para nada.
Fix: un check de igualdad por REFERENCIA antes del deep-compare — el caso común
(nuestras propias ediciones) se resuelve en O(1); el `samePalabrasDocument` solo
corre cuando la referencia difiere de verdad (una escritura externa).
Medido (imagen base64 de 2MB en el modelo): tecleo 20ms/char → 12ms/char. NO era
el `src` grande en el DOM (mi hipótesis inicial, errónea) — era el deep-compare.
Verificado: provider 53 tests verdes; check 0 errores palabras.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Actualiza el handoff en el design-record del bloque: v1 completo (incl.
onMessageSeen), los 3 remates de v2 shipped (typing avatars, read-by,
jump-to-message highlight con su decisión de visual-puro-sin-sema y el
gotcha del boolean en attrs de presencia), y los frentes v2 restantes
(chat-list, emoji-picker, threads, menciones, rangos bidireccionales) por
valor/independencia. docs:check 0 errores.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- imagenes-base64.md (NUEVO): investigación completa del atasco (raíz base64-en-
modelo sin adapter; descartado historia/render; fix de perf 79a7e4dea html→blocks;
residual ~4ms/MB intrínseco del navegador) + 4 opciones de fix de raíz (PENDIENTE
de decisión) + método de repro. Para retomar mañana sin re-investigar.
- continuar-palabras.md: bitácora del día (copyBlock + investigación + perf fix) +
«Próximo» apunta el base64 a DECIDIR+implementar (ver el doc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigando el atasco de imágenes base64 se destapó un anti-patrón general:
SEIS effects del chrome (bubble/slash reposition · activate re-stamp · grip/
column-inserter/table-resize remeasure) dependían de `provider.html` como señal
de «cambió el contenido». Pero `html` es el `join('')` O(documento) de TODOS los
htmls de bloque, así que cada tecla lo recomputaba + reasignaba el string entero
(con imágenes base64 en el modelo, MB por tecla; con docs de texto grandes, O(texto)).
Migrados a `provider.blocks` / `api.blocks` — la MISMA señal (el array que el
render ya computa, referencia nueva por cambio, cubre contenido + decoraciones)
pero O(1). `html` deja de computarse en el hot path (solo en export/tests).
Comentarios en cada sitio para que no vuelva a `html`.
NOTA: este NO es el coste dominante del atasco base64 (medido: ~igual antes/
después). El residual (~4ms/MB/char) parece intrínseco del navegador — un
atributo `src` de varios MB en el DOM del contenteditable ralentiza el tecleo.
La raíz (base64 en el modelo por falta de adapter `onUploadImage`) se decide
aparte. Este commit es una mejora de hot path independiente, correcta para docs
grandes en general.
Verificado: check 0 errores palabras; Playwright — grip aparece al activar,
bubble aparece y se posiciona al seleccionar (top 363), 0 errores de consola.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Segundo remate de v2. chat-message gana la parte opcional `ReadBy`: una
fila de avatares "visto por N" que la app compone (AvatarGroup), típica
de mensajes propios; el emisor la alimenta con los acuses recolectados
vía el onMessageSeen (v1). DECORATIVA (aria-hidden): el indicador Status
ya transmite "read" a AT; las caras son realce visual.
Morfo part + soma provider/wrapper + eidos wrapper/recipe (fila de
avatares muted, ejes lógicos in/out) + demo con toggle. next-features §7
marca read-by y typing-avatars como shipped.
Verificado (Playwright): la fila aparece con 3 avatares + "Visto por 3",
aria-hidden, en la columna del cuerpo. component:audit PASS, eidos-lint
invalid=0, recipe-contract 30/30, check en baseline (76).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
continuar-palabras.md §2026-07-19: bullet de copyBlock (8215a9f41) en «Lo hecho»;
quitado de la deuda del motor en «Próximo».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cierra la deuda del motor «copy = hack de outerHTML». El chrome scrapeaba el
`outerHTML` renderizado del bloque activo (con sus `data-palabras-*`) al
clipboard; ahora hay un comando simétrico que serializa desde el MODELO.
- palabras-provider.svelte.ts: método `copyBlock(index)` (async) — serializa el
bloque top-level envuelto en un doc de 1 (`serializeHtml` → text/html limpio +
`renderPalabrasPlainText` → text/plain) y lo escribe al clipboard realm-aware.
Guard `typeof ClipboardItem` (fallback writeText en realm sin él). Non-mutating
→ funciona read-only. Expuesto en el api snippet + ProviderSnippetProps (types).
- palabras-menubar.svelte: `doCopy` usa `api.copyBlock(loc.path[0])` (fuera el
`new ClipboardItem/Blob` + outerHTML); gate `!loc`; announce «Bloque copiado».
- palabras-announce.ts: `PALABRAS_ANNOUNCE.copied`.
La serialización (doc de 1 bloque) ya la cubren los tests de serialize-html /
plain-text. Langs es/en de `announce.copied` DIFERIDO (entanglement; ts fallback).
Verificado Playwright (permisos de clipboard): copiar el H1 → clipboard
`text/html` = `<h1>Cómo migrar sin downtime</h1>` LIMPIO (0 `data-palabras-*` →
es serialización del modelo, no scrape), `text/plain` correcto, announce «Bloque
copiado»; 0 errores. Ese HTML limpio es justo lo que pasteAfterBlock parsea →
round-trip simétrico. check 0 errores palabras.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cierra Stage 1 de la iniciativa next-features §1 con los veredictos del usuario:
- reference.md §40 (doctrina standing): contrato de contraste por pares de
slots. Texto = dos tiers (text·11 secundario ≈APCA 60 / text-strong·12 AA
garantizado). Bordes = dos tiers WCAG 1.4.11 (decorativo exento: accent·7,
subtle·4/default·6, reposo-sobre-fondo · portador 3:1: solid·9 lo cumple, el
resto con señal redundante). Foco = eje de config (primitives.focusRing +
color.focus, un solo `outline` por §32); default suave, endurecer = valores
por config, no código.
- changelog.md §44: el chronicle datado (drift medido + veredictos + la
herramienta scripts/contrast-audit.ts).
- next-features.md §1: Stage 1 marcado DONE; Stage 2 (generador
by-construction) sigue abierto, gated en la migración base→seeds.
Sin cambios de código (el hallazgo del foco es una decisión de valores-por-
defecto vía config; defaults mantenidos por decisión del usuario). El script
de auditoría (commiteado antes) queda como herramienta de medición.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Primer remate de v2 (elegido por el usuario). chat-typing gana una parte
opcional `Avatars`: un slot antes de los dots donde la app compone
`AvatarGroup` / `Avatar` para quienes escriben. Decorativo (aria-hidden:
la label ya los nombra). Morfo part + soma provider/wrapper + eidos
wrapper/recipe (layout mínimo; el AvatarGroup ya apila) + demo con toggle.
Verificado (Playwright): slot presente con 2 avatares al activar 2 typers,
aria-hidden, antes de los dots; ausente en idle; toggle off = solo dots.
component:audit PASS, eidos-lint invalid=0, check en baseline (76).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
El handoff listaba «aria-live announce» como próximo, pero se hizo en 5150bfb38.
- continuar-palabras.md §2026-07-19: bullet de F3.6 announce en «Lo hecho»;
quitado de «Próximo»; sus keys de langs añadidos a la deuda diferida.
- PLAN-EJECUCION.md item 6 (A11y del chrome) → ✅ (Ctrl+. a8a75b196 + announce
5150bfb38 + focus-trap/breadcrumb del framework).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extiende el script de Stage 1 con el análisis que pidió el veredicto de
usuario: mide TODOS los tokens semantic-border (subtle·4 / default·6 /
strong·8 / focus-ring / focus-ring-error / invalid·7 / checked·9), cada uno
contra las 3 superficies (default·1 / raised·2 / muted·3) en ambos modos,
marcando load-bearing (3:1 requerido) vs decorativo (exento). Añade
compositing sRGB para los focus rings translúcidos (color-mix @ ~50% alpha).
Fix: oklchToGammaRgb devuelve [0,1] en tupla [r,g,b] (no {r,g,b} ni [0,255]).
Hallazgo: todo el vocabulario de border vive en el rango sutil 4-8 (contrato
Radix); el único que cruza 3:1 es el solid·9 (checked/selected). El resto es
correcto como decorativo salvo el FOCO (indicador único, WCAG 2.4.11) → se
verifica en navegador aparte.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
El único item del alcance v1 acordado (plan §4.2, "lectura por visibilidad")
que se había quedado fuera al cerrar F3. chat-log gana el hook
onMessageSeen(id): observa las filas renderizadas con IntersectionObserver
(root = viewport) y dispara UNA vez por id al entrar en vista (dedup interno
vía seenIds); la app manda el acuse e ignora los ids que no son mensajes
(los eventos de sistema también disparan).
Verificado en navegador (Playwright): 8 filas vistas al montar → 12 al
revelar historial arriba → 12 al volver (dedup; nunca supera las filas
totales). Provider test (harness) + lector "seen" en la demo + README +
tabla API. component:audit PASS, check en baseline (76).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cierra la pata de a11y pendiente de F3.6 (announce). El chrome anuncia sus
acciones estructurales por una única región viva compartida, para lectores de
pantalla.
- palabras-announce.ts (NUEVO): contexto ligero `announce(ref)` (`ts()` ref, el
shell lo resuelve → los llamadores quedan locale-agnósticos) + vocabulario
`PALABRAS_ANNOUNCE` (mover arriba/abajo · eliminar · insertar · pegar ·
combinar · dividir), compartido por menú y panel. Vía CONTEXT (no props)
porque los anunciadores están repartidos por menubar/panel/docPanel, todos
descendientes del único shell que posee la región.
- palabras.svelte: región `[data-palabras-live]` (role=status, aria-live=polite,
aria-atomic) al pie del shell + `setPalabrasAnnounce`. Clear-then-set en el
frame siguiente (`dom.requestFrame`) para que el SR re-anuncie un mensaje
idéntico consecutivo (solo dispara al cambiar el TEXTO).
- palabras-chrome.css: región visualmente oculta (clip/1px).
- menubar + panel: `announce(...)` en mover/eliminar/insertar/pegar + merge/split
(handlers `doMerge`/`doSplit` extraídos, dedup del inline).
Langs es/en de los mensajes DIFERIDOS (fichero de langs entangled; `ts()` por
fallback castellano).
Verificado Playwright headless: región role=status/aria-live=polite/oculta;
«Mover abajo»→«Bloque movido abajo», Insertar→«Bloque insertado» (texto real de
la región); 0 errores. Delete/merge/split = mismo `announce()` de una línea en
el mismo patrón de handler (verificados por consistencia; el harness no conduce
fiable el menú Borrar por gating/hidratación).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pase de medición de la iniciativa #1 de next-features: comprueba si las
promesas de contraste por par de slots se cumplen en las 33 escalas × 2 modos,
reutilizando la matemática de `$color` (apcaLc · wcagContrastRatio ·
oklchToGammaRgb · safeParseColor). WCAG 2 = gate normativo (4.5:1 texto /
3:1 no-texto); APCA Lc reportado al lado. Tabla de pares = las promesas
declaradas (texto·11/12 sobre track·1/bg·2/element·3; border·7 sobre track/bg).
Solo mide — no cambia emisión. Correr: `node --import tsx/esm scripts/contrast-audit.ts`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
La entrada #6 (THM-2 rollout) decía que los controles semánticos (checkbox/
radio/stepper/select/file-upload) y `Avatar.Badge` quedaban role-restricted
"por diseño". La iniciativa open-color-cage (2026-07-18/19) revirtió justo eso:
`color` = sistema completo en TODOS los componentes. Se añade la nota de
reversión con puntero al handoff; qr-code queda fuera por diseño. Sin cambios
de código.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documentación del bloque chat-* recién shippeado, siguiendo la convención
del corpus (por-componente = READMEs dobles; por-lib = README; doctrina de
bloque = design record en decisions).
- docs/decisions/design-chat-block.md: la doctrina cross-cutting que abarca
los 4 componentes — la tesis comparativa (5 fallos del sector = nuestras
oportunidades), la topología de composición (triple registro Feed +
VirtualList anclado, Textarea, FileUpload, Icon lucide), el anclaje
(sticky monótono, pin contra DOM real), la doctrina visual del rediseño
de referencia y los 2 fixes de framework que destapó. Indexado en
decisions.md (sección "Component families — design records").
- src/libs/chat/README.md: los helpers puros (groupIntoRuns ventana 8min,
aggregateTypers, límites de día) — convención de README por lib.
docs:check 0 errores (503 docs). Los READMEs por componente y next-features
§7 ya iban en el commit del bloque (4c4878f13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Familia chat-* completa, componiendo el ecosistema (Feed + VirtualList
anclado, Textarea, FileUpload, Avatar, Popover, Icon). Transporte-agnóstica:
el modelo de datos es de la app, los componentes reciben props + snippets.
- chat-log: Feed + VirtualList(anchor:'end') en triple registro; day
separators, unread divider, filas de sistema (Feed.Article), pill "N
nuevos" + notifyIncoming (announce a live-region), to-latest distance-aware.
- chat-message: raíz Feed.Article; runs ($libs/chat), ticks de entrega,
reply embebido, reacciones (chip pressed + tapback panel), acciones hover.
- chat-composer: Textarea (doble registro) + FileUpload; teclado
Enter/Shift+Enter/Esc/ArrowUp; typing saliente debounced (uix.timers);
barra reply/edit.
- chat-typing: sustain-streaming (visual-only, loop del sistema);
aggregateTypers 1/2/3+ con plantillas plurales.
- $libs/chat: groupIntoRuns (ventana 8 min estilo Discord) + aggregateTypers.
- VirtualList: extensión anchor:'end' (followOutput, prepend por clave
estable, sticky monótono, onAtEndChange) — sin scroller paralelo.
Diseño de referencia (WhatsApp/Telegram, pase lado-a-lado): burbujas suaves
(out = primary-element + texto normal; in = surface card + sombra) sobre
lienzo muted del log, meta dentro de la burbuja, tapback bar horizontal,
iconos lucide del ecosistema (nada de SVG a mano). Contrato de tamaños:
size="md" = 16px canónico.
Fixes de framework (afectaban más allá de chat):
- EngineLangs.t() ahora pela el prefijo '#?': los idlangrefs imperativos
(pagination/file-upload/chronos/chat) caían al fallback EN en locale es.
Fix parseLangRef ?? parsePathFallback + test de regresión (132/132).
- virtual-list commit-set-resize con channels:[]: es bookkeeping de
ResizeObserver, no gesto → dejaba de emitir beep/vibrate pre-interacción
(interventions de navigator.vibrate / AudioContext).
Escaparate messenger en /uix/demos/chat (bot de eco con uix.timers;
variante $connection señalada). Gaps v2 registrados en next-features §7.
Pase: component:audit 4×PASS · eidos-lint invalid=0 · recipe-contract
29/29 · Playwright escaparate PASS end-to-end · consola limpia ·
npm run check en baseline (76, cero errores nuevos).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Actualiza el handoff y el changelog §43 al estado FINAL de la iniciativa
(cerrada, cuarta sesión):
- open-color-cage-2026-07.md: añade las 2 fugas transitivas cerradas
(card-group-item · s-text) + el guard runtime, el fix del bug de Card/Avatar
(`b93cc6c5c`), la lista completa de commits (sesiones 3–4), y una sección
«Follow-ups» con lo menor que queda (override docs-chrome sobre `<Code>`,
ringColor roles-only, límites de los 2 guards, comentarios históricos) +
nota del estado del working tree (WIP de chat/palabras sin commitear, ajeno).
- changelog.md §43: el bug de Card/Avatar pasa de «flageado, NO arreglado» a
ARREGLADO con el resumen del fix (migración a resolveComponentColor + compose
del seed + canales ring/badge separados intactos).
Sin cambios de código. La iniciativa queda CERRADA sin huecos abiertos
(qr-code excluido por diseño). Contract 30/30.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BUG (descubierto en la verificación adversarial de la iniciativa): un color
CSS crudo (p.ej. `color="#3b82f6"`) NO pintaba el FILL de Card ni Avatar —
caía a neutral. Card/Avatar (Fase 2a) usaban un split bespoke pre-helper
(`isCanonicalColor` + seed `--{c}-color-custom` + bloque derivador
`[data-{c}][data-color-custom]{--_{c}-palette-*: …}` en su .css). Ese bloque
quedó ENSOMBRECIDO por el forward compartido de THM-2 en `generated/base.css`
(misma especificidad (0,2,0), carga después → gana); para un valor crudo
`--palette-*` está sin definir (el shared layer lo deriva de `--color-custom`,
NO de `--{c}-color-custom`) → host neutral.
Fix (los alinea con TODO el resto de la iniciativa):
- card.svelte / avatar.svelte: el split bespoke → `resolveComponentColor`
(genérico: canónico→`data-color`; crudo→`data-color-custom` + seed
`--color-custom`, que el forward YA consume). Ambos ahora COMPONEN el seed
con el `style` del consumidor (`composeInlineStyle`) para que `...rest` no lo
pise — Card lo pisaba (style antes de rest), por eso el custom se perdía
incluso con el seed correcto.
- card.css / avatar.css: borrados los bloques bespoke `[data-{c}][data-color-
custom]{…}` (muertos + ensombrecidos). Las variant slices leen
`--_{c}-palette-*`, que el forward llena para rol/escala/custom uniformemente.
- Avatar RING y BADGE son canales de color SEPARADOS (`--avatar-ring-color-
custom` / `--avatar-badge-color-custom`): NO se tocan — usan su propio
namespace de tokens, el forward no los ensombrece y funcionan (verificado).
- base.ts: quitados los 4 tokens de recipe muertos (`card`/`avatar` ·
`color-custom` + `color-custom-contrast`); ring/badge se conservan.
Verificado en Chrome: Card real (picker del demo) + Avatar sintético pintan el
custom en soft/solid/outline/ghost, componiendo el style del consumidor;
roles/escalas siguen exactos (behavior-preserving); ring + badge intactos.
Orphan test resuelto (los `-contrast` ya no huérfanos). Los 2 fallos residuales
del contract son WIP de chat-composer (otro track, sin commitear), ajenos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cierra la UI de F4 (las tres piezas elegidas por el usuario: listas · merge/split
· count/TOC · resize). Alcance v1 acordado: solo COLUMNAS (sin filas ni snap).
palabras-table-resize.svelte (NUEVO): overlay de tiradores en el borde superior
de cada tabla top-level (uno por borde interno de columna), materializados solo
al pasar el puntero por la tabla (mirror de palabras-column-inserter: medición
frame-relative, remeasure en api.html + scroll/resize). Arrastrar un tirador
redimensiona la columna a su IZQUIERDA:
- feedback EN VIVO por inline `width` en la celda de la 1ª fila durante el drag
(el navegador dimensiona la columna por la celda más ancha),
- commit a `cell.width` en UN paso de historia al soltar (nada durante el drag),
- doble-clic = auto (limpia el width).
`cell.width` es una LONGITUD CSS en string (el campo `length` del panel commitea
p.ej. "314px") — un número crudo renderiza `width:314` inválido; el commit usa
`${w}px`. Restringido a tablas TOP-LEVEL (path del commit `[table,0,col]`).
Filas + snap a fracciones (1/2·1/3·2/3) diferidos a v1.1. Langs `resize-column`
diferido (entanglement; ts() por fallback).
Verificado Playwright headless: hover tabla → 2 tiradores (3 columnas); arrastrar
+80px → col0 234→314 (las otras se ajustan 183/196), commit width:314px; doble-
clic → auto restaurado (234/221/238); 0 errores consola. Screenshot MIRADO
(tirador de acento en el borde hover). check 0 errores palabras.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cierra F4.7. Los derivados wordCount/charCount/tableOfContents ya vivían en el
snippet del provider; faltaba su affordance. Decisión del usuario (2026-07-19):
barra de estado al pie con el recuento (readout pasivo) + botón «Índice» que
abre un popover de encabezados; clic hace scroll al heading.
- palabras-status.svelte (NUEVO): «N palabras · M caracteres» + `<Popover>` con
`tableOfContents` (sangrado por `level` vía data-level); jumpTo hace
scrollIntoView del `[data-palabras-id]` del encabezado y cierra el popover.
El trigger se deshabilita sin encabezados.
- palabras.svelte: montada al pie del shell (los 3 modos — el recuento es un
readout del documento, no una propiedad; el usuario lo quitó del panel).
- palabras-chrome.css: sección [data-palabras-status] + outline (tokens).
El índice va en affordance PROPIA (no el panel de propiedades — decisión del
usuario 2026-07-18: es navegación, no una propiedad de página).
Langs es/en (status.words/chars/outline/untitled) DIFERIDOS (fichero de langs
entangled con el merge pendiente; los `ts()` renderizan por fallback).
Verificado: check 0 errores palabras; Playwright headless: «45 palabras · 253
caracteres», popover con 2 encabezados (niveles 1/2), clic en «Estrategias» →
popover cierra + h2 en vista; 0 errores consola. Screenshot MIRADO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chart era el último hueco de la iniciativa. Su `color` NO tinta labels/ejes:
tinta la MARCA de datos (stroke de línea, fill de área/barra/burbuja, hue de
heatmap, arco de gauge, porción de pie/funnel/bar-list/polar, punto de smith).
Es SVG con resolución JS, así que NO usa el `data-color` + capa de paleta
compartida (eso es para cascadas CSS en HTML) — el mecanismo es propio.
Resolver central en `chart/context.ts`:
- `seriesColor` / `seriesSurface` reescritos + `seriesContrast` nuevo, todos
`number | ComponentColorProp`. Un índice cicla `SERIES_ROLES` (multi-serie,
sin cambios). Un valor explícito pasa por `resolveChartColor`:
· rol/intent → `var(--color-{role}-{solid,surface,contrast})`
· una de las 33 escalas → `var(--scale-{name}-{9,a2})` (steps solid /
surface-alpha del `PALETTE_SLOT_STEP`); contrast → white (no hay token)
· valor CSS crudo → verbatim (surface = `color-mix 15%`, contrast = white;
sin contraste garantizado, el trade-off de cualquier custom).
- `ChartContext.color` widenado a `number | ComponentColorProp`.
Todos los props `color?: ColorRole` (~14: ChartSeriesProps, Bubble, Sparkline,
ChartCategory, BarList, Funnel, Calendar/Heatmap, Smith×2, Gauge) →
`ComponentColorProp`. Los 3 builders que armaban el fill inline (funnel:
solid+contrast, heatmap:56, calendar-heatmap:118) refactorizados para pasar
por el helper — funnel pierde su `roleOf` local.
qr-code queda FUERA por decisión de diseño (color = tinta de módulos del QR,
contraste con el fondo, no encaja escalas).
Verificado en Chrome: las 3 ramas del resolver resuelven a color real
(--scale-teal-9→oklch, --scale-teal-a2→rgba translúcido, #3b82f6→verbatim,
mix translúcido ok); marcas existentes = primary-solid (roles behavior-
preserving). check chart limpio; contract 30/30. Docs: changelog §43 +
handoff actualizados (hueco chart cerrado, qr-code excluido por diseño).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expone los comandos mergeTableCells/splitTableCell del motor en el chrome,
gateados a celda activa de tabla top-level.
Desviación declarada respecto a "solo UI": los comandos resolvían la celda vía
`state.selection`, que el chrome PIERDE al abrirse fuera del editor (el menú
Editar / el panel). Ajuste mínimo del motor, consistente con mover/eliminar
(que ya operan por `loc.path`): mergeTableCells/splitTableCell aceptan un `at`
explícito `[block,row,cell]` (fallback a la selección). Test nuevo con
`selection: null`.
UI (eidos):
- palabras-block-actions.ts: `tableCellActions(loc, activeEl)` deriva inCell +
`at` (desde loc) + canMergeRight/Down/Split (geometría del <td> renderizado) +
dispatch `mergeActiveCells`/`splitActiveCell`.
- Menú Editar (barra): 3 items gateados (Combinar derecha `cols:2` / abajo
`rows:2` / Dividir).
- Panel: fila de acciones de celda PROPIA — el `blockFoot` (mover/insertar) se
oculta para celdas structural, así que las celdas tienen su pie.
Restringido a tablas top-level (el resolver del motor direcciona
[block,row,cell]). Langs es/en de las 3 acciones DIFERIDOS (fichero de langs
entangled con el merge pendiente; los `ts()` renderizan por fallback).
Verificado: motor 37 tests (2 nuevos del `at`); check 0 errores palabras/table;
Playwright headless en AMBAS superficies: barra Editar → Combinar derecha →
celda colspan=2 (B1 absorbida) → Dividir la restaura; pie del panel acoplado
igual. 0 errores de consola.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Los campos start/numbering del panel de lista (motor + escritura ya cableados
desde 5411becb5) aparecían para viñetas y tareas, donde no tienen sentido.
Añade kinds:['ordered'] a ambos — mismo filtro que ya gobierna marker/box.
Verificado en navegador: el modelo start:3/numbering:'i' proyecta a
<ol start="3" type="i"> y el navegador pinta lower-roman (iii, iv).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>