Skip to content

DS admission: IconAvatar initials + corner accessory (+ ListAction forwarding) #231

Open

Description

@darioaschero

Admission class

Extension

Requesting actor / evidence owner

@darioaschero

Consumer owner / affected-surface owner

@darioaschero (nuri-com/nuri-expo: send step S1, the search mode, the address book)

Request summary and current state

Amended 2026-10-24 after the independent review
(#231 (comment)), following the
architect's direction on the open calls. The admission decision itself is not recorded here; it is
@darioaschero's, in an architect-owned comment.

Current surface. IconAvatar (packages/spec/components/icon-avatar.ts) shows exactly one of two
contents: a glyph (icon) or an image (source, which wins when both are given). An internal
mode axis (glyph | image, derived by propMaps.contentMode) mutes the root's variant paint in
image mode. Sizes: md 48px (default), sm 24px. Decorative (hidden from assistive tech).
ListAction's leadingAvatar part nests an icon-avatar and forwards name → icon, variant
(default outline), accent and source. The DS has no way to put text in an avatar and no way to
lay one part over another (the schema has no positioning; BleedNS exists precisely so that
descriptors and View cannot opt into overlap).

Consumer need. The send step S1 (recipient list), search results and the address book list
people as flat contacts. The row subtitle names the identifier (@emil, N26 ···4521, bc1q…9m7t).
The avatar should carry the person's initials and a small accessory at the bottom-right that
shows the payment rail (a Nuri user vs a bank, Bitcoin, Lightning, card or mobile-money destination).
Actions in the same list (Scan or paste, All contacts, New recipient) keep the outline glyph avatar.

Why now. The need is nuri-expo's. Its address book, its suggestions and the send step draw every
contact as a generated wordpet PNG passed as source, with coloured PNG logos for rails (§ Consumer
need), and the intended look cannot be built from DS parts: IconAvatar takes no text,
ListActionLeadingAvatar accepts only IconAvatar props, and nothing in the DS can overlay a part.
The playground boards (send.html, pure DS; send-recipient.html, outside the DS) show the target
and the gap; they are evidence, not the source of the need.

Proposed contract delta (not admitted)

Two public props on IconAvatar (initials, accessory), one public axis (accessorySurface),
their forwarding through ListActionLeadingAvatar, one chrome slot (inverse), one size leaf
(2xs), and the schema, codegen and engine rules they need. Nothing public is removed or narrowed
(the one internal reshape is under Class-scoped Contraction / Mixed evidence).

Public surface (what a consumer writes)

RN Web
Initials content <IconAvatar initials="EB" /> <nuri-icon-avatar initials="EB">
Accessory glyph accessory="nuri" (an IconName) accessory="nuri"
Accessory surface accessorySurface="canvas" | "inverse" (default canvas) accessory-surface="…"
In a list row <ListActionLeadingAvatar initials="EB" accessory="bank" /> <nuri-list-action-leading-avatar initials="EB" accessory="bank">

A Nuri contact: initials="EB" accessory="nuri" accessorySurface="inverse". Every other rail:
initials="CM" accessory="bank" (the canvas default). A photo: source={…} accessory="nuri" accessorySurface="inverse". Existing icon, source, variant, size, accent are unchanged.

A · Initials

A1 · Content. A new initials part (el: 'text') in the icon-avatar anatomy and an initials
slot with a scalar prop. 1–2 letters, derived and cased by the caller; the DS does not parse names.
The text leaf is single-line with tail truncation, so a longer string cannot break the circle (three
or more letters truncate; that is not a feature). Presence = a non-empty string: initials=""
behaves as absent (no initials mode, no text, no dev warning).

A2 · Treatment, locked by mechanism. The internal mode axis gains initials. In that mode the
root paints soft (bg-strong fill, text-primary letters) whatever variant/accent say, the
same way image mode already mutes the variant. variant and accent are documented no-ops in
initials mode. Type: md (17px), regular, on the md avatar (chosen by the requester on the board,
§B4); xs (13px), regular, on the sm avatar (not yet seen on a board).
Why lock it: ListAction forwards variant with an outline default, so without the lock every
person row would draw outline initials (transparent, muted letters) unless the caller remembered
variant="soft"; and variant="solid" accent="orange" would bring back per-person colour, which
the requester dropped.

Letter-spacing: the initials use their step's own letter-spacing (md −0.02em, xs 0). No tracking
token, no letter-spacing rule (dropped after review). On web the second letter's position does not
depend on letter-spacing (centred text, spacing trails every glyph), so the board's ring contact
(§B4) carries over unchanged; verified by probe at both values.

OS text size: the initials do not follow the OS text size. The avatar is decorative and a
fixed-size circle; the row's name, which is text, scales. Without this, large accessibility sizes
would truncate the initials inside the 48px circle and push them into the accessory. Mechanism (RN):
text leaves inside a decorative descriptor render with allowFontScaling={false}. icon-avatar is
the only decorative descriptor and the initials its only text leaf; the rule keys on existing
descriptor data (decorative, decision 50), not on the slot kind, so slot kinds carry no
presentation. Web: no rule (browser zoom scales the whole avatar; the type step is rem, so a larger
browser default font size grows the initials inside the px circle: accepted).

A3 · Precedence + the contentMode reshape. source > initials > icon. Today
contentMode = { slot, axis, present, absent } handles one slot. Proposed:
contentMode = { axis, present: [{ slot, value }, …], absent }; the first present slot wins (array
order is the precedence). icon-avatar: present: [{ slot: 'source', value: 'image' }, { slot: 'initials', value: 'initials' }], absent: 'glyph'. The generated one-time dev warning generalises
from "exactly one of icon or source" to "exactly one of icon, source or initials" (initials=""
counts as absent, A1).

A4 · Slot kind. SlotSpec.kind gains a scalar kind for this content, working name 'initials'.
prop is legal only on icon-name and image-source slots (Channel 7); rich text is
composition-only (the 2026-07-01 rule). A dedicated kind keeps that rule: only this kind of text
gets a prop. New guard: a text slot must be component or default. Today the RN codegen
turns a non-component, non-default kind: 'text' slot into a slotName?: string prop and routes it
as content (scripts/parsers/components-api.js:379, 492); no descriptor uses that path and no guard
forbids it. After this change the initials kind is the only scalar text prop in the DS, by
mechanism. The kind carries routing only, no presentation (no letter-spacing, no font-scaling rule).

Q1 · Initials shape · resolved after review. Scalar prop + new kind + mode lock, as above;
letter-spacing dropped (the step's own value); presence = non-empty string; the bare-text guard
closes the latent scalar path. Rejected: (a) a composition marker
(<IconAvatarInitials>EB</IconAvatarInitials>): contentMode requires a scalar prop slot, and
ListAction forwards nested-component props, not composition children, so it costs more engine
work for a worse 2-letter API; (b) letting prop appear on any text slot: it would undo the
composition-only rule for every component; (c) initials that follow variant: the two failure
modes in A2; (d) caps letter-spacing in any form (a tracking.caps token keyed on the kind, or a
TypographyNS flag that every Text would gain): dropped; (e) the two letters as separate nodes
with a gap: the smallest space step (3px) is about 0.18em at 17px, it does not scale with the text,
and splitting breaks single letters, accents, emoji and non-Latin names (and would make the DS
split names, which A1 rules out).

B · Accessory

B1 · Anatomy. Two parts: accessory (a view host: the disc) and accessoryIcon (an icon
leaf inside it). Slot accessory: { part: 'accessoryIcon', kind: 'icon-name', prop: 'accessory' }.
Public axis accessorySurface: 'canvas' | 'inverse', default canvas. No accessory accent.
The disc has no width or height: it sizes from its content, the 15px glyph plus space.xs (3px)
padding, so 21px in both surfaces (accessory: box: { padding: 'xs', radius: 'full' };
accessoryIcon: box: { width: '2xs', height: '2xs' }). Only existing BoxNS fields; the
canvas hairline takes no layout (B2); §B4 has the numbers.

B2 · Placement: a new, descriptor-only overlay. PartAnatomy gains
overlay?: 'corner-accessory' (named for the mechanism, not a corner coordinate). One value, which
bundles:

  • the part leaves the flow; its box is pinned to the parent host's bottom-end corner and pushed out
    by space.xs (3px) on both axes, drawn above the parent's content;
  • a 2px knockout ring in bg-canvas, outside the disc, taking no layout space;
  • a 1px border-subtle separation hairline inside the disc, taking no layout space, drawn when the
    disc paints the ring's own colour (chrome: canvas) so a canvas disc stays distinct from its
    canvas ring; an inverse disc has none. The hairline is the accessory's, like the image element's
    ring: no chrome row gains a border, and no resolver change is needed;
  • content gating: an overlay host renders only when its slot has content (hosts always render today;
    without this rule every avatar would draw an empty disc).

An overlay part is a non-interactive view host (no pressable, no interactive, no hit
target). Allowlist guard: only icon-avatar.accessory may declare overlay; any other use fails
CI and is a new Extension. This narrowly revisits the BleedNS position ("descriptors and View
cannot opt into overlap"): one descriptor part may overlap, View and every other descriptor still
cannot. Engines realise it generically, keyed off overlay, with no component logic:

  • RN: position: 'absolute'; the ring as an absolutely-positioned bordered sibling and the hairline
    as an absolutely-positioned bordered child (inset 0), both the focus-ring technique already in
    runtime/renderer.tsx.
  • Web: position: absolute; box-shadow: 0 0 0 2px var(--nuri-bg-canvas) (+ inset 0 0 0 1px var(--nuri-border-subtle) on a canvas disc); position: relative on the root painting node,
    not on the display: contents component host.

Q2 · Where overlap lives · resolved after review. A PartAnatomy field, as above: structure,
invariant per descriptor (no variant can move it) and invisible to the open primitives. Value
renamed from 'bottom-end' to 'corner-accessory'; ring, outset, hairline and gating stay bundled
in the one value (separate fields would be more surface for one consumer); the allowlist guard and
the non-interactive rule bound the precedent. Rejected: a BoxNS field (box.anchor). Every
BoxNS field becomes a flat prop on View, Pressable and Image (Object.keys(BOX_FIELDS)), which
would hand absolute positioning to every agent-written layout.

B3 · The two surfaces: chrome slots, accent-invariant by construction.

accessorySurface Paints Realisation
canvas (default · every other rail) canvas disc, text-primary glyph, 1px border-subtle hairline the existing chrome canvas (bg-canvas + text-primary); the hairline is the overlay's, no layout (B2)
inverse (Nuri users) inverse disc (dark in light theme, light in dark), contrasting glyph new chrome inverse = bg-inverse + text-on-inverse
  • Named for what it paints, following the surface axis on topbar and tab-bar (surface: canvas | transparent, the chrome slot each value paints).
  • bg-inverse / text-on-inverse resolve to exactly the neutral accent-solid / accent-on-solid
    values in both themes (neutral-1-dark/-light, neutral-12-dark/-light). So inverse paints
    the requester's "solid + accent neutral" pixel for pixel, but under the lilac or orange accent it
    stays neutral. A lilac Nuri badge cannot happen.
  • The existing outline variant cannot serve: it paints transparent + text-muted. Over a
    photo the disc would be see-through, and the glyph would be the 5.85:1 muted the requester
    rejected (text-primary is 16.03:1 on canvas).
  • inverse-muted is already a chrome slot; inverse itself is not, though its roles exist. Only
    inverse becomes a new public chrome slot. It also becomes a chrome= value on View and
    Pressable: admitted for the accessory; that availability sets no precedent for further chrome rows.
  • accessorySurface without accessory is a no-op.

Q3 · Accessory shape · resolved after review. The narrow pair accessory (glyph) +
accessorySurface (two values, default canvas), no accent; canvas reuses the existing chrome
row, only inverse is new; the hairline is the accessory's, drawn without layout, so both surfaces
measure 21×21 like the board. Web attribute accessory-surface via a generic camelCase→kebab-case
mapping for axis attributes in the web factory (today it reads axis names raw: factory.js:1008, 1116; single-word axes are unaffected). Rejected: (a) a nested icon-avatar as the accessory
(self-referencing component part at a new size): it exposes all five variants plus accent
(coloured rail badges by the back door), its outline paints the wrong pair, and a
self-referencing component part is untested in codegen; (b) solid variant + a per-part
accent: 'neutral' pin: no descriptor pins palette.accent per part today, and RN resolves accent
only as an upstream scope (resolve.ts · SEED-4), so it needs new engine work to avoid one palette
row; (c) a standalone Badge component: consumers still cannot overlap it, and it invites
count/status badges; (d) accessoryVariant: 'solid' | 'outline': reuses the variant words while
painting different pairs; (e) a canvas-outline chrome row with a palette border: the border takes
layout on both engines, so the disc would be 23px, not the chosen 21px.

B4 · Geometry, as chosen on the board. The requester's first stated target was "22px disc, −3px,
2px ring, glyph about 16px". Measured on the board's first version (commit bb33e6e in
#228, by probe, not by eye): disc 22×22, outset 3/3, ring 2px, glyph
12.96px
(<nuri-icon size="sm"> = 18px × scale(.72)); that page CSS never drew a 16px glyph. The
requester then iterated on the board (2026-10-24). Every row below was measured by probe on the 48px
avatar, light and dark (the fixed 24/18 row on the scratch comparison, the rest on the board), all
with a 3px outset (space.xs) and the 2px ring:

Badge Glyph Padding Initials Verdict
22, fixed (first version: commit bb33e6e in #228) 12.96 — 13px / 400 (the dropped rule) first reviewed look; off-scale
24, fixed size.sm 18 size.xs — — glyph nearly touches the hairline: reject
24, fixed 12 — 14.5px / 600 balanced, but needs a 12px leaf
18, fixed size.xs 12 — 14.5px / 600 lighter; glyph 2px from the hairline
24, from content 18 size.xs 3 space.xs 17px / 400 covers about 9×5px of the second initial: reject
27, from content 15 6 space.sm 17px / 400 too big: reject
21, from content 15, new size.2xs 3 space.xs 17px / 400 chosen by the requester; the evidence at main@d06e2cc
  • New size leaf 2xs = 15px (packages/spec/tokens/dimensions.ts), the step below xs (18).
    It sits on the 3px grid the space scale uses, not the size scale's own steps (18, 24, 36…). Cost:
    SizeLeaf widens, so width="2xs" becomes legal on every box-bearing surface (doc rows move).
    2xs is not an icon size. The token-free options were the 18px glyph rows above, both rejected.
  • The 2px ring stays a literal, mirroring FOCUS_RING_WIDTH (the focus ring's 2px is a design
    constant, not a token). Minting border.2 for one ring is the alternative. The 1px hairline uses
    the existing border.1.
  • Known contact: at 17px the initials reach the ring, which touches the lower-right corner of a
    wide second letter by about 1px (independent review probe: the second letter's bounding box
    against the ring circle, Chromium/macOS system-ui: about 1.1px for M, 0.6–0.7px for C and K,
    about 0 for B). Font-dependent. Seen and accepted on the board (14.5px initials would clear it); the
    device check is in the native residue (§ Downstream verification plan).
  • The evidence board draws this geometry (packages/playground/pages/send-recipient.html on
    main@d06e2cc, feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228; page CSS, still outside the DS; X17, X19 and X16
    share the classes). Probed: all 12 badges (6 inverse, 6 canvas) measure 21×21 with no width or
    height set, a 15px glyph, 3px padding, 3/3 outset, the ring and the canvas hairline as box-shadows
    (no layout), and 17px / 400 initials; the row titles keep md's −0.34px. The board sets the initials
    at +0.05em (0.85px); the contract uses the step's −0.02em (A2), which does not move the second
    letter.
  • The nuri glyph looks optically larger than the outline glyphs at this size; that is a separate
    Correction for the DS maintenance form (noted in the five-glyph request), not a geometry question.

Q4 · Sizes · resolved after review. The accessory is specified for md (48) only. On sm (24)
a proportional badge would be about 10px, too small for a legible glyph. Enforcement:
documentation, stated in two places: the generated API table's Notes for accessory and
accessorySurface (NOTE_BY_PROP, packages/doc/pipeline/component-api-ir.js) and the
icon-avatar page lead (same file). The RN codegen emits no per-prop TSDoc today, and this request
adds no channel for it. The one forwarding consumer (ListAction) always renders md. A generated
dev warning would need a SlotSpec field: more schema for one warning. Not proposed.

C · ListAction forwarding

leadingAvatar.props gains initials: '$slot.initials', accessory: '$slot.accessory',
accessorySurface: '$slot.accessorySurface'. The generated ListActionLeadingAvatar marker gains
the three props / attributes. Nothing else changes in list-action. Because of the mode lock (A2),
<ListActionLeadingAvatar initials="EB" /> paints soft despite the row's outline default.
SelectTrigger / SelectField avatars do not forward (no consumer).

Codegen: forwarded marker props are typed from the target component's api, generically
(public axis → its value union; slot kind → IconName / ImageSourcePropType / string; accent →
Accent), replacing the hand special-cases in slotPropTs (components-api.js:318-325, which today
types only accent, variant and the SLOT_PROP_TS map, and would emit unknown for the three new
props).

D · Accessibility

IconAvatar stays decorative (decision 50): initials and the accessory are hidden from assistive
tech along with the circle.

Contract: the accessory is never the only carrier of the rail. The rail is named in visible
row text or in the row's accessibilityLabel; a handle is not a name. Today send.html labels the
bank row "Claude M., N26 account ending 4521" but a Nuri row "Emil Berg, at emil" (send.html:67),
which implies the rail without naming it. The rebuilt send.html Nuri rows read "Emil Berg, Nuri
user, @emil"
.

Q5 · Labels · resolved after review. No accessoryLabel prop; the contract sentence above. A
labelled standalone avatar is anti-scope until a consumer shows one.

Schema / codegen / engine ledger (what moves deliberately)

# Where Change Guard
1 schema.ts PartAnatomy overlay?: 'corner-accessory' Guard-F pin moves · new overlay allowlist guard (icon-avatar.accessory only; non-interactive view host)
2 schema.ts SlotSpec.kind + 'initials' (scalar, prop-legal) Guard-F pin moves · Channel 7 admits the kind · new guard: a text slot is component or default
3 schema.ts ComponentApi.propMaps.contentMode single slot → ordered present list Guard-F pin moves
4 schema.ts PaletteChrome + palette-surface.ts + 'inverse' Guard-F pin moves (leafUnions) · palette guards (web Guard D · RN palette.js PALETTE_CONTRACT cell check)
5 tokens/dimensions.ts size + 2xs: 15px dimension-token oracle
6 scripts/parsers/components-api.js forwarded marker props typed from the target's api (generic) component-api tests + RN type-tests
7 web factory.js axis attributes camelCase → kebab-case (generic; single-word axes unchanged) factory test
8 RN renderer.tsx + web factory.js / CSS the corner-accessory overlay: placement, ring, canvas hairline, content gating render-smoke + factory test
9 RN renderer.tsx text leaves in a decorative descriptor: allowFontScaling={false} render-smoke

Guard F moves for four frozen surfaces (rows 1–4), in one deliberate, commented move. No tracking
token, no kind-keyed presentation rule, no resolver change.

Proposed anti-scope (refused generalisation)

  • No person colour palettes of any kind (pastel, Radix, hue wheel, hash-to-colour); initials are
    always soft. variant and accent are documented no-ops in initials mode.
  • No coloured rail badges and no per-rail colour table; colour is not a rail code. Exactly two
    accessory surfaces; no accessory accent; no lilac Nuri badge.
  • No KYC / "verified" / status marks, no count or notification badges, no online dots. The
    accessory is a rail glyph from the DS icon set: no image and no initials in the accessory, and no
    status or motion glyphs as accessory content (check-circle, warning-circle, cross-circle,
    spinner). The type stays IconName: which glyphs are rails is product knowledge.
  • No overlay value other than 'corner-accessory'; no overlay on any part other than
    icon-avatar.accessory (allowlist guard; another use is a new Extension); no overlay on
    View/Pressable/Image; overlay parts are non-interactive; no general positioning vocabulary.
  • No surface-adaptive ring: it is bg-canvas. On a non-canvas surface (a strong card, a pressed
    bg-subtle row while the finger is down) it shows as a canvas ring. No ring width or colour option.
  • inverse is admitted for the accessory; its availability as chrome= on View and Pressable sets
    no precedent for further chrome rows.
  • No accessory on sm; no new avatar size. nuri-expo's 72px "hero" avatar
    (components/SendRecipientContact.tsx:16 "the DS avatar only comes in list sizes") stays app-owned.
  • 2xs is a size leaf, not an icon size: no new icon step.
  • No name parsing, casing or grapheme logic in the DS; no image fallback to initials (no load or
    error states: the image-mode anti-scope stands; the caller passes one content). Three or more
    letters truncate; that is not a feature.
  • No letter-spacing option or token anywhere: initials use their step's letter-spacing. No
    text-transform: the caller supplies capitals.
  • Initials do not follow the OS text size (RN); no font-scaling option.
  • accessorySurface without accessory is a no-op.
  • No generated avatars: nuri-expo's wordpets stay an app decision.
  • No forwarding through SelectTrigger / SelectField.

Consumer need, gap, and current workaround (before admission)

  • Consumer / owner: @darioaschero, for nuri-com/nuri-expo: send S1 (recipient list), the search
    mode, the address book.
  • Concrete use case: a row "Emil Berg · @emil" with EB initials and an inverse Nuri accessory;
    "Claude M. · N26 ···4521" with CM initials and a canvas bank accessory; the same accessory over a
    profile photo.
  • Gap: IconAvatar takes a glyph or an image only; ListActionLeadingAvatar forwards only
    name/variant/accent/source; nothing in the DS can overlay a part.
  • Current workaround (DS evidence page): packages/playground/pages/send-recipient.html on
    main@d06e2cc (feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228). Every person row is hand-built: a nuri-view row
    • a <span class="xav"> circle + a <span class="xbadge"> badge, styled by a
      <style data-outside-ds> block, with the chevron copied by hand.
  • Current workaround (the app): nuri-expo (feat/send-shortcut-1741@bdf020b1) draws every
    contact as a generated wordpet PNG passed as source:
    screens/addressBook/AddressBookScreen.tsx:140 and components/AddressBookSuggestions.tsx:106
    (<ListActionLeadingAvatar source={contactAvatar(…)} />), plus a raw 72px Image in
    components/SendRecipientContact.tsx. Rail marks are coloured PNG logos
    (lib/addressBook/kindImages.ts).

Available consumer-local prototype or evidence (before admission)

  • Boards on main@d06e2cc (feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228):
    packages/playground/pages/send-recipient.html (outside the DS, with disclaimer): X17 target
    (initials + accessory), X19 the same accessory on photos (randomuser.me placeholder portraits,
    inlined), X16 what nuri-expo draws today (vendored wordpets, for reference); its closing GAP LOG
    lists what this contract must cover. packages/playground/pages/send.html (pure DS): the decided
    send boards, whose person rows this contract would rebuild.
  • App code: nuri-expo feat/send-shortcut-1741@bdf020b1, the files named above; the app already
    derives letters (lib/addressBook/initial.ts, initialOf, for index grouping).
  • External references (Mobbin, iOS). The pattern of a network/rail badge at the bottom-right of a
    person avatar recurs across fintech send flows:
    • Revolut: "contacts on Revolut" list, coloured initials + a black "R" badge with a canvas ring
      (screen); the same badge on a
      photo (screen) and on search
      results (screen).
    • Wise: request list with neutral initials + a Wise badge, beside an outline-glyph action row
      "Anyone" (screen); search
      "On Wise" (screen). Closest
      to this target: neutral initials, glyph actions in the same list.
    • Monzo: split-bill contacts, initials and photos with an "M" badge
      (screen); an account-type
      glyph badge on initials (screen).
    • Honest limit: in these references the badge mostly marks in-network users (Revolut,
      Wise, Monzo). Badging every external rail with a monochrome canvas glyph is Nuri's own
      extension of the pattern, and Revolut and Monzo use per-person colours, which this request drops.
  • Corrections found while preparing this request (by probe, on the board's first version:
    commit bb33e6e in feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228): the accessory glyph was 12.96px, not ~16px
    (§B4). The initials rendered at 13px / weight 400: the page rule font: 600 15px/1 inherit was
    an invalid shorthand (a font value cannot end in inherit), so the browser dropped it. The "600,
    size-scaled" treatment in the first decision was therefore never on screen; once the rule was fixed
    and compared, the requester chose md (17px) regular (§A2, §B4).
  • Corrections from the independent review (DS admission: IconAvatar initials + corner accessory (+ ListAction forwarding) #231 (comment)):
    the outline disc as first proposed (a chrome row with a palette border) would measure 23px, not
    21px; Guard F covers four surfaces, not three; the web axis attribute and the forwarded-prop types
    needed generic changes; the initials-to-ring contact is about 1px, not ~2px. All are answered in
    the amended text above.
  • Missing evidence and owner: an exact nuri-com/nuri-expo issue or PR for the send S1 /
    address-book avatar change; owner @darioaschero.

Class-scoped Contraction / Mixed evidence

Filed as an Extension. One internal reshape is listed here in case the architect reads it as a
narrowing: ComponentApi.propMaps.contentMode changes from one slot to an ordered list (§A3). Its
only user is icon-avatar, and no descriptor is authored outside this repo (contentMode appears
only in schema.ts, icon-avatar.ts, its generated twin
packages/prototype/generated/descriptors/icon-avatar.js, scripts/parsers/components-api.js,
packages/prototype/factory/factory.js and the Guard-F pin). The new bare-text guard (A4) forbids
a slot shape no descriptor uses. Affected consumers: none; migration: the one in-repo descriptor, in
the same PR; rollback: revert that PR.

Class-scoped Extension / Mixed evidence

  • Recurrence: inside nuri-expo, three list surfaces render person avatars today (address book
    list, address-book suggestions, the activity sheet's contacts via contactAvatar), and send S1 +
    search add two more. Outside: three of the reference fintech apps above show initials and an
    in-network badge. The overlap itself (a glyph badge on a circle) is the one composition no consumer
    can build from DS parts. Product decision, not recurrence: showing the rail on every external
    contact (the canvas accessory) is the consumer owner's product decision for Nuri; the references
    badge only in-network users.
  • DS ownership: the look (circle, initials type step, accessory disc, ring, hairline, the two
    surfaces) and its cross-platform mechanics (out-of-flow placement, knockout ring, content gating,
    fixed-size decorative text) are presentation vocabulary. Which rail a contact uses, the letters,
    and the row's label stay consumer-owned.
  • Trade study:
    • Keep it consumer-owned (an app wrapper around IconAvatar with an absolute badge): works in
      nuri-expo's RN code, but ListActionLeadingAvatar only accepts IconAvatar props, so the app
      must hand-build rows and loses the list-row contract (72px floor, press, a11y, trail icon).
      Rejected.
    • A nested icon-avatar as the accessory, a Badge component, a per-part accent pin, a
      composition initials marker, a bordered canvas-outline chrome row:
      rejected in Q1 and Q3.
    • Off-scale literals (22/13) baked into the engines: rejected; hand numbers in the engines are
      exactly what the token discipline and rn-token-escape-hatch guard exist to stop.
    • Existing leaves only (an 18px glyph, in a fixed or a padded 24px badge): rejected on the
      board (§B4).
    • overlay as a BoxNS field: rejected (Q2).
    • Caps letter-spacing (tracking.caps): dropped after review (Q1).
  • Contract cost: 4 frozen-schema surfaces (Guard-F pin, one move), 1 chrome slot, 1 size leaf,
    1 generic overlay realisation per engine (placement, ring, canvas hairline, gating), 1 RN
    font-scaling rule for decorative text, 1 new slot kind, 2 new guards (overlay allowlist; bare
    text slots), generic forwarded-prop typing in the codegen, generic axis-attribute kebab mapping in
    the web factory, the contentMode reader reshaped in 2 places, icon-avatar + list-action
    descriptor data, doc pages.
  • Compatibility: additive for consumers. With none of the new props set, IconAvatar and
    ListAction render as today: the initials leaf has no content, and the overlay host is gated.
    The axis-attribute mapping changes nothing for today's single-word axes. Existing render-smoke
    snapshots should not move; any that do must be justified. Generated deltas expected: RN
    icon-avatar.ts / list-action.ts adapters, recipes.ts, palette.ts, tokens.ts /
    token-paths.ts (the 2xs leaf); web descriptor twins, palette.css (1 row), token CSS (2xs) +
    box.css (the 2xs leaf), the overlay rule; doc pages (icon-avatar, list-action, the palette
    and dimension foundations, every page whose width/height/chrome union widens).
  • Anti-scope: see "Proposed anti-scope" under the contract delta.

Requested DS architect / admission recorder

@darioaschero · second human architectural reviewer: none · sole-architect exception (@darioaschero) · independent review: #231 (comment) (docs/design-system-admission.md, "Named exception · sole DS architect (@darioaschero)", #229)

Architect decision record location (architect-owned)

(Leave empty on initial request: architect-owned.)

Proposed bounded execution scope if admitted

  • Spec: components/schema.ts (deltas 1–4), components/icon-avatar.ts (anatomy initials,
    accessory with overlay: 'corner-accessory', accessoryIcon; mode gains initials;
    accessorySurface axis + default canvas; contentMode list; slots), components/list-action.ts
    (3 forwarded props), tokens/dimensions.ts (2xs), axes/palette-surface.ts (the inverse
    chrome row).
  • Codegen / guards: scripts/parsers/components-api.js (the scalar initials kind → string
    prop / attribute; contentMode list with presence = non-empty for initials; the generalised
    warning; forwarded marker props typed from the target's api), scripts/parsers/palette.js
    (PALETTE_CONTRACT row), scripts/docs-drift.test.js (Guard-F pin for the four surfaces, moved
    once, the move commented), scripts/component-api.test.js (Channel 7 admits the initials kind;
    the bare-text guard; the overlay allowlist + non-interactive guard), the dimension and palette
    oracles.
  • RN: runtime/renderer.tsx (overlay placement + ring + canvas hairline + content gating;
    initials routed to the text leaf; allowFontScaling={false} on text leaves of a decorative
    descriptor), regenerated generated/, render-smoke + type-tests (IconAvatarProps,
    ListActionLeadingAvatarProps exact).
  • Web: factory/factory.js (overlay, ring, hairline, gating; camelCase→kebab-case axis
    attributes), the overlay CSS rule, regenerated styles + descriptor twins, factory.test.js.
  • Playground: component/icon-avatar.html and component/list-action.html proofs; send.html
    person rows rebuilt as list-action rows with initials + accessory (Nuri rows labelled "Emil
    Berg, Nuri user, @emil"); delete send-recipient.html and its card in pages/index.html
    (line 51 on main@d06e2cc).
  • Expo-demo: one committable screen or section with the person rows (never the operator-local
    screens/index.tsx / Overview.tsx).
  • Doc: generated pages + the IR notes (packages/doc/pipeline/component-api-ir.js: the
    icon-avatar lead and the NOTE_BY_PROP notes for accessory / accessorySurface, md only) +
    the drift pins, kept in sync.
  • NOT in scope: the five-glyph request (its own issue), nuri-expo changes (the consumer's PR),
    SelectTrigger / SelectField forwarding, a hero size, a search trigger, the merge.

Downstream verification plan (before Ready)

  • Repository checks / CI: root npm run build + npm test; the 5 gates (spec · prototype
    · doc · rn render-smoke + tsc · expo-demo tsc), including @nuri/doc (npm test +
    build + git diff --exit-code packages/doc/generated/); re-emit determinism (two builds,
    identical hashes); only the generated files listed above differ.
  • Contract / type proof: IconAvatarProps and ListActionLeadingAvatarProps exact (the three
    new keys, initials: string, accessory: IconName, accessorySurface: 'canvas' | 'inverse',
    no unknown, still children?: never); the Guard-F pin moved once for the four surfaces;
    the overlay allowlist and bare-text guards fail on a synthetic violating descriptor;
    existing snapshots unchanged or justified.
  • Rendered proof, web (by probe, not by eye): at 48px, for initials, image and glyph, each
    with no accessory, canvas and inverse, light and dark: both surfaces measure 21×21
    with no width/height set (15px glyph + 3px padding), outset 3/3, ring 2px in bg-canvas, the
    canvas hairline 1px border-subtle with no layout, glyph 15×15, initials 17px / 400 with
    the step's letter-spacing (−0.34px); the inverse disc colour equals --nuri-accent-solid
    under data-accent="neutral" and stays equal under lilac and orange; no accessory node
    exists when accessory is absent; initials="" renders glyph mode with no text node; the
    overlay's containing block is the root painting node, not the display: contents host;
    accessory-surface is observed and live-updates; the host stays aria-hidden.
  • Rendered proof, RN-web (Expo web preview, by probe): the same geometry on host nodes (both
    surfaces 21×21); the accessory is drawn above an image; variant/accent are no-ops in
    initials mode; the initials Text carries allowFontScaling={false} (render-smoke).
  • Native residue (operator, device): Android drawing the accessory 3px outside the 48px
    avatar (RN Android overflow), the ring and hairline at device pixel densities, VoiceOver/
    TalkBack reading the row label (including "Nuri user") and not the letters, the 17px initials
    against the ring with wide second letters (B, M: the accepted ~1px contact, §B4) at the
    device's system font, and OS text size at the largest accessibility setting (iOS Larger
    Accessibility Sizes, Android font scale max): initials stay fixed and untruncated, the row
    name scales.
  • Real consumer: an exact nuri-com/nuri-expo PR replacing the wordpet source in
    AddressBookScreen.tsx:140 and AddressBookSuggestions.tsx:106 with initials + accessory.
    Attached during Verification/Done, never before Ready.

Acceptance criteria and Verification/Done evidence

  1. send.html person rows are pure DS list-action rows, Nuri rows labelled with the rail ("Emil
    Berg, Nuri user, @emil"); send-recipient.html and its index card are gone; no data-outside-ds
    CSS remains in the playground.
  2. The geometry and colour probes above pass on web and RN-web, light and dark; both surfaces 21×21.
  3. Accent invariance: the accessory never changes colour with the accent; initials never leave
    soft.
  4. Existing IconAvatar (glyph, image) and ListAction renders unchanged.
  5. Full gate suite green, including @nuri/doc; regeneration deterministic; the two new guards
    present and proven to fail on a violation.
  6. Doc pages generated for the new props, the inverse chrome slot and the 2xs leaf; the
    anti-scope sentences on the icon-avatar page (decorative accessory, the rail always named in row
    text or label, md only, canvas ring, fixed-size initials, step letter-spacing) and md only in
    the API table notes.
  7. The schema deltas recorded in the Guard-F pin comment + this record (the decisionlog is archived).
  8. This record's status line flipped in the SAME PR that ships the implementation.

Owning repository and base branch

nuri-com/nuri-design-system @ main (baseline main@d06e2cc)

Dependencies and human decisions

Risk class

Product

Authority, responsibility, and accountability evidence

  • May change if admitted: only the artifacts under "Proposed bounded execution scope".
  • Excluded: the five-glyph request, nuri-expo code (the consumer's own PR), SelectTrigger /
    SelectField forwarding, a hero avatar size, a search trigger (evaluated separately against
    SelectField).
  • Approval boundaries: the requester decided the target on 2026-10-24; the drafting agent may
    not admit, record decisions, move Project state, tag or merge. Admission is @darioaschero's record
    under the sole-architect exception, informed by the independent review session; execution starts
    only at Project Ready. Amended 2026-10-24 after the independent review, following the
    architect's direction on the open calls.
  • Independent verification: the probes, gates and device checks in the verification plan, plus
    the nuri-com/nuri-expo PR attached at Verification/Done.

Release, migration, and documentation plan

  • Release note: the two props + accessorySurface, the ListAction forwarding, the inverse
    chrome slot, the 2xs = 15px size leaf, the descriptor-only overlay: 'corner-accessory',
    fixed-size initials on RN, camelCase→kebab-case axis attributes on web.
  • Migration: none for consumers (additive). Inside the repo: send-recipient.html and its index
    card deleted, send.html person rows rebuilt as list-action rows.
  • Documentation: generated pages for icon-avatar, list-action and the palette and dimension
    foundations; the Guard-F pin comment records the schema deltas.
  • Rollback: revert the single PR.

Admission-request acknowledgements

  • This submission requests independent DS contract admission; it does not grant admission, execute the change, or authorize Project Ready.
  • The requested contract delta is proposed until a named independent human DS architect records an Admit decision for a specific accepted contract and anti-scope.
  • Initial submission remains Pending/request-only until an architect-owned decision record exists; this form cannot be used to select or self-attest Admit.
  • Admission independence is from the requester/consumer; the admitting architect may implement later, but independent verification remains required.
  • If one human occupies both requester/consumer and architect roles, a second named human architectural reviewer is required in the architect-owned decision record.
  • Class-specific evidence is provided for the selected class above; the named human DS architect enforces selected-class completeness because GitHub issue forms cannot conditionally require those fields.
  • Any Extension or Mixed request includes recurrence or system-invariant evidence, DS ownership rationale, alternatives/trade study, contract cost, and anti-scope.
  • Any Contraction or Mixed request includes proportional affected-consumer/non-use evidence, migration/deprecation/rollback implications, and ambiguity, misuse, duplication, or maintenance burden removed; pure Contractions are not forced through Extension prompts.
  • Any Mixed request includes both Extension admission evidence and Contraction evidence.
  • Before Project Ready, this issue must have the architect-accepted contract, bounded execution scope, and downstream verification plan.
  • Executed Expo/device/runtime/compatibility proof is collected after implementation before Verified/Done, not treated as pre-Ready admission evidence.
  • No form field, label, assignment, branch, PR, implementation, or green gate grants Project Ready; Project Ready remains the sole execution authorization.
  • No public/observable DS growth is being routed through a maintenance form, bounded Experiment, playground, prototype, Expo demo, or other fast lane.
  • All issue and PR references use canonical owner/repo#number form.

Tick all fourteen boxes. All apply as written: this is a request only (no Admit, no Ready); the
roles overlap, and the second-reviewer box is met through the sole-architect exception
(docs/design-system-admission.md, #229): an independent review session
posts on this issue and its link goes into the decision record; the Extension evidence is above;
executed Expo/device proof comes after implementation; references use canonical owner/repo#number
form.

Activity

  1. darioaschero commented on Oct 24, 2026

    @darioaschero
    CollaboratorAuthor

    Independent review (sole-architect exception)

    Fresh session, no part in drafting the request, the evidence or any brief. Inputs: this issue, the repository at main@45918bb (d06e2cc plus the policy text from #229; no code or data differs from the cited baseline), and docs/design-system-admission.md. prompts/ was not read. Read-only: no files, branches, decisions or Project state touched. The evidence page was served from main and probed in Chromium on macOS (light theme). This review informs the decision; it does not make it.


    1 · Claims

    Confirmed against main

    • IconAvatar: icon / source slots, source wins, internal mode: glyph | image via propMaps.contentMode, image mode mutes the root to ghost, md 48 / sm 24, decorative: true (packages/spec/components/icon-avatar.ts:98-130). The RN dev warning text matches (packages/rn/generated/components/icon-avatar.ts:41).
    • ListAction forwards name → icon, variant|outline, accent, source, and never forwards size, so it always renders md (packages/spec/components/list-action.ts:24-31).
    • contentMode = { slot, axis, present, absent }, one slot (schema.ts:471), and the codegen requires a scalar prop slot (scripts/parsers/components-api.js:160).
    • prop is legal only on icon-name / image-source (Channel 7, scripts/component-api.test.js:515). The BleedNS comment says descriptors and View cannot opt into overlap (schema.ts:174-184).
    • Every BoxNS field is a flat prop on View, Pressable and Image (packages/rn/primitives/View.tsx:20, Pressable.tsx:29, Image.tsx:41; BOX_KEYS = Object.keys(BOX_FIELDS)). Text takes all of TypographyNS (Text.tsx:17), so rejected option (d) is costed correctly.
    • soft = bg-strong + text-primary, both accent-invariant chrome roles, so the initials mode lock does make accent a no-op (palette-surface.ts:86, colours.ts:273-296). outline = transparent + text-muted + border-subtle (:89).
    • bg-inverse / text-on-inverse equal neutral accent-solid / accent-on-solid in both themes (colours.ts:279,284 vs :314,316). inverse-muted is a chrome slot and inverse is not (schema.ts:223).
    • RN resolvePalette drops border on chrome slots (packages/rn/runtime/resolve.ts:219). Web palette CSS emits a border for any row that has one (packages/prototype/pipeline/parsers/palette-css.js:103).
    • Focus-ring technique and FOCUS_RING_WIDTH = 2 (packages/rn/runtime/renderer.tsx:51, 512-537). No descriptor pins palette.accent per part. Accent is an upstream scope in RN (resolve.ts:203-207).
    • Tokens: space.xs 3 / sm 6, size 18/24/36/…, border has only 1, type md 17 / −0.02, sm −0.01, xs 13 / 0 (tokens/dimensions.ts, tokens/typography.ts).
    • Contrast (cream, light): text-primary on canvas 16.03:1, text-muted 5.85:1.
    • Icon set: nuri, bank, bitcoin, lightning, card exist. No mobile-money glyph (the companion DS admission: six icon glyphs (address-book, id-card, selfie, lock, lock-open, phone) + the nuri mark at 22/32 #230 covers glyphs).
    • Evidence page (packages/playground/pages/send-recipient.html), by probe: all 12 badges (6 solid, 6 outline) are 21×21 with no width or height set, with a 15×15 glyph, 3px padding, 3/3 outset, a 2px bg-canvas ring, and initials at 17px / 400 with 0.85px letter-spacing. Row titles keep −0.34px. The index card sits at pages/index.html:51. send.html person rows are rail glyphs today (nuri soft, bank solid lilac, the Bitcoin source), and send.html has no page CSS.

    Wrong or incomplete

    1. The outline disc is 23px under the proposed mechanism, not 21px. The board draws the outline hairline as inset 0 0 0 1px box-shadow, which takes no layout space (send-recipient.html:31-32). B3 realises it as a chrome row with a border channel. That border takes layout on both engines (web border: var(--nuri-border-1) solid …, palette-css.js:103; RN borderWidth: PALETTE_BORDER_WIDTH, resolve.ts:325-327). On a disc sized by its content that gives 15 + 6 + 2 = 23px. I probed it: a 1px layout border on the board's outline badge measures 23×23 even under border-box, and its leading edge moves 2px further into the avatar. Every non-Nuri row would differ from the chosen look and would push further into the second initial. The verification probe "badge 21×21 … outline" would fail as specified.
    2. Guard F moves for four schema surfaces, not three. PaletteChrome is pinned member-for-member in Guard F's leafUnions (scripts/docs-drift.test.js:595), so ledger row 4 also moves the Guard-F pin. The changed frozen surfaces are PartAnatomy, SlotSpec.kind, contentMode and PaletteChrome.
    3. accessory-variant="…" does not work on <nuri-icon-avatar> today. The factory reads public axes by raw name: observed = [...axisNames, …] (factory.js:1008) and this.getAttribute(axis) (:1116). No public axis is multi-word yet, so accessoryVariant would observe a lowercased accessoryvariant, not accessory-variant. The kebab form works only on the ListAction marker, whose attributes are converted kebab→camel (factory.js:322-324). This needs a generic factory change that is not among the "four generic rules", or a one-word axis name.
    4. Forwarded props would be typed unknown. slotPropTs types marker props only for accent, variant (from the target's variant axis) and the SLOT_PROP_TS hand map (components-api.js:318-325). So initials, accessory and accessoryVariant would emit unknown until the codegen derives them from the target's api. That change is not in the execution scope, and the exact-type proof in the verification plan depends on it.
    5. A4's "only icon-name/image-source may declare a prop" holds for prop, but not as a mechanism. The RN codegen already turns a non-component, non-default kind: 'text' slot into a slotName?: string prop and routes it as content (components-api.js:379, 492). No descriptor uses that path and no guard forbids it. The composition-only rule is convention plus Channel 7, not an enforced invariant.
    6. Minor: the contentMode occurrence list leaves out the generated twin packages/prototype/generated/descriptors/icon-avatar.js.

    Could not confirm

    • Everything in nuri-com/nuri-expo: the branch, the files and lines (AddressBookScreen.tsx:140, AddressBookSuggestions.tsx:106, SendRecipientContact.tsx:16, initial.ts, kindImages.ts), and the in-app recurrence count. These are outside this review's inputs.
    • The Mobbin references, which are external.
    • The first-version measurements (22px / 12.96px / 13px, the invalid font shorthand). Commit bb33e6e is not on main (feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228 was squash-merged). The page's own comment records the same history.
    • The "~2px" contact. A bounding-box probe of the second letter against the ring circle (Chromium, system-ui) gives about 1.1px into the ring for "CM", 0.6–0.7px for "SC" and "AK", and about 0 for "EB". The order of magnitude agrees, the figure does not, and it depends on the font. Dark theme was not probed; the geometry does not depend on theme.
    • The "why now" premise ("expressible in pure DS on the playground is the consumer channel's ship gate") is not recorded anywhere at main outside prompts/. Under the policy, the consumer need is nuri-expo's; the playground's gap is not evidence by itself.

    2 · Risks to the shared contract

    • Surface growth is large for one row look, and most of it comes from the accessory. On IconAvatar: 2 props and 1 axis. On the ListAction marker: 3 props. Two chrome= values, also public on View and Pressable. One size leaf, which widens width, height, minWidth and minHeight on every box surface. A new token family (tracking). Four Guard-F surfaces. New engine semantics: out-of-flow placement, a knockout ring, a content-gated host (hosts always render today, schema.ts:317-338), and presentation keyed on a slot kind. Plus the two codegen and factory changes found above. A alone (initials + mode lock + forwarding) touches 2 Guard-F surfaces and no layout vocabulary.
    • Precedent. PartAnatomy.overlay is the first time a descriptor can lay one part over another, which narrows the BleedNS position. As one enum value, 'bottom-end' reads like general positioning vocabulary and invites top-end next, while it actually bundles corner, outset, ring, z-order and content gating. Without a mechanical bound, the next status dot or tab-bar count will cite it.
    • Slot kind carries presentation. Today a slot kind drives API routing only; the image ring is keyed on an element, not a kind. Keying tracking.caps on the initials kind is a new coupling that no gate sees.
    • Cross-platform.
      • The accessory sits 3px outside the root on RN Android; this is already in the native residue. If the overlay is bound to non-interactive hosts, Android not hit-testing outside parent bounds never matters.
      • RN text follows the OS font scale: nothing in packages/rn/runtime or primitives sets allowFontScaling or maxFontSizeMultiplier. At large text sizes, 17px initials grow inside a fixed 48px circle, truncate ("E…") and run deeper into the accessory. This is not in the verification plan.
      • On web, the overlay must anchor to the inner painting node. The mount-a-tree host is display: contents.
    • Accessibility. Decorative is consistent with decision 50. But the board's own Nuri rows do not name the rail: aria-label="Emil Berg, at emil" (send.html:67) and "Emil Berg, @emil" (:213). The handle implies Nuri; it does not say it. The bank rows do name it. The proposed contract sentence would be failed by the evidence it cites.
    • Per-person or per-rail colour. The mode lock and the chrome-slot accessory close it for initials and for the accessory, as claimed. Glyph mode still accepts variant="solid" accent=…, which is exactly how send.html:74 draws bank in solid lilac today. "Colour is not a rail code" stays guidance, not mechanism, for glyph avatars.
    • Recurrence. Initials recur (the app's lists, and the external references). The outline badge on every external rail is, by the request's own account, Nuri's extension of the pattern. The record should state it as a product decision, not as recurrence.

    3 · Open questions

    Q1 · Initials shape. Agree: a scalar prop, a dedicated kind and the mode lock. The mode lock is the most valuable part, because it removes the outline-default footgun and the colour back door by mechanism. The new kind is acceptable and is actually narrower than today, provided the same change closes the latent bare-text scalar path (a guard: a text slot must be component or default). For letter-spacing, the smallest contract ships initials at the step's own −0.02em and drops tracking.caps. If the architect keeps it (the requester chose it on the board), record that a slot kind now carries presentation for this one kind only, and add a two-engine guard that only initials-kind content leaves its step's letter-spacing. Also define presence as a non-empty string. contentMode tests != null, so initials="" would draw a blank soft circle.

    Q2 · Where overlap lives. Agree: PartAnatomy, not BoxNS. BoxNS would hand positioning to View, Pressable and Image, which I verified. Add:

    • an allowlist guard, so that only icon-avatar.accessory may declare overlay and a second use fails CI until it is admitted;
    • a rule that an overlay part is a non-interactive view host;
    • a sentence in the record that this narrowly revisits BleedNS.

    Keep the ring and outset bundled; separate fields would add surface for one consumer. Consider a value name that reads as this mechanism rather than as a corner coordinate.

    Q3 · Accessory shape. Agree with the narrow pair: a glyph, two treatments, no accent, painted by chrome slots. Changes:

    • (a) Resolve the 23px disc first. I recommend drawing the outline hairline without layout, as the board does (web inset shadow; RN an absolutely positioned bordered child, the same technique as the ring), as part of the accessory or chrome-border contract. The alternative is to accept 23px and look at it on the board before admitting.
    • (b) Fix the web attribute: add a generic camel→kebab mapping for axis attributes to the scope, or choose a one-word name.
    • (c) Values solid / outline reuse the variant words but paint different pairs: accessory solid is not accent-solid, and accessory outline is not the outline variant. Agents will assume they follow the variant rows. The existing precedent is surface: canvas | transparent on topbar and tab-bar, an axis named for the chrome slot it paints (topbar.ts:109-112, tab-bar.ts:69-72). I recommend values named for what they paint.
    • (d) A smaller contract is available if the hairline becomes accessory-owned as in (a): only inverse needs to become a public chrome slot.

    Q4 · Sizes. Agree: md only, enforced by documentation, since the single forwarding consumer is always md. Put the sentence in the generated prop docs as well as the doc page; agents choose by the type surface, and size="sm" accessory=… typechecks. A schema field for one warning is not worth it.

    Q5 · Labels. Agree: no accessoryLabel. Sharpen the contract text to say the accessory is never the only carrier of the rail: the rail must be named in visible row text or in the row's accessibilityLabel. A handle is not a name. The rebuilt send.html should model this on Nuri rows.


    4 · Anti-scope: missing or too loose

    • accessory: IconName admits every glyph, including check-circle, warning-circle and cross-circle, which is a verified mark by the back door, and spinner, the motion glyph. Name these as refused accessory content. Do not narrow the type to a rail list; which glyphs are rails is product knowledge.
    • Overlay: the allowlist, non-interactive, no hit target, and "a second descriptor's use is a new Extension".
    • Chrome: the two slots are admitted for the accessory. Their availability on View is incidental and sets no precedent for further chrome rows.
    • 2xs: not an icon size, so no new icon step.
    • tracking: one row, not a scale, so no tight or wide to follow.
    • Initials: no OS font-scaling promise, whichever way it is decided. Three or more letters truncate, and that behaviour is not a feature. variant and accent are documented no-ops in initials mode.
    • accessoryVariant without accessory is a no-op.

    5 · Verdict: Admit with changes

    The initials half is sound and well bounded. The accessory half is the right shape, but its stated realisation does not reproduce the evidence, and its execution scope omits work the plan depends on. Before an Admit record:

    1. Decide how the outline hairline is realised, so that both treatments measure 21×21 (preferred). Otherwise accept 23px and re-check it on the board first; in that case, Request more evidence for B only. Update B1, B3, B4 and the probe.
    2. Fix the axis: a generic factory axis-attribute mapping in scope, or a one-word name. Use values named for what they paint.
    3. Add generic forwarded-prop typing in components-api.js to the scope.
    4. Correct the ledger: Guard F covers 4 surfaces, including PaletteChrome.
    5. Add the guards to the scope: the overlay allowlist with non-interactive hosts, a ban on bare-text scalar slots, and the kind-keyed letter-spacing guard if tracking is kept.
    6. Add the anti-scope items in §4.
    7. Use the sharpened accessibility sentence, and name the rail on the rebuilt Nuri rows.
    8. Add to the verification plan: RN OS font scaling (with large-text initials and the accessory), initials="", the web overlay anchoring to the painting node, and both treatments measuring 21×21.

    These are the architect's calls: whether to drop tracking.caps (the smaller contract), and whether to admit A + C-initials now and B after change 1 is settled.

  2. darioaschero commented on Oct 24, 2026

    @darioaschero
    CollaboratorAuthor

    Request amended after independent review

    The issue body is amended to answer the independent review ("Admit with changes"), following the architect's direction on the open calls. No decision is recorded here and no Project state moved. The admission decision remains @darioaschero's, in an architect-owned comment.

    Independence note. The session that wrote the review also made this amendment, at the operator's direction. The review was written before the amendment and independently of it. Any further independent review of the amended text needs a new session.

    Review §1 · claims found wrong or incomplete

    Review item How the request answers it now
    1 · Outline disc would be 23px (the palette border takes layout) The hairline belongs to the accessory and is drawn without layout: web inset box-shadow, RN an absolutely positioned bordered child (the focus-ring technique). It appears when the disc paints the ring's colour (chrome: canvas). No chrome row gains a border and no resolver change is needed. Both surfaces measure 21×21 (B1, B2, B3), and the probe checks both.
    2 · Guard F covers four surfaces, not three The ledger and contract cost now list four: PartAnatomy, SlotSpec.kind, contentMode, PaletteChrome (leafUnions), in one commented move.
    3 · accessory-variant does not work on <nuri-icon-avatar> The axis is now accessorySurface. A generic camelCase→kebab-case mapping for axis attributes in the web factory is in scope (ledger row 7, accessory-surface); single-word axes are unchanged.
    4 · Forwarded props typed unknown Generic forwarded-prop typing from the target component's api in components-api.js (C, ledger row 6). The type proof requires initials: string, accessory: IconName, accessorySurface: 'canvas' | 'inverse', and no unknown.
    5 · Latent bare-text scalar path is unguarded New guard: a text slot must be component or default (A4, ledger row 2). After it, the initials kind is the only scalar text prop, by mechanism.
    6 · contentMode occurrences omit the generated twin Added packages/prototype/generated/descriptors/icon-avatar.js (Contraction section).

    Could not confirm

    • Contact "~2px": now about 1px (§B4), with the probe numbers. The native residue expects ~1px.
    • "Why now" ship-gate premise: dropped. The need is nuri-expo's, and the boards are evidence only.
    • The nuri-expo paths, the Mobbin references and bb33e6e: unchanged. They remain the requester's evidence, outside the review's inputs.

    Review §2 · risks

    • Surface growth: reduced. One new chrome slot (inverse; canvas reuses the existing row) instead of two. No tracking token family, no kind-keyed presentation rule, no RN resolver change. Added: two guards, generic codegen typing, the factory axis mapping, and one RN font-scaling rule.
    • Precedent / BleedNS: the value is renamed overlay: 'corner-accessory'. An overlay part is a non-interactive view host, rendered only when its slot has content. An allowlist guard admits only icon-avatar.accessory; any other use fails CI and is a new Extension. B2 states that this narrowly revisits the BleedNS position.
    • Slot kind carries presentation: removed. tracking.caps is dropped, and initials use the step's letter-spacing. The kind carries routing only.
    • Cross-platform.
      • RN OS font scaling: initials do not follow the OS text size (allowFontScaling={false}; web unaffected), stated in A2 and the anti-scope, and tested at the largest accessibility size in the native residue.
      • Web anchoring: position: relative goes on the root painting node, not the display: contents host, and the probe checks it.
      • Android overflow: unchanged in the native residue. Overlay parts have no hit target.
    • Accessibility: D now says the accessory is never the only carrier of the rail; the rail is named in visible row text or accessibilityLabel, and a handle is not a name. The rebuilt Nuri rows read "Emil Berg, Nuri user, @emil" (scope, acceptance 1).
    • Per-person / per-rail colour: unchanged. The mode lock and accent-invariant chrome close it for initials and for the accessory. Glyph mode still accepts variant="solid" accent=…, so "colour is not a rail code" stays guidance there. The rebuilt send.html person rows no longer use glyph avatars.
    • Recurrence: the request now states that showing the rail on every external contact is the consumer owner's product decision, not recurrence.

    Review §3 · open questions (each now marked "resolved after review" in the body)

    • Q1: scalar prop + new kind + mode lock. tracking.caps dropped. Presence is a non-empty string (initials="" behaves as absent). Bare-text guard added.
    • Q2: PartAnatomy.overlay: 'corner-accessory', with ring, outset, hairline and gating bundled. Allowlist plus non-interactive rule. BleedNS revisit recorded.
    • Q3: accessorySurface: 'canvas' | 'inverse', default canvas, named for what it paints after the surface axis on topbar and tab-bar. Only inverse is a new chrome slot. accessoryVariant and a bordered canvas-outline row are listed as rejected.
    • Q4: md only, stated in the generated API table's Notes (NOTE_BY_PROP) and on the icon-avatar page lead, both in packages/doc/pipeline/component-api-ir.js.
    • Q5: no accessoryLabel; the sharpened contract sentence in D.

    Review §4 · anti-scope (all added)

    • No status or motion glyphs as accessory content (check-circle, warning-circle, cross-circle, spinner); the type stays IconName.
    • inverse is admitted for the accessory, and its availability on View and Pressable sets no precedent.
    • 2xs is not an icon size.
    • Three or more letters truncate, and that is not a feature.
    • variant / accent are documented no-ops in initials mode.
    • accessorySurface without accessory is a no-op.
    • Also added: no overlay outside the allowlist and no interactive overlay; no ring width or colour option; no letter-spacing option or token; no font-scaling option.

    Review §5 · change list

    1. Hairline without layout, both 21×21 → B1–B3, and verification. A, B and C go together, no split.
    2. Axis rename and web mapping → accessorySurface, ledger row 7.
    3. Forwarded-prop typing → C, ledger row 6.
    4. Ledger → four Guard-F surfaces.
    5. Guards → overlay allowlist and bare-text (rows 1–2). The tracking guard is moot because tracking is dropped.
    6. Anti-scope → above.
    7. Accessibility sentence and Nuri row label → D, scope, acceptance 1.
    8. Verification → both surfaces 21×21; RN OS font scaling at the largest accessibility size (initials fixed, row name scales); initials=""; the web overlay's containing block is the painting node; accessory-surface observed.

    Choices made while amending (for the architect to confirm in the decision record)

    • Where the font-scaling rule is keyed. The RN rule applies allowFontScaling={false} to text leaves inside a decorative descriptor, not to the initials slot kind. That way slot kinds carry no presentation, and the rule matches the stated reason (the avatar is decorative). icon-avatar is the only decorative descriptor, and the initials are its only text.
    • When the hairline is drawn. The overlay draws the hairline when the disc paints the ring's colour (chrome: canvas). This keeps it generic, with no axis-value logic in the engines.
    • No TSDoc for "md only". The RN codegen has no per-prop TSDoc channel today, so md only lives in the generated doc API notes and the page lead. The request adds no TSDoc channel.
    • Web default font size. Web type is rem while the circle is px, so a larger browser default font size grows the initials inside the circle. This is noted as accepted; browser zoom scales the whole avatar uniformly.
  3. darioaschero commented on Oct 24, 2026

    @darioaschero
    CollaboratorAuthor

    DS admission decision

    Outcome: Admit
    Admission class: Extension
    Architect: @darioaschero
    Second human architectural reviewer, if requester/consumer and architect overlap: none · sole-architect exception (@darioaschero) · independent review: #231 (comment)
    Requester / consumer owner: @darioaschero (nuri-com/nuri-expo, send S1 and the address book)
    Evidence considered: this issue as amended ("Request amended after independent review", #231 (comment)); the independent review; the evidence boards on main@d06e2cc (#228, packages/playground/pages/send-recipient.html X17, X19, X16); the companion request #230; the policy at main@45918bb (#229).

    Rationale: People in Nuri's send and contact lists need initials and a rail mark that no DS part can express today: IconAvatar takes a glyph or an image only, and nothing can lay one part over another. The initials half is small and closes two failure modes by mechanism (outline initials from ListAction's default, per-person colour through variant/accent). The accessory half is the right shape once its hairline takes no layout, which reproduces the reviewed board exactly. The overlap mechanism is bounded to this one part by a guard, so it does not become general positioning vocabulary.

    Accepted contract:

    • IconAvatar initials (string). A new initials text part and a scalar initials slot kind. Precedence source > initials > icon; presence is a non-empty string. In initials mode the root paints soft (bg-strong, text-primary) whatever variant/accent say. Type: md 17px regular on md, xs 13px on sm, each step's own letter-spacing; single line, tail truncation. RN: text inside a decorative component does not follow the OS font scale (IconAvatar is the only decorative component), so slot kinds carry no styling. Web: text is sized in rem, so a larger browser default size grows the initials inside the px circle; accepted.
    • IconAvatar accessory (IconName) and axis accessorySurface: 'canvas' | 'inverse', default canvas, on md only. Parts accessory (non-interactive view host, rendered only when accessory is set) and accessoryIcon. Disc sized by content: 15px glyph (new size leaf 2xs = 15px) + 3px padding = 21px for both surfaces; outset 3px; 2px knockout ring in bg-canvas; the hairline (border-subtle) is owned by the accessory part, takes no layout, and is drawn when the disc paints chrome: canvas (the disc matches the ring), with no axis-specific engine logic. canvas reuses chrome=canvas; inverse is a new chrome slot (bg-inverse + text-on-inverse), accent-invariant.
    • Sizes: the accessory is specified for md only, stated in the generated doc page's API notes and intro (the RN codegen emits no per-prop doc comments).
    • PartAnatomy.overlay: 'corner-accessory', descriptor-only, with an allowlist guard: only icon-avatar.accessory may declare it.
    • ListAction leadingAvatar forwards initials, accessory, accessorySurface, typed from the target's api (generic codegen change).
    • Web factory: generic camelCase→kebab-case axis attributes (accessory-surface).
    • Guards: overlay allowlist + non-interactive host; a text slot must be component or default.
    • Frozen-schema moves (Guard F, one deliberate move): PartAnatomy, SlotSpec.kind, contentMode (ordered list), PaletteChrome.
    • Accessibility: IconAvatar stays decorative. The accessory is never the only carrier of the rail: name it in visible row text or in the row's accessibilityLabel; a handle is not a name.

    Anti-scope: no person colour palettes; no coloured or accent rail badges; no accessory accent; no status, check, warning, cross or motion glyph as accessory content; no KYC or verified marks, counts or online dots; no overlay on any other part, on View, Pressable or Image, and no general positioning vocabulary (a second use is a new Extension); inverse on View sets no precedent for further chrome rows; 2xs is not an icon size; no letter-spacing token or option; no accessory on sm and no new avatar size; no name parsing or casing in the DS, three or more letters truncate and that is not a feature; no image-to-initials fallback; no surface-adaptive ring; no forwarding through SelectTrigger / SelectField; no generated avatars.

    Answers to the independent review:

    1. Outline disc 23px → the hairline is accessory-owned and takes no layout; both surfaces are 21×21, as on the board.
    2. Guard F covers four surfaces → accepted, PaletteChrome added to the ledger.
    3. accessory-variant on the web → axis renamed accessorySurface, generic camel→kebab attribute mapping in scope.
    4. Forwarded props typed unknown → generic forwarded-prop typing in scope; the exact-type proof stays.
    5. Bare-text scalar path → closed by the new text-slot guard.
    6. Missing generated twin → added to the occurrences.
    • Precedent of overlay → allowlist guard, non-interactive host, renamed value, BleedNS narrowing recorded here.
    • Slot kind carrying presentation → moot: tracking.caps is dropped.
    • OS font scaling → initials do not scale; verified at the largest accessibility size.
    • Accessibility of Nuri rows → the rail is named; the rebuilt Nuri rows read "Emil Berg, Nuri user, @emil".
    • Accessory accepts any IconName → status and motion glyphs refused in anti-scope; the type stays IconName (which glyphs are rails is product knowledge).
    • Glyph avatars can still take accent colour → acknowledged; out of this contract. The send.html person rows stop using accent glyphs because they move to initials.
    • Recurrence of the outline badge on every rail → recorded as a product decision of the consumer, not as recurrence.
    • tracking.caps → dropped (the smaller contract); a later request if initials read cramped on device.
    • Could not confirm: the nuri-expo references are the consumer's and are checked in the consumer PR; the first-version measurements stay visible in feat(playground): Send + Send-to-bank boards — the nuri-expo send journey as DS evidence #228; the contact is about 1px, as the review measured; the "ship gate" premise is withdrawn from the request.

    Process note: the session that wrote the independent review also applied the amendment, at the architect's direction and after the review was posted (stated in the amendment comment). The review itself was written before and independently of the amendment. The amendment only implements the review's own change list and the architect's calls above; the architect accepts it without a second review of the amended text.

    Contraction evidence considered, if applicable: contentMode reshape from one slot to an ordered list; its only user is icon-avatar and no descriptor is authored outside this repo. No consumer migration.
    Bounded execution scope reference: #231, "Proposed bounded execution scope if admitted" (as amended)
    Downstream verification plan reference: #231, "Downstream verification plan (before Ready)" (as amended)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions