Build TextArea component + fix locale propagation bug across soma providers
Major work in this session:
1. NumberField demo refactored to expose Intl.NumberFormatOptions live
- 9 canonical presets (currency/unit/percent/compact/plain)
- Locale/currency/unit lists derived from $libs/langs, $libs/currency,
$format/units (no more hardcoded arrays)
- Default locale from uix.format.numbers.getLocale()
2. Bug fix: locale prop ignored when soma.format service is active
- soma/NumberField: 5 bypass points via new `hasOwnLocale` getter
(decimalSeparator, groupSeparator, formatValue, formatEditValue, parseValue)
- soma/Calendar: dateOrder respects locale prop (matches DateField precedence)
- soma/RangeCalendar: same dateOrder fix
- When the consumer passes `locale` prop, soma.nums/dates is bypassed
and Intl is used directly with the prop locale
3. TextArea — new component, full 4-layer stack
- morfo: 3 parts (provider/input/count), 3 events
(commit-input/commit-submit/shift-count-overflow), APG textbox
- soma: TextAreaProvider with autosize (scrollHeight measurement),
submitOn modes (false|enter|shift+enter|mod+enter), maxLength soft clamp,
Field integration (OR-merge), counter overflow tracking
- eidos: 5 sizes × 3 variants × 8 colors, autosize suppresses CSS resize
- sema: commit-submit + shift-count-overflow perceptual cues
- demo: canonical 6-tab depth, all props exposed as live controls
- PrimitiveTextareaAttributes added to soma/types/html.ts
4. Also includes accumulated unreleased work from prior sessions:
- Eidos ports: announce, clipboard, drag-drop, feed, table,
tree-grid, tree-view, virtual-grid, virtual-list, words
- Their canonical demos under web/routes/uix/components/
- Semantic events defined for utility morfos
(announce/clipboard/virtual-list/virtual-grid)
- Tree indent guides fixed (TreeView ::before, TreeGrid stacked gradients)
- Sidebar reorganized with Data + Utilities groups
- Eidos words content layer and primitives
Reference library survey performed before TextArea API design (Mantine,
Radix Themes, Chakra v3, React Aria, Ark UI, Bits UI, shadcn, MUI, Prime).
Decision documented: no separate CurrencyField/UnitField — single
NumberField with formatOptions is the canon (matches all surveyed
headless libs except PrimeReact's mode enum, which still uses 1 component).
svelte-check: 0 errors in modified/created files.
See CONTINUE.md for full session notes and next steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< script lang = "ts" >
import {
Announce,
type AnnounceApi,
type AnnouncePriority,
type AnnounceRole
} from '$uix/eidos/components/announce';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { announceMorfo } from '@/uix/morfo/components/announce';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state< Tab > ('live');
// ── Imperative path: api.announce(msg, priority) ─────────────────────
let api: AnnounceApi | null = $state(null);
let customMessage = $state('Profile saved');
let priority = $state< AnnouncePriority > ('polite');
let defaultTimeout = $state(1000);
type HistoryEntry = { msg : string ; priority : AnnouncePriority ; at : number } ;
let history = $state< HistoryEntry [ ] > ([]);
function send(msg: string, p: AnnouncePriority = priority) {
if (!msg.trim() || !api) return;
api.announce(msg, p);
history = [{ msg , priority : p , at : Date.now () } , ...history].slice(0, 12);
}
function sendCustom() {
send(customMessage);
}
// ── Quick scenarios (canonical uses, not hardcoded fluff) ────────────
const SCENARIOS: { label : string ; msg : string ; priority : AnnouncePriority } [] = [
{ label : 'Save success' , msg : 'Profile saved' , priority : 'polite' } ,
{ label : 'Validation error' , msg : 'Email is required' , priority : 'assertive' } ,
{ label : 'Network failure' , msg : 'Connection lost — retrying' , priority : 'assertive' } ,
{ label : 'Pagination' , msg : 'Showing page 3 of 12' , priority : 'polite' } ,
{ label : 'Item added' , msg : 'Item added to cart (4 items)' , priority : 'polite' }
];
// ── Declarative path: Region with bound `message` prop ───────────────
let regionRole = $state< AnnounceRole > ('status');
let regionMessage = $state('');
let regionVisible = $state(false);
let regionAtomic = $state(true);
// ── Log-mode demo: bounded chat-like history ─────────────────────────
let logEntries = $state< string [ ] > ([]);
let logInput = $state('');
function pushLog() {
if (!logInput.trim()) return;
logEntries = [...logEntries.slice(-9), logInput];
logInput = '';
}
// ── Live region inspector (read back what the AT would receive) ──────
let politeRegionText = $state('');
let assertiveRegionText = $state('');
$effect(() => {
// Soma's internal regions live somewhere in the DOM — find them after mount.
const tick = () => {
const polite = document.querySelector('[aria-live="polite"][data-announce-internal-region]');
const assertive = document.querySelector('[aria-live="assertive"][data-announce-internal-region]');
politeRegionText = polite?.textContent ?? '';
assertiveRegionText = assertive?.textContent ?? '';
};
const id = setInterval(tick, 100);
return () => clearInterval(id);
});
const compiled = compileMorfo(announceMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${ String ( d . getSeconds ()). padStart ( 2 , '0' )} .${ String ( d . getMilliseconds ()). padStart ( 3 , '0' )} `;
}
let stageRef = $state< HTMLElement | null > (null);
const eidosSnippet = $derived(
[
"< script lang = 'ts' > " ,
" import { Announce } from '$uix/eidos/components/announce';",
' let api: AnnounceApi | null = $state(null);',
'< /' + 'script>',
'',
'< Announce ' ,
defaultTimeout !== 1000 && ` defaultTimeout={ $ { defaultTimeout }} `,
'>',
' { # snippet children ( snippetProps )} ',
' {( api = snippetProps , "" )} ',
'',
' <!-- Imperative: post on demand --> ',
` < button onclick = {() => api . announce ( " $ { customMessage } ", " $ { priority } " )} > Send</button > `,
'',
' <!-- Declarative: addressable region --> ',
' < Announce.Region ' ,
regionRole !== 'status' && ` role="${ regionRole } "`,
' message={ someBoundState } ',
!regionVisible ? ' visuallyHidden' : ' visuallyHidden={ false } ',
!regionAtomic && ' aria-atomic={ false } ',
' />',
' { /snippet } ',
'< / Announce > '
]
.filter(Boolean)
.join('\n')
);
< / script >
< div data-uix-canvas-inner >
< header >
< div data-uix-eyebrow > Utility · Announce< / div >
< h1 data-uix-page-title > Announce< / h1 >
< p data-uix-page-lede >
ARIA live-region service for screen-reader announcements. The component
itself paints nothing — its job is to write text into hidden live regions
so assistive tech reads it aloud. Two usage shapes: < strong > imperative< / strong >
(< code > api.announce(msg, priority)< / code > from any descendant) and
< strong > declarative< / strong > (< code > < Announce.Region message> < / code >
bound to reactive state). Compose with < code > Clipboard< / code > ,
< code > DragDrop< / code > , form validation, chat logs.
< / p >
< div data-uix-page-meta >
< span data-uix-meta-pill >
< span data-uix-meta-key > parts</ span > { compiled . parts . order . length }
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > events</ span > { events . length }
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > history</ span > { history . length }
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > region role</ span > { regionRole }
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > timeout</ span > { defaultTimeout } ms
< / span >
< / div >
< / header >
< Announce { defaultTimeout } >
{ # snippet children ( snippetProps )}
{( api = snippetProps , '' )}
< div data-uix-stage >
< div data-uix-stage-area bind:this = { stageRef } style="padding: var ( --uix-space-4 ); display: flex ; flex-direction: column ; gap: var ( --uix-space-4 );" >
<!-- Imperative path -->
< div >
< div style = "font-size: var(--font-size-xs); color: var(--color-content-secondary); margin-block-end: var(--uix-space-2);" >
Imperative path · < code > api.announce(msg, priority)< / code >
< / div >
< div style = "display: flex; gap: var(--uix-space-2); align-items: stretch; flex-wrap: wrap;" >
< input
type="text"
bind:value={ customMessage }
onkeydown={( e ) => { if ( e . key === 'Enter' ) sendCustom (); }}
placeholder="Type a message to announce…"
style="flex: 1 1 18rem; padding: 0.375rem 0.625rem; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); color: var(--color-content-primary); font: inherit;"
/>
< span data-uix-chips role = "radiogroup" style = "align-self: stretch;" >
{ #each [ 'polite' , 'assertive' ] as p ( p )}
< button data-uix-chip data-active = { priority === p } onclick= {() => ( priority = p as AnnouncePriority )} > { p } </ button >
{ /each }
< / span >
< Button size = "sm" variant = "surface" onclick = { sendCustom } disabled= { ! customMessage . trim ()} >
Announce
< / Button >
< / div >
< div style = "margin-block-start: var(--uix-space-2); display: flex; flex-wrap: wrap; gap: var(--uix-space-1);" >
{ #each SCENARIOS as s ( s . label )}
< button
data-uix-chip
onclick={() => { customMessage = s . msg ; priority = s . priority ; send ( s . msg , s . priority ); }}
>{ s . label } </ button >
{ /each }
< button data-uix-chip onclick = {() => api ? . clear ()} > Clear regions </ button >
< / div >
< / div >
<!-- Live region inspector — show what the AT actually sees -->
< div >
< div style = "font-size: var(--font-size-xs); color: var(--color-content-secondary); margin-block-end: var(--uix-space-2);" >
Live region inspector · what the screen reader currently sees
< / div >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: var(--uix-space-2);" >
< div style = "padding: var(--uix-space-2) var(--uix-space-3); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-subtle); font-family: monospace; font-size: var(--font-size-xs);" >
< div style = "color: var(--color-content-secondary); margin-block-end: 0.25rem;" > polite< / div >
< div style = "min-block-size: 1.5rem;" > { politeRegionText || '—' } </ div >
< / div >
< div style = "padding: var(--uix-space-2) var(--uix-space-3); border: 1px solid var(--color-threat-element); border-radius: var(--radius-md); background: var(--color-threat-surface, var(--color-surface-subtle)); font-family: monospace; font-size: var(--font-size-xs);" >
< div style = "color: var(--color-content-secondary); margin-block-end: 0.25rem;" > assertive< / div >
< div style = "min-block-size: 1.5rem;" > { assertiveRegionText || '—' } </ div >
< / div >
< / div >
< / div >
<!-- Declarative Region path -->
< div >
< div style = "font-size: var(--font-size-xs); color: var(--color-content-secondary); margin-block-end: var(--uix-space-2);" >
Declarative path · < code > < Announce.Region role message> < / code >
< / div >
< div style = "display: flex; gap: var(--uix-space-2); align-items: center; flex-wrap: wrap; margin-block-end: var(--uix-space-2);" >
< input
type="text"
bind:value={ regionMessage }
placeholder="Bound message — AT announces on every change…"
style="flex: 1 1 16rem; padding: 0.375rem 0.625rem; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); color: var(--color-content-primary); font: inherit;"
/>
< label style = "display: inline-flex; align-items: center; gap: 0.375rem; font-size: var(--font-size-xs);" >
< input type = "checkbox" bind:checked = { regionVisible } / >
visible
< / label >
< label style = "display: inline-flex; align-items: center; gap: 0.375rem; font-size: var(--font-size-xs);" >
< input type = "checkbox" bind:checked = { regionAtomic } / >
aria-atomic
< / label >
< / div >
< Announce.Region role = { regionRole } message= { regionMessage } visuallyHidden = { ! regionVisible } aria-atomic= { regionAtomic } />
< / div >
<!-- Log - mode showcase -->
< div >
< div style = "font-size: var(--font-size-xs); color: var(--color-content-secondary); margin-block-end: var(--uix-space-2);" >
Log mode · < code > role="log"< / code > — running history (chat / activity)
< / div >
< div style = "display: flex; gap: var(--uix-space-2); align-items: stretch; margin-block-end: var(--uix-space-2);" >
< input
type="text"
bind:value={ logInput }
onkeydown={( e ) => { if ( e . key === 'Enter' ) pushLog (); }}
placeholder='Append a "chat" line…'
style="flex: 1 1 auto; padding: 0.375rem 0.625rem; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); color: var(--color-content-primary); font: inherit;"
/>
< Button size = "sm" variant = "surface" onclick = { pushLog } disabled= { ! logInput . trim ()} > Append</ Button >
< Button size = "sm" variant = "ghost" onclick = {() => ( logEntries = [])} > Clear</Button >
< / div >
< Announce.Region role = "log" visuallyHidden = { false } aria-atomic= { false } >
{ #if logEntries . length === 0 }
< div style = "color: var(--color-content-secondary); font-size: var(--font-size-xs); font-style: italic;" > No entries yet — type and Enter to append.< / div >
{ : else }
< ul style = "margin: 0; padding-inline-start: 1rem; font-size: var(--font-size-sm);" >
{ #each logEntries as line , i ( i + line )}
< li > { line } </ li >
{ /each }
< / ul >
{ /if }
< / Announce.Region >
< / div >
< / div >
< div data-uix-stage-trace >
< span data-uix-stage-trace-key > history< / span >
{ #if history . length === 0 }
< span > type a message and Enter, or pick a scenario — the live region inspector above shows what the AT receives< / span >
{ : else }
{ #each history . slice ( 0 , 3 ) as h ( h . at )}
< span >< strong > { h . priority } </ strong > · "{ h . msg } "</ span >
< span style = "color: var(--uix-text-faint)" > { fmtTime ( h . at )} </ span >
{ /each }
{ /if }
< / div >
< / div >
{ /snippet }
< / Announce >
< div data-uix-tabs role = "tablist" >
< button data-uix-tab data-active = { tab === 'live' } onclick= {() => ( tab = 'live' )} > Live</ button >
< button data-uix-tab data-active = { tab === 'api' } onclick= {() => ( tab = 'api' )} > API < span data-uix-tab-count > 2</ span ></ button >
< button data-uix-tab data-active = { tab === 'morfo' } onclick= {() => ( tab = 'morfo' )} >
< span data-uix-layer-badge = "morfo" > morfo< / span >
< span data-uix-tab-count > { partsList . length } p · { events . length } e</ span >
< / button >
< button data-uix-tab data-active = { tab === 'sema' } onclick= {() => ( tab = 'sema' )} >
< span data-uix-layer-badge = "sema" > sema< / span >
< span data-uix-tab-count > { events . length } </ span >
< / button >
< button data-uix-tab data-active = { tab === 'recipe' } onclick= {() => ( tab = 'recipe' )} > Recipe</ button >
< button data-uix-tab data-active = { tab === 'a11y' } onclick= {() => ( tab = 'a11y' )} > A11y</ button >
< / div >
{ #if tab === 'live' }
< section data-uix-section >
< h2 data-uix-section-title > Controls< / h2 >
< p data-uix-section-desc >
The 4 ARIA region roles imply different semantics. < code > status< / code > = polite info,
< code > alert< / code > = assertive (interrupts), < code > log< / code > = running history,
< code > timer< / code > = time-sensitive (don't announce every tick).
< / p >
< div data-uix-subsection-head >
< span data-uix-layer-badge = "soma" > soma< / span > behaviour
< / div >
< div data-uix-controls >
< label data-uix-control >
< span data-uix-control-label > defaultTimeout< / span >
< span data-uix-chips role = "radiogroup" >
{ #each [ 500 , 1000 , 3000 ] as n ( n )}
< button data-uix-chip data-active = { defaultTimeout === n } onclick= {() => ( defaultTimeout = n )} > { n } ms</ button >
{ /each }
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > Region role< / span >
< span data-uix-chips role = "radiogroup" >
{ #each [ 'status' , 'alert' , 'log' , 'timer' ] as r ( r )}
< button data-uix-chip data-active = { regionRole === r } onclick= {() => ( regionRole = r as AnnounceRole )} > { r } </ button >
{ /each }
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > default priority< / span >
< span data-uix-chips role = "radiogroup" >
{ #each [ 'polite' , 'assertive' ] as p ( p )}
< button data-uix-chip data-active = { priority === p } onclick= {() => ( priority = p as AnnouncePriority )} > { p } </ button >
{ /each }
< / span >
< / label >
< / div >
< div data-uix-code >
< div data-uix-code-head >
< span data-uix-layer-badge = "eidos" > eidos< / span >
< span > visual · Announce + Region (imperative + declarative)< / span >
< span data-uix-code-lang > svelte< / span >
< / div >
< pre >< code > { eidosSnippet } </ code ></ pre >
< / div >
< / section >
{ /if }
{ #if tab === 'api' }
< section data-uix-section >
< h2 data-uix-section-title > API reference< / h2 >
< p data-uix-section-desc >
2 parts: < code > Provider · Region< / code > . Provider mounts 2 internal regions
(polite + assertive) and exposes an imperative < code > api< / code > via snippet
props + via context. Region is for declarative purpose-specific live regions.
< / p >
< div data-uix-subsection-head > Provider (Announce)< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Prop< / th > < th > Type< / th > < th > Default< / th > < th > Description< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > defaultTimeout< / td > < td class = "type" > number< / td > < td class = "default" > 1000< / td > < td > ms before a posted message clears so the same text can be re-announced.< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Region (declarative)< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Prop< / th > < th > Type< / th > < th > Default< / th > < th > Description< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > role< / td > < td class = "type" > 'status' | 'alert' | 'log' | 'timer'< / td > < td class = "default" > 'status'< / td > < td > Implies polite/assertive + AT-specific handling semantics.< / td > < / tr >
< tr > < td class = "name" > aria-live< / td > < td class = "type" > 'polite' | 'assertive'< / td > < td class = "default empty" > from role< / td > < td > Override the implicit urgency.< / td > < / tr >
< tr > < td class = "name" > aria-atomic< / td > < td class = "type" > boolean< / td > < td class = "default" > true< / td > < td > Whether AT reads the entire region on change (vs the diff only).< / td > < / tr >
< tr > < td class = "name" > aria-relevant< / td > < td class = "type" > string< / td > < td class = "default" > 'additions text'< / td > < td > Which mutations trigger an announcement.< / td > < / tr >
< tr > < td class = "name" > message< / td > < td class = "type" > string< / td > < td class = "default empty" > —< / td > < td > Bound reactive text — AT reads on change.< / td > < / tr >
< tr > < td class = "name" > visuallyHidden< / td > < td class = "type" > boolean< / td > < td class = "default" > true< / td > < td > Standard sr-only technique. Set false for visible+announced banners.< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Imperative API (Provider snippet props)< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Method< / th > < th > Signature< / th > < th > Description< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > announce< / td > < td class = "type" > (message, priority?, timeout?) => void< / td > < td > Post to the polite (default) or assertive internal region.< / td > < / tr >
< tr > < td class = "name" > clear< / td > < td class = "type" > () => void< / td > < td > Clear all internal live regions immediately.< / td > < / tr >
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'morfo' }
< section data-uix-section >
< h2 data-uix-section-title >
< span data-uix-layer-badge = "morfo" > morfo< / span >
· declarative contract
< / h2 >
< p data-uix-section-desc > Source: < code > src/uix/morfo/components/announce.ts< / code > .< / p >
< div data-uix-subsection-head > Parts< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Part< / th > < th > Marker< / th > < th > Element< / th > < th > Role< / th > < th > States< / th > < th > Optional< / th > < / tr > < / thead >
< tbody >
{ #each partsList as part ( part . kebab )}
< tr >
< td class = "name" > { part . kebab } </ td >
< td >< code data-uix-part-marker > [{ part . marker } ]</ code ></ td >
< td class = "type" > < { part . defaultElement } > </ td >
< td class = "type" > { part . role ?? '—' } </ td >
< td class = "default" > { part . states . length ? part . states . join ( ' | ' ) : '—' } </ td >
< td class = "default" > { part . optional ? 'yes' : 'no' } </ td >
< / tr >
{ /each }
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Events declaration< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > name< / th > < th > family< / th > < th > verb< / th > < th > sequence< / th > < th > intent< / th > < th > target< / th > < / tr > < / thead >
< tbody >
{ #each events as action ( action . name )}
{ @const sem = action . semantic }
{ @const intentDecl = 'intent' in sem ? sem.intent : undefined }
{ @const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:$ {( intentDecl as { fromProp? : string }). fromProp ?? '?' } ` : '—' }
< tr >
< td class = "name" > { action . name } </ td >
< td class = "type" > { sem . family } </ td >
< td > { sem . verb ?? '—' } </ td >
< td > { sem . sequence ?? 'pre' } </ td >
< td class = "default" > { intentStr } </ td >
< td > { action . target } </ td >
< / tr >
{ /each }
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'sema' }
< section data-uix-section id = "sema" >
< h2 data-uix-section-title >
< span data-uix-layer-badge = "sema" > sema< / span >
· events
< / h2 >
< p data-uix-section-desc > Announce raises < code > commit-announce-polite< / code > / < code > commit-announce-assertive< / code > on every message post, and < code > commit-reset< / code > when regions are wiped. Use Play to replay an event against the Provider.< / p >
Build TextArea component + fix locale propagation bug across soma providers
Major work in this session:
1. NumberField demo refactored to expose Intl.NumberFormatOptions live
- 9 canonical presets (currency/unit/percent/compact/plain)
- Locale/currency/unit lists derived from $libs/langs, $libs/currency,
$format/units (no more hardcoded arrays)
- Default locale from uix.format.numbers.getLocale()
2. Bug fix: locale prop ignored when soma.format service is active
- soma/NumberField: 5 bypass points via new `hasOwnLocale` getter
(decimalSeparator, groupSeparator, formatValue, formatEditValue, parseValue)
- soma/Calendar: dateOrder respects locale prop (matches DateField precedence)
- soma/RangeCalendar: same dateOrder fix
- When the consumer passes `locale` prop, soma.nums/dates is bypassed
and Intl is used directly with the prop locale
3. TextArea — new component, full 4-layer stack
- morfo: 3 parts (provider/input/count), 3 events
(commit-input/commit-submit/shift-count-overflow), APG textbox
- soma: TextAreaProvider with autosize (scrollHeight measurement),
submitOn modes (false|enter|shift+enter|mod+enter), maxLength soft clamp,
Field integration (OR-merge), counter overflow tracking
- eidos: 5 sizes × 3 variants × 8 colors, autosize suppresses CSS resize
- sema: commit-submit + shift-count-overflow perceptual cues
- demo: canonical 6-tab depth, all props exposed as live controls
- PrimitiveTextareaAttributes added to soma/types/html.ts
4. Also includes accumulated unreleased work from prior sessions:
- Eidos ports: announce, clipboard, drag-drop, feed, table,
tree-grid, tree-view, virtual-grid, virtual-list, words
- Their canonical demos under web/routes/uix/components/
- Semantic events defined for utility morfos
(announce/clipboard/virtual-list/virtual-grid)
- Tree indent guides fixed (TreeView ::before, TreeGrid stacked gradients)
- Sidebar reorganized with Data + Utilities groups
- Eidos words content layer and primitives
Reference library survey performed before TextArea API design (Mantine,
Radix Themes, Chakra v3, React Aria, Ark UI, Bits UI, shadcn, MUI, Prime).
Decision documented: no separate CurrencyField/UnitField — single
NumberField with formatOptions is the canon (matches all surveyed
headless libs except PrimeReact's mode enum, which still uses 1 component).
svelte-check: 0 errors in modified/created files.
See CONTINUE.md for full session notes and next steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Name< / th > < th > Family< / th > < th > Verb< / th > < th > Sequence< / th > < th > Intent< / th > < th > Play< / th > < / tr > < / thead >
< tbody >
{ #each events as action ( action . name )}
{ @const intentDecl = 'intent' in action . semantic ? action.semantic.intent : undefined }
{ @const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined }
< tr >
< td class = "name" > { action . name } </ td >
< td class = "type" > { action . semantic . family } </ td >
< td > { action . semantic . verb ?? '—' } </ td >
< td > { action . semantic . sequence ?? 'pre' } </ td >
< td class = "default" > { effectiveIntent ?? '—' } </ td >
< td >
< button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-announce-region]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent : effectiveIntent } : {} )
});
}}
>▶ play< / button >
< / td >
< / tr >
{ /each }
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'recipe' }
< section data-uix-section >
< h2 data-uix-section-title > Eidos recipe< / h2 >
< p data-uix-section-desc > Selectors at < code > src/uix/eidos/components/announce/announce.css</ code > . The Provider is < code > display: contents</ code > — no visual surface. Regions get the standard sr-only technique unless < code > visuallyHidden={ false } </ code > .</ p >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Selector< / th > < th > Source< / th > < th > What it paints< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > [data-announce]< / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > < code > display: contents< / code > — invisible host.< / td > < / tr >
< tr > < td class = "name" > [data-announce-region]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Live region — sr-only when < code > visuallyHidden< / code > .< / td > < / tr >
< tr > < td class = "name" > [data-announce-region]:not([data-visually-hidden])< / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Card chrome when the Region opts into visible UI.< / td > < / tr >
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'a11y' }
< section data-uix-section >
< h2 data-uix-section-title > Accessibility< / h2 >
< p data-uix-section-desc >
Announce IS the perception channel for the screen reader audience —
the component's whole purpose is to write text into < code > aria-live< / code >
regions. Without an active screen reader, the regions are functionally
invisible (sr-only). The live region inspector above mirrors the region
contents so you can verify behaviour without launching a real AT.
< / p >
< div data-uix-subsection-head > Role implications< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > role< / th > < th > Implicit aria-live< / th > < th > Use for< / th > < th > Example message< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > status< / td > < td class = "type" > polite< / td > < td > Non-critical updates.< / td > < td > "Profile saved"< / td > < / tr >
< tr > < td class = "name" > alert< / td > < td class = "type" > assertive< / td > < td > Errors, urgent notices.< / td > < td > "Connection lost"< / td > < / tr >
< tr > < td class = "name" > log< / td > < td class = "type" > polite< / td > < td > Running history (chat, activity).< / td > < td > New chat lines appended.< / td > < / tr >
< tr > < td class = "name" > timer< / td > < td class = "type" > polite< / td > < td > Time-sensitive info (don't announce every tick).< / td > < td > "5 minutes remaining"< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Best practices< / div >
< ul style = "margin: 0; padding-inline-start: 1.25rem; color: var(--color-content-secondary); font-size: var(--font-size-sm);" >
< li > Mount < code > < Announce> < / code > ONCE near the app root — multiple providers create duplicate regions and AT may read messages twice.< / li >
< li > Posts are cleared after < code > defaultTimeout< / code > ms so identical consecutive strings still trigger an announcement (AT dedupes within a region).< / li >
< li > Use < code > polite< / code > by default. Reserve < code > assertive< / code > for errors and urgent interruptions only — abusing it makes the AT exhausting.< / li >
< li > For visually-shown status banners, set < code > visuallyHidden={ false } </ code > — the eidos recipe paints outlined card chrome.</ li >
< li > Verify with a real screen reader (VoiceOver / NVDA / JAWS) — the inspector above is a debugging aid, not a substitute.< / li >
< / ul >
< / section >
{ /if }
< / div >