Skip to content

feat(native): Swift AvatarStatusDot shows status by shape and color, named with the avatar - #8328

Merged
cixzhang merged 1 commit into
mainfrom
native/swift-avatar-status-dot
Oct 26, 2026
Merged

cixzhang merged 1 commit into
mainfrom
native/swift-avatar-status-dot

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #8306 (Swift Avatar) and carries its commits until #8306 lands; the last commit is this change. The base is main so CI runs.

User need

People lists and chat headers show who is online, away, or busy on each person's avatar. Swift Avatar had no status slot, so an iOS app could not show presence at all, or drew its own dot that relied on color alone and that VoiceOver never read.

Problem and solution fit

  • AvatarStatusDot view. Core's three variants, each with its own shape so status never rests on color alone: success a filled dot, neutral a ring, error a minus bar. Core's size tiers (10, 20, 32 pt) follow the avatar's size.
  • Avatar gains its status slot. The dot sits outside the avatar's clip, on a circle's edge at four o'clock or overhanging a rounded or square corner by a quarter, mirrored in right-to-left layouts, and never changes the avatar's size.
  • Status in the name. The dot's label joins the avatar's name through core's message ("Ada Lovelace, Online"), in the person's language; core's message joins the Swift string catalogs.

Current specification

  • spec:AST-082 FR4 (typed content slot), FR7; spec:AST-093 FR1–FR5.
  • Swift library records: component API, slots (S4), theme environment, accessibility (X1, X5, X6).
  • New: component:swift/AvatarStatusDot at packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.spec.md (draft, platform_of: component:AvatarStatusDot, beside the view as core keeps it in Avatar/).
  • Amends component:swift/Avatar (from feat(native): Swift Avatar with core's picture, initials, and fallback order #8306): FR7 (status placement), AR4 (status in the name), FR6 (a single trailing closure is now the status); its OQ2 is resolved and removed.

Public delta and caller burden

import Astryx

Avatar("Ada Lovelace", size: .lg) {
  AvatarStatusDot(variant: .success, label: "Online")
}
Avatar("Grace Hopper") { AvatarStatusDot(variant: .neutral, label: "Away") } onClick: { open() }
AvatarStatusDot(variant: .error, label: "Busy")   // alone: an image named "Busy"
  • New: AvatarStatusDot and generated AvatarStatusDotProps, AvatarStatusDotVariant.
  • Changed from feat(native): Swift Avatar with core's picture, initials, and fallback order #8306: Avatar is generic over its status content, and a single trailing closure is the status. A button avatar now passes onClick: by label or as a trailing onClick: closure. This matches Item, where a single trailing closure is content and the action comes last, and it stops Avatar("Ada") { AvatarStatusDot() } from silently becoming a button.
  • Not in Swift: a custom icon inside the dot (core's is free-form content; it needs the shared icon-name slot).

Design and alternatives

  • SwiftUI has no status badge for an image, so the dot is drawn from SwiftUI shapes at core's stroke widths.
  • The dot reports its label to the avatar through a SwiftUI preference, so the avatar composes its name without the caller passing the label twice.

Evidence

Capture set: iPhone 16 simulator, iOS 18.5, Xcode 16.4, built from 60235fe3 (the head since changes only the record), with the initials drawn, the right-to-left dot outside the leading corner, the busy bar in on-error, and a labeled Icon in the status slot placed from its own size (circle, square, right to left). At the largest text size the status Icon grows only as far as the avatar does, 1.6 times: status content still grows with Dynamic Type (AST-082 FR11) up to its owner's growth, since the owner sizes what it holds (Swift slots S4). Each capture shows the three variants, the three tiers on sm, md, lg, and xl avatars, the three shapes, right-to-left placement, and two themes.

Light Dark Light, largest text Light, largest text, end Dark, largest text Dark, largest text, end
AvatarStatusDot, Light AvatarStatusDot, Dark AvatarStatusDot, Light, largest text AvatarStatusDot, Light, largest text, end AvatarStatusDot, Dark, largest text AvatarStatusDot, Dark, largest text, end

At the largest text size the avatars grow 1.6 times with #8306's text-size growth, and an lg avatar's dot moves up to the 32 pt tier.

Not measured here: #8306's tooltip on hover, focus, and tap, which simctl captures cannot drive; an app-hosted UI test opens it with a tap above the avatar.

Not measured: the composed name ("Ada Lovelace, Online") in a rendered accessibility tree. No app-hosted test reads it yet; AvatarStatusDotTests.swift tests the name the avatar composes from the dot's label.

An AvatarStatusDot wire node draws as AvatarStatusDot in AstryxNodeView, and an Avatar node draws its status children as its dot (AST-082 FR19). The component API record lists both among the views AstryxNodeView draws. The status name joins the avatar's as AR6; tests check the composed name and a render of the dot. The name in a rendered accessibility tree is not measured (AR3).

check:knowledge does not scan native component specs yet; both records pass the checker in #8288, which does.

Implementation choices under current rules

Open-work search

Searched current records and open pull requests before creating component:swift/AvatarStatusDot, by canonical id (component:swift/AvatarStatusDot, component:AvatarStatusDot), paths (packages/native/apple/Sources/Astryx/AvatarStatusDot/, packages/native/src/components/AvatarStatusDot.ts), symbols (AvatarStatusDot, AvatarStatusDotProps, status slot, @astryx.avatar.nameWithStatus), and terms (native, swift, avatarstatusdot). No other record or open pull request owns this platform component; the Swift views moved onto core names in #8299 and the Icon slot in #8302 are its neighbors.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

  • R1 narration: none; the records state what the views do.
  • R2 research: none.
  • R3 evidence: bases name specs, core's AvatarStatusDot and Avatar, and tests; no pull request or commit.
  • R4 rejected alternatives: none recorded.
  • R5 change references: none.
  • R6 open questions: AvatarStatusDot OQ1 (a custom icon) is unanswered and keeps its question; Avatar's OQ2 is answered by this change and removed.

Testing

  • vitest run packages/native: the Avatar status slot and the AvatarStatusDot contract pass; the regenerated string catalogs pass their drift check.
  • swiftc -typecheck of AstryxNative, Astryx, and the tests passes. An immediate-mode render checks each variant's fill and glyph at the large tier, that a dot leaves a 48 pt avatar 48 pt, and the placement offsets.
  • The Swift tests run in CI on macOS and on the iOS Simulator.

Every changed file is under packages/native/.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 26, 2026 12:32pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 26, 2026, 12:56 PM UTC

@cixzhang
cixzhang changed the base branch from native/swift-avatar to main October 24, 2026 23:18
@cixzhang cixzhang closed this Oct 24, 2026
@cixzhang cixzhang reopened this Oct 24, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the two new draft Swift records, component:swift/Avatar and component:swift/AvatarStatusDot: an engineering owner records it by commenting /approve-spec d330a66cdaf3b0e6c2aff5c2669f27568001bf71 on this pull request (the author may do so, being an engineering owner), and says for each of three departures from current spec whether the current claim will be amended or this pull request should conform: fixed-size initials versus spec:AST-082 FR11, the missing tooltip versus spec:AST-082 FR3, and AvatarStatusDot.spec.md living in Avatar/ versus spec:AST-093 FR1. Separately from that decision, these need fixing:

  • Three of the six linked captures (Dark, Light largest text, Dark largest text) are the same image and it shows the light appearance, so please relink the dark and largest-text captures.
  • AvatarStatusDotTests.swift checks only the default light theme and never asserts the standalone dot's label and image trait, both of which spec:AST-093 FR5 asks for.
  • warnIfUnnamed in Avatar.swift counts the status label as a name, so a link or button avatar with only a labelled dot can skip the warning that core and Avatar AR4 require.
  • Avatar.spec.md line 73 still lists status dots as a non-goal with no Swift view.

[Automated review]


private func warnIfUnnamed() {
#if DEBUG
if Avatar.needsNameWarning(tap: tap, isNamed: accessibilityLabel != nil) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accessibilityLabel includes the status dot's label, so a link or button avatar with no alt or name but a labelled dot can skip this warning once the label has arrived. Core counts only alt or name here, and AR4 says the same; consider isNamed: (Avatar.meaningful(props.alt) ?? Avatar.meaningful(props.name)) != nil.


- The meaning of the props, the initials rule, and the size scale — owned by
`component:Avatar`.
- Status dots and avatar groups, which have no Swift view.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status dots now have a Swift view, and FR7 and AR5 of this record own the slot; this non-goal could read "Avatar groups, which have no Swift view."


@MainActor
@Test func eachVariantPaintsItsTokensAndShape() {
let tokens = Theme.default.light.color

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads one theme only. spec:AST-093 FR5 asks for a render from two different themes and for assertions of the label and traits, here the standalone dot's label and image trait.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the two new draft Swift records, component:swift/Avatar and component:swift/AvatarStatusDot: an engineering owner records it by commenting /approve-spec 9c8add60d60a308ae621d9e37c7327efcf7fdb48 on this pull request (the author may do so, being an engineering owner), and says for each departure from current spec whether the current claim will be amended or this pull request should conform: the 1.6× cap on Dynamic Type growth versus spec:AST-082 FR11, the touch behaviour of tooltip and its new string | boolean contract kind versus spec:AST-082 FR3 and FR4, and AvatarStatusDot.spec.md living in Avatar/ versus spec:AST-093 FR1. Separately from that decision, two things need fixing:

  • The default tooltip goes through .help, which also sets the VoiceOver hint, so every avatar with a name is read with its name twice (Avatar.swift:66); core keeps the default name tooltip out of the description, so give the default name no hint and keep the hint for custom words.
  • AvatarStatusDotTests.swift never renders the dot at the largest Dynamic Type size, which spec:AST-093 FR5 asks for, although an lg avatar's dot moves to the 32 pt tier there.

[Automated review]

public var body: some View {
if props.base.isHidden != true {
control
.modifier(AvatarTooltipModifier(text: tooltipText))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.help also sets the accessibility hint (Apple: "Adding help to a view configures the view's accessibility hint and its help tag"), and tooltipText is the name by default, so VoiceOver reads a named avatar's name and then the same name again as its hint. Core keeps the default name tooltip visual-only and describes only custom words (packages/core/src/Avatar/Avatar.tsx:638-643). Consider leaving the default name out of the hint and keeping it for a custom string, with a test of which case sets a hint.

}

@MainActor
@Test func theDotSitsOnTheAvatarsCornerWithoutChangingItsSize() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-093 FR5 asks for a check at the largest Dynamic Type size, and no test here renders the dot at any size but the default. At .accessibility5 an lg avatar draws 77 pt, so its dot moves to the 32 pt tier and statusOffset changes; a variant of this test with typeSize: .accessibility5 asserting the 77 pt side and 32 pt dot would cover it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the two new draft Swift records, component:swift/Avatar and component:swift/AvatarStatusDot: an engineering owner records it by commenting /approve-spec 61f5feffd2c6374b4cae87ffaaca4b882229afe5 on this pull request (the author may do so, being an engineering owner), and says for each departure from current spec whether the current claim will be amended or this pull request should conform: the 1.6× cap on Dynamic Type growth versus spec:AST-082 FR11, the pointer-only tooltip and its new string | boolean contract kind versus spec:AST-082 FR3 and FR4, and AvatarStatusDot.spec.md living in Avatar/ versus spec:AST-093 FR1. Separately from that decision, four things need fixing:

  • The pointer tooltip waits 500 ms and calls that core's delay (Avatar.swift:433-434, Avatar FR8), but core's Avatar tooltip opens after 200 ms.
  • Avatar.spec.md does not list the theme values the tooltip bubble reads (color.textPrimary, color.backgroundSurface, space.s1, space.s2, radius.container, and the body text size and weight), which spec:AST-093 FR4 asks for.
  • No test covers a hidden AvatarStatusDot, although spec:AST-093 FR5 asks for parameter-mapping tests.
  • The description says the largest-text captures match the default ones, but they show the avatars at 1.6× and the lg dot at 32 pt.

[Automated review]

@State private var pending: Task<Void, Never>?

/// Core's tooltip delay before showing on hover.
static let delay: Duration = .milliseconds(500)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core's Avatar tooltip opens after 200 ms: Avatar.tsx calls useTooltip({placement: 'above', isEnabled: showTooltip}) with no delay, and useTooltip defaults to delay = 200. Consider 200 ms here and in Avatar FR8, or keep 500 ms as a stated platform choice rather than calling it core's.

pages: Images, Buttons, and Accessibility in the Apple Human Interface
Guidelines.

Theme values read: `color.neutral`, `color.textSecondary`, `radius.element`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-093 FR4 asks each spec to list the theme values the component reads. AvatarTooltipBubble also reads color.textPrimary, color.backgroundSurface, space.s1, space.s2, radius.container, and the body text size and weight; they could join this list.

}

@MainActor
@Test func aDotAloneIsAnImageNamedByItsLabel() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test hides a dot. A test like aHiddenAvatarDrawsNothing for AvatarStatusDot(AvatarStatusDotProps(base: AstryxBaseProps(isHidden: true))) would cover spec:AST-093 FR5's parameter mapping and guard against a hidden dot still adding its label to the avatar's name.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the two new draft records, component:swift/Avatar and component:swift/AvatarStatusDot: an engineering owner records it by commenting /approve-spec c0dd70535f3114d3ea14d72f205ccf6ed3bc73ff on this pull request (the author may do so as an engineering owner) and says, for each departure from current spec, whether the current claim will be amended or this pull request should conform. Three record fixes remain whatever that decision is.

  • Departures to settle: the 1.6x Dynamic Type cap versus spec:AST-082 FR11; the new string | boolean contract kind versus FR4; Avatar's tooltip, which a scroll view can cut off because the shared bubble draws in the avatar's own layer, versus FR3's rule that a platform never offers a prop it honors only in part; and AvatarStatusDot.spec.md in Avatar/ versus spec:AST-093 FR1.
  • Avatar FR7 says four o'clock where core and circleEdgeInset use the 45° point; use core's wording.
  • AvatarStatusDot FR2 makes the base color.success and color.error a requirement where core requires the surface status roles and records the base tokens as a gap; align the row with core.
  • AvatarStatusDot's verification row says its tests fail on a name without the status label, but no test reads the label the dot reports to the avatar; mark that path not measured, as the Avatar record does, or add the test.

[Automated review]

| FR5 | With an `href` that is an absolute URL (it has a scheme), the avatar MUST be a SwiftUI `Link`; with `onClick` too, `onClick` runs as it opens and the URL then goes to the app's own URL handler, or the system's. An `href` that is not an absolute URL, such as a web path, MUST stop a development build with a message naming it; a release build draws the avatar without a link, as a button with `onClick` or static. With only `onClick`, a SwiftUI `Button`. Either draws exactly as the static avatar, and the avatar has no disabled state. | Swift component API A4 | verify |
| FR6 | Both initializers MUST build the same `AvatarProps`; `name` is the unlabeled first parameter, a single trailing closure is the status, and `onClick` comes last. Base props come through `AvatarProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; `spec:AST-082` FR7 | verify |
| FR7 | The `status` slot MUST draw its content, one `AvatarStatusDot`, outside the avatar's clip: on a circle with the dot's center on the edge at four o'clock, on a rounded or square avatar overhanging the bottom trailing corner by a quarter of the dot, mirrored in right-to-left layouts. It MUST NOT change the avatar's size or layout, and gives the dot the avatar's side for its size tier. | core `Avatar` status position; Swift slots S4 | verify |
| FR8 | `tooltip` MUST resolve, as core does, to the name for `nil` or `true`, the given words for a string, and nothing for `false` or blank words, and MUST show through the Astryx tooltip (`architecture:native-swift-library-tooltip`), with the focus and touch the Tooltip by input table below states: a static avatar is a tab stop and opens on a tap; a link or button keeps its focus and its tap. Given words are the VoiceOver hint. On the wire it is the web value, a string or a Bool. | core `Avatar` `tooltip`; core `Tooltip`; `spec:AST-082` FR3 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR8 shows tooltip through the shared Astryx tooltip, whose draft record says the bubble "draws in the view's own layer, so a scroll view or another clipping container around the view cuts it off" (packages/native/specs/swift/library/tooltip.spec.md INV1). Avatar is the first view to offer tooltip through it, and people lists scroll, while spec:AST-082 FR3 says a platform never offers a prop it honors only in part. This is one of the departures waiting on an engineering owner's ruling: amend FR3 on main, or draw the bubble above clipping containers.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision about the new draft rows in component:swift/AvatarStatusDot and component:swift/Avatar (FR6, FR7 and AR6): an engineering owner records it by commenting /approve-spec with the full SHA of the head they approve, /approve-spec 891203c1d6aa354d33fb3832e24c996820877478 for this one, and the author may do so as an engineering owner. The decision also needs to settle whether spec:AST-093 FR1 will be amended to allow the dot's record and view in Avatar/, or they should move to AvatarStatusDot/. One fix is needed either way:

  • Right to left, the status dot lands inside the avatar's leading edge: SwiftUI already mirrors the .offset inside the .bottomTrailing overlay, so negating it at Avatar.swift:201 moves the dot inward (5 pt inside a square's edge and 6 pt inside a circle's in the pull request's own capture); dropping the negation and adding a right-to-left render test fixes it.

[Automated review]

// Outside the clip, so the dot is never cut off.
status
.environment(\.astryxAvatarSide, side)
.offset(x: layoutDirection == .rightToLeft ? -statusOffset : statusOffset, y: statusOffset)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right to left, SwiftUI already mirrors this horizontal offset inside the .bottomTrailing overlay, so the negation moves the dot inward: in the capture attached to this pull request the dot sits 5 pt inside a square avatar's leading edge and its centre 6 pt inside a circle's 45° point, where core Avatar FR4 and this record's FR7 mirror the left-to-right placement. .offset(x: statusOffset, y: statusOffset) keeps the outward push in both directions, and a right-to-left render test would pin it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Swift status dot follows core's tiers, marks, placement, and composed name, and its tests pass on macOS and the iOS Simulator. Approval waits on an engineering owner deciding the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record and Avatar FR6, FR7, and AR6, including the trailing-closure change) by commenting /approve-spec 006afd55abdc041a6e7a14a6d7ed9c1aa11a3184 on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether AvatarStatusDot.spec.md may stay in Avatar/ under AST-093 FR1 or must move to AvatarStatusDot/. Two fixes are needed either way:

  • The right-to-left rows in the attached captures put the dot inside the leading edge, while FR7 and rightToLeftTheDotMirrorsOutsideTheLeadingCorner put it outside; please capture again from this head or mark right-to-left placement as not measured.
  • eachVariantPaintsItsTokensAndShape reads only the neutral dot's centre, so a missing ring still passes; assert a pixel on the ring or narrow the FR2 verification row.

[Automated review]

#expect(close(success.at(16, 16), rgba(tokens.success)))
#expect(close(error.at(16, 16), rgba(tokens.backgroundSurface)))
#expect(close(error.at(16, 9), rgba(tokens.error)))
#expect(close(neutral.at(16, 16), rgba(tokens.backgroundSurface)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads the neutral dot's centre, which is the surface plate with or without the ring, and no other test draws .neutral, so removing the ring keeps every test green although the FR2 verification row says a missing glyph fails. A pixel on the ring would pin it, for example at the large tier, where the ring spans 10 to 12 pt from the centre:

#expect(close(neutral.at(16, 5), rgba(tokens.textSecondary)))

| FR5 | With an `href` that is an absolute URL (it has a scheme), the avatar MUST be a SwiftUI `Link`; with `onClick` too, `onClick` runs as it opens and the URL then goes to the app's own URL handler, or the system's. An `href` that is not an absolute URL, such as a web path, MUST stop a development build with a message naming it; a release build draws the avatar without a link, as a button with `onClick` or static. With only `onClick`, a SwiftUI `Button`. Either draws exactly as the static avatar, and the avatar has no disabled state. | Swift component API A4 | verify |
| FR6 | Both initializers MUST build the same `AvatarProps`; `name` is the unlabeled first parameter and `onClick` the last, so it can be a trailing closure. Base props come through `AvatarProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; `spec:AST-082` FR7 | verify |
| FR6 | Both initializers MUST build the same `AvatarProps`; `name` is the unlabeled first parameter, a single trailing closure is the status, and `onClick` comes last. Base props come through `AvatarProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; `spec:AST-082` FR7 | verify |
| FR7 | The `status` slot MUST draw its content, one `AvatarStatusDot`, outside the avatar's clip: on a circle centered on the circle's edge at the bottom trailing 45° point, on a rounded or square avatar overhanging the bottom trailing corner by a quarter of the dot, mirrored in right-to-left layouts. It MUST NOT change the avatar's size or layout, and gives the dot the avatar's side for its size tier. | core `Avatar` status position; Swift slots S4 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The captures attached to the description draw this row's right-to-left case with the dot inward: on the lg square it sits 5 pt inside the leading edge and on the circle its centre is 6 pt inside the mirrored 45° point, while .offset(x: statusOffset, y: statusOffset) and rightToLeftTheDotMirrorsOutsideTheLeadingCorner put it outside. AST-093 FR5 rests appearance on captures of the change, so the right-to-left rows need a capture from this head, or a not-measured note.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record and Avatar FR6, FR7 and AR6, including the trailing-closure change), recorded by commenting /approve-spec 82b9b6f72c9b6fc8073ee7bceddfbeaa98064e2f on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether AvatarStatusDot.spec.md may stay in Avatar/ under AST-093 FR1 or must move to AvatarStatusDot/. One fix is needed either way:

  • The busy dot's minus bar is drawn in color.backgroundSurface (AvatarStatusDot.swift line 88), while theme-tokens INV11 and core's AvatarStatusDot FR2 put it in on-error; in the Y2K light theme it is 1.52:1 against its plate, so please use color.onError, update FR2 to match, and test the bar in a theme where the two colors differ.

[Automated review]


/// What the glyph draws in: the surface on a colored dot, secondary text on the ring.
var ink: Color {
variant == .neutral ? astryx.tokens.color.textSecondary : astryx.tokens.color.backgroundSurface

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error bar takes backgroundSurface here, while theme-tokens INV11 puts content on a strong status fill in --color-on-<status> and core's AvatarStatusDot FR2 paints the minus bar in --color-on-error. In the Y2K light theme this bar is white on the pale pink plate at 1.52:1 (on-error gives 6.69:1), and in the default dark theme it is 4.37:1. Returning astryx.tokens.color.onError for .error fixes both.

| ID | Candidate invariant | Basis | Draft review state |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------ |
| FR1 | The dot MUST take core's tier for its avatar's side: up to 36 points a 10-point dot with a 1-point border and 1-point glyph stroke; up to 72 a 20-point dot, 2-point border, 1.5-point stroke; larger a 32-point dot, 4-point border, 2-point stroke. Outside an avatar it is small. | core `AvatarStatusDot` size tiers | verify |
| FR2 | `success` MUST fill with the success plate color and draw no glyph; `neutral` MUST fill with `color.backgroundSurface` and draw a ring in `color.textSecondary` on the inner field; `error` MUST fill with the error plate color and draw a round-capped bar in `color.backgroundSurface` across three quarters of the inner field. Every dot has a `color.backgroundSurface` border. Status fills use the surface-specific status roles, as core requires (`architecture:theme-tokens` INV11); core records that its plates still use the base tokens, so the success and error plates fill with `color.success` and `color.error`, as core's do, until core moves. | core `AvatarStatusDot` styles | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR2 draws the error bar in color.backgroundSurface and says core's plates "still use the base tokens"; core's current FR2 calls the plates "the strong status fills" and draws the bar in --color-on-error. This row and the theme-value list should name color.onError for the bar.

@cixzhang
cixzhang force-pushed the native/swift-avatar-status-dot branch from 82b9b6f to 7d64e9d Compare October 26, 2026 05:08

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record and Avatar FR6, FR7 and AR6, including the trailing-closure change), recorded by commenting /approve-spec 7d64e9d4085c8a6b8b6327b152de86b8ac0eef50 on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether AvatarStatusDot.spec.md may stay in Avatar/ under AST-093 FR1 or must move to AvatarStatusDot/. Two fixes are needed either way:

  • The status slot places and names everything as a dot (Avatar.swift lines 212–218), so other content, such as a labeled Icon, lands off the position core's Avatar FR4 gives it and its label never joins the name as core's FR5 composes it; please size the offset from the content and read its label, or limit the slot to AvatarStatusDot.
  • AvatarStatusDot FR2 still says core records base-token plates, while core's current FR2 calls them the strong status fills; please update that sentence.

[Automated review]

Comment on lines 212 to 218
var statusOffset: CGFloat {
let dot = AvatarStatusDot.tier(forAvatarSide: side).dot
switch resolvedShape {
case .circle: return dot / 2 - side * Avatar.circleEdgeInset
case .rounded, .square: return dot / 4
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The offset comes from the dot tier, not from what the slot holds, so any other status content (an Icon, a wire node, any view) lands off the point core's Avatar FR4 gives it: a 24 pt icon in an md circle's slot centers at (23.7, 23.7) instead of (30.7, 30.7). Core translates its slot by 50% or 25% of its own size. Its label also never joins the name, since only AvatarStatusDot reports one, while core FR5 reads a string label on the passed element. Sizing the offset from the content (for example alignment guides reading its ViewDimensions) and reading a held component's label would match core; or the slot could accept only AvatarStatusDot.

| ID | Candidate invariant | Basis | Draft review state |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------ |
| FR1 | The dot MUST take core's tier for its avatar's side: up to 36 points a 10-point dot with a 1-point border and 1-point glyph stroke; up to 72 a 20-point dot, 2-point border, 1.5-point stroke; larger a 32-point dot, 4-point border, 2-point stroke. Outside an avatar it is small. | core `AvatarStatusDot` size tiers | verify |
| FR2 | `success` MUST fill with the success plate color and draw no glyph; `neutral` MUST fill with `color.backgroundSurface` and draw a ring in `color.textSecondary` on the inner field; `error` MUST fill with the error plate color and draw a round-capped bar in `color.onError` across three quarters of the inner field. Every dot has a `color.backgroundSurface` border. Status fills use the surface-specific status roles, as core requires (`architecture:theme-tokens` INV11); core records that its plates still use the base tokens, so the success and error plates fill with `color.success` and `color.error`, as core's do, until core moves. | core `AvatarStatusDot` styles | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"core records that its plates still use the base tokens … until core moves" no longer holds: core's current AvatarStatusDot FR2 says "The plates are the strong status fills and their ink is the matching content role", and theme-tokens INV11 makes --color-<status> the strong status fill. The code is right; this sentence could say the plates are the strong status fills color.success and color.error with their on-color ink.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record, Avatar FR6, FR7 and AR6 including the trailing-closure change, and the Swift Icon record's new AR1 sentence), recorded by commenting /approve-spec 1c4bdf00c288ce0f283a2370147214268b06a12e on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether AvatarStatusDot.spec.md may stay in Avatar/ under AST-093 FR1 or must move to AvatarStatusDot/. Two changes are needed either way:

  • A labeled Icon placed before an AvatarStatusDot in the status slot outranks the dot's label (AvatarStatusDot.swift lines 152–153), so VoiceOver reads "Ada Lovelace, Verified" where core's Avatar FR5 and ORD2 give "Ada Lovelace, Online"; please let a dot's label win.
  • The non-dot status state has no capture and no test that renders its measured placement, which AST-093 FR5 asks for; please attach captures of an avatar holding a labeled Icon at this head, or report that state as not measured, and add a test that fails without the measurement.

[Automated review]

static let defaultValue: String? = nil

static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps the first label in view order, and Icon now writes the same key (Icon.swift line 49), so HStack { Icon(.success, label: "Verified"); AvatarStatusDot(label: "Online") } names the avatar "Ada Lovelace, Verified". Core's Avatar FR5 and ORD2 let the dot's reported label win, and core names the same slot "Ada Lovelace, Online". Carrying the source with the value and preferring a dot's label here would match core.

// slot without changing the avatar's size. Its label joining the name in a
// rendered tree is not measured (AR3).
@MainActor
@Test func aLabeledIconInTheStatusSlotIsPlacedFromItsSize() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks only that the avatar keeps its size, so it still passes with the .onGeometryChange line in Avatar.swift removed, when the icon would sit where a dot of the avatar's tier does. AST-093 FR5 asks for a state-change test and captures of this state; checking the icon's position, or recording the measured placement as not measured in the FR7 row, would close the gap.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record, Avatar FR6, FR7 and AR6 including the trailing-closure change, and the Swift Icon record's new AR1 sentence), recorded by commenting /approve-spec b9865c967252d1daa4b15628c0bf61eeef2e51c9 on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether AvatarStatusDot.spec.md may stay in Avatar/ under AST-093 FR1 or must move to AvatarStatusDot/, and whether status content should keep its own text-size growth, since at the largest text size a labeled Icon reaches 62 pt on a 77 pt avatar in the description's captures. One change is needed either way: the Swift Avatar record's AR1 and ORD2 still say an avatar without alt or name is hidden from VoiceOver, while AR6 now names it by its status label, so please align them as core's Avatar AR1 and ORD2 read.

[Automated review]

`color.textSecondary` on `color.neutral`, as core's Avatar does. In the
default and neutral themes' dark appearance that pair is below 4.5:1, in
core as well; the fix belongs to the theme tokens.
- **AR6 — Status in the name.** The status label joins the avatar's name as

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This AR6 names an avatar by its status label alone, and Avatar.swift line 288 does so, but AR1 (line 144) still says an avatar without alt or name "is hidden from VoiceOver" and ORD2 (line 134) still reads "alt → name → none". Please add "and no status label" to AR1, and the status composition with the dot-then-Icon label order to ORD2, as core's Avatar AR1 and ORD2 read.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record; Avatar FR6, FR7 with its new Accessibility 1 cap on status content, and AR6; and the Swift Icon record's AR1 sentence), recorded by commenting /approve-spec 60235fe3a37942c722af01b84368b8f54efe6227 on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether spec:AST-082 FR11 will be amended for that cap, which stops text in the status slot from growing past Accessibility 1, and whether AvatarStatusDot.spec.md may stay in Avatar/ under spec:AST-093 FR1 or must move to AvatarStatusDot/. One change is needed either way: the description's largest-text captures were built before the cap and show a 62 pt status Icon this head no longer draws, so please recapture them from this head, or report that state as not measured, and mention the cap in the description.

[Automated review]

.environment(\.astryxAvatarSide, side)
// The owner sizes what it holds (slots S4): status content never
// grows with text faster than the avatar does.
.dynamicTypeSize(...Avatar<EmptyView>.statusTypeSizeCap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cap is new at this head, so the description's largest-text captures, built before it, show a status Icon at 62 pt on a 77 pt avatar where this line now holds it near 32 pt. spec:AST-093 FR5 rests text-size claims on captures attached to the change: please recapture the light and dark largest-text "end" captures from this head, or report the capped state as not measured, and mention the cap in the description.

| FR4 | Initials and the person symbol MUST draw in `color.textSecondary` on `color.neutral`; initials use the theme's body family at medium weight and 40% of the side. | core `Avatar` fallback styles; `spec:AST-093` FR4 | verify |
| FR5 | With an `href` that is an absolute URL (it has a scheme), the avatar MUST be a SwiftUI `Link`; with `onClick` too, `onClick` runs as it opens and the URL then goes to the app's own URL handler, or the system's. An `href` that is not an absolute URL, such as a web path, MUST stop a development build with a message naming it; a release build draws the avatar without a link, as a button with `onClick` or static. With only `onClick`, a SwiftUI `Button`. Either draws exactly as the static avatar, and the avatar has no disabled state. | Swift component API A4 | verify |
| FR6 | Both initializers MUST build the same `AvatarProps`; `name` is the unlabeled first parameter, a single trailing closure is the status, and `onClick` comes last. Base props come through `AvatarProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; `spec:AST-082` FR7 | verify |
| FR7 | The `status` slot MUST draw its content, usually one `AvatarStatusDot`, outside the avatar's clip, placed from the content's own size, in the same layout pass, as core places any status: on a circle centered on the circle's edge at the bottom trailing 45° point, on a rounded or square avatar overhanging the bottom trailing corner by a quarter of its size, mirrored in right-to-left layouts. It MUST NOT change the avatar's size or layout, gives a dot the avatar's side for its size tier, and lets other content grow with Dynamic Type no faster than the avatar, up to the text size at its 1.6 times growth, since the owner sizes what it holds (Swift slots S4). | core `Avatar` status position; Swift slots S4 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new last clause holds every status view at Accessibility 1, so text placed in the slot stops following text sizes above it, while spec:AST-082 FR11 says "text follows the platform's text-size setting". This is one of the rulings the open owner question asks for: amend FR11 on main, or let text in the slot keep growing.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record; Avatar FR6, FR7 and AR6; and the Swift Icon record's AR1 sentence), recorded by commenting /approve-spec 850812ff80fd3fb89ac9b8f52f970d8140f3122c on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether spec:AST-082 FR11 allows FR7's Accessibility 1 cap on text in the status slot, and whether AvatarStatusDot.spec.md may stay in Avatar/ under spec:AST-093 FR1 or must move to AvatarStatusDot/. One fix is needed either way: AstryxStrings.avatarNameWithStatus substitutes {status} into the name itself, so an avatar named "Fan of {status}" with an "Online" dot is read as "Fan of Online, Online"; replacing {status} before {name}, or filling both in one pass, keeps the name as core's message does.

[Automated review]

Comment on lines +13 to +14
.replacingOccurrences(of: "{name}", with: name)
.replacingOccurrences(of: "{status}", with: status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two passes substitute into the name too: with name = Fan of {status} and an "Online" dot, the second pass turns the name's own {status} into "Online", giving "Fan of Online, Online", where core's ICU message (core Avatar FR5) keeps the name as given. Replacing {status} first and {name} last (the status is the app's own label), or filling both placeholders in one pass, avoids it; a case in theStatusJoinsTheAvatarsName would pin it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision on the draft Swift requirements this head adds (the new component:swift/AvatarStatusDot record; Avatar FR6, FR7 and AR6; and the Swift Icon record's AR1 sentence), recorded by commenting /approve-spec a440083d66c668951f71a9ffaf451cae8415688f on this pull request; the author may do so as an engineering owner, and a later commit needs the command again. The owner should also say whether spec:AST-082 FR11 allows FR7's Accessibility 1 cap on text in the status slot, and whether AvatarStatusDot.spec.md may stay in Avatar/ under spec:AST-093 FR1 or must move to AvatarStatusDot/. The name-with-status fill now fills each placeholder once, so a name containing {status} is read as written, and no other change is needed.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Decision (Cindy, 2026-10-26): approved. The Swift AvatarStatusDot record and the Avatar and Icon record changes on this head are accepted as written. That includes the status-content growth cap under slots S4, which keeps AST-082 FR11 unamended, and the spec file staying beside its view in Avatar/.

What this PR needs: nothing more. It merges at this head once CI is green.

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec a440083

This branch was successfully deployed

1 active deployment
Preview — a440083d Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant