diff --git a/packages/native/apple/Package.swift b/packages/native/apple/Package.swift index da37c458acbfc..60555733073cf 100644 --- a/packages/native/apple/Package.swift +++ b/packages/native/apple/Package.swift @@ -28,7 +28,7 @@ let package = Package( dependencies: ["AstryxNative"], path: "Sources/Astryx", // Each component's spec lives beside its view (AST-093 FR1). - exclude: ["Avatar/Avatar.spec.md", "Icon/Icon.spec.md"], + exclude: ["Avatar/Avatar.spec.md", "Avatar/AvatarStatusDot.spec.md", "Icon/Icon.spec.md"], resources: [.process("Resources")] ), .testTarget(name: "AstryxNativeTests", dependencies: ["AstryxNative"], path: "Tests/AstryxNativeTests"), diff --git a/packages/native/apple/Sources/Astryx/Avatar/Avatar.spec.md b/packages/native/apple/Sources/Astryx/Avatar/Avatar.spec.md index 14660d3c7727f..a4880cf9f1055 100644 --- a/packages/native/apple/Sources/Astryx/Avatar/Avatar.spec.md +++ b/packages/native/apple/Sources/Astryx/Avatar/Avatar.spec.md @@ -14,6 +14,7 @@ review_triggers: [public-api, behavior, layout, theming, accessibility] verified_by: [ packages/native/apple/Tests/AstryxTests/AvatarTests.swift, + packages/native/apple/Tests/AstryxTests/AvatarStatusDotTests.swift, packages/native/src/contract/contracts.test.ts, packages/native/src/contract/parseDocType.test.ts, ] @@ -29,15 +30,15 @@ system_specs: [spec:AST-082, spec:AST-093] ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `Avatar(_ name: String? = nil, src:, fallbackSrc:, alt:, size: AvatarSize?, shape: AvatarShape?, tooltip: AvatarTooltip?, href:, onClick:)` and `Avatar(_ props: AvatarProps)`, a SwiftUI `View` in module `Astryx`. `size` is the named sizes `xsm` to `xl`. | -| Behavior | Draws the image at `src`, else `fallbackSrc`, else initials from `name` on the theme's neutral plate, else a person symbol, clipped to its shape. With `href` it is a SwiftUI `Link`; with only `onClick` it is a button; it draws the same either way. | -| End-user impact | A person sees the same picture, initials, and shape as on the web, and VoiceOver reads the avatar's name, or skips a decorative one. | -| Builder impact | An app passes a name and an image URL, and optionally tooltip words. Pixel sizes, `status`, `target`, and `rel` have no Swift form; an `href` must be an absolute URL. | -| Compatibility/readiness | Additive: a new view and generated `AvatarProps`. Draft; built and tested on macOS and the iOS Simulator; device captures are attached to the change. | -| Review checks | Reject a size or shape beyond the contract, a disabled state, a link or button that draws differently from the static avatar, a layout that grows for the touch target, or a static avatar VoiceOver reads without a name. | -| Governing rules | `spec:AST-082` FR3, FR4, FR7; `spec:AST-093` FR1–FR5; the Swift component API (A1–A9), theme environment, and accessibility records under `packages/native/specs/swift/library/`. | +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `Avatar(_ name: String? = nil, src:, fallbackSrc:, alt:, size: AvatarSize?, shape: AvatarShape?, tooltip: AvatarTooltip?, href:, status:, onClick:)` and `Avatar(_ props: AvatarProps, status:)`, a SwiftUI `View` in module `Astryx`. `size` is the named sizes `xsm` to `xl`. | +| Behavior | Draws the image at `src`, else `fallbackSrc`, else initials from `name` on the theme's neutral plate, else a person symbol, clipped to its shape. With `href` it is a SwiftUI `Link`; with only `onClick` it is a button; it draws the same either way. | +| End-user impact | A person sees the same picture, initials, and shape as on the web, and VoiceOver reads the avatar's name, or skips a decorative one. | +| Builder impact | An app passes a name and an image URL, and optionally tooltip words and an `AvatarStatusDot`. Pixel sizes, `target`, and `rel` have no Swift form; an `href` must be an absolute URL. | +| Compatibility/readiness | Additive: a new view and generated `AvatarProps`. Draft; built and tested on macOS and the iOS Simulator; device captures are attached to the change. | +| Review checks | Reject a size or shape beyond the contract, a disabled state, a link or button that draws differently from the static avatar, a layout that grows for the touch target, or a static avatar VoiceOver reads without a name. | +| Governing rules | `spec:AST-082` FR3, FR4, FR7; `spec:AST-093` FR1–FR5; the Swift component API (A1–A9), theme environment, and accessibility records under `packages/native/specs/swift/library/`. | This table is a review projection; the body below is authoritative. @@ -70,7 +71,7 @@ Consumer migration instructions belong in consumer docs and release notes. - 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. +- Avatar groups, which have no Swift view. ## Public concepts @@ -83,15 +84,16 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Candidate invariant | Basis | Draft review state | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------ | -| FR1 | The view MUST show the image at `src`; when it is absent or fails, the image at `fallbackSrc`; then initials from a `name` that is not blank; then a person symbol. The fallback image loads only after `src` fails or when there is no `src`; while an image loads, the initials or the person symbol show. | core `Avatar` fallback order; `spec:AST-093` FR2 | verify | -| FR2 | Initials MUST follow core's rule: the first letter, digit, or emoji of the first and last words that have one, uppercased, skipping leading punctuation, keeping a whole emoji, flag, or joined emoji sequence. A name with none shows the person symbol and still names the avatar. | core `Avatar` initials | verify | -| FR3 | The avatar MUST be a square of 20, 24, 36, 48, or 128 points for `xsm` to `xl` at the default text size, grown with Dynamic Type up to 1.6 times and never shrunk, so it and its initials grow with the name beside them without swallowing a row (`spec:AST-082` FR11). It is clipped to a circle, to the theme's `radius.element`, or to a square. | core `Avatar` sizes and shapes | verify | -| 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 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 | -| 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 | +| ID | Candidate invariant | Basis | Draft review state | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ | +| FR1 | The view MUST show the image at `src`; when it is absent or fails, the image at `fallbackSrc`; then initials from a `name` that is not blank; then a person symbol. The fallback image loads only after `src` fails or when there is no `src`; while an image loads, the initials or the person symbol show. | core `Avatar` fallback order; `spec:AST-093` FR2 | verify | +| FR2 | Initials MUST follow core's rule: the first letter, digit, or emoji of the first and last words that have one, uppercased, skipping leading punctuation, keeping a whole emoji, flag, or joined emoji sequence. A name with none shows the person symbol and still names the avatar. | core `Avatar` initials | verify | +| FR3 | The avatar MUST be a square of 20, 24, 36, 48, or 128 points for `xsm` to `xl` at the default text size, grown with Dynamic Type up to 1.6 times and never shrunk, so it and its initials grow with the name beside them without swallowing a row (`spec:AST-082` FR11). It is clipped to a circle, to the theme's `radius.element`, or to a square. | core `Avatar` sizes and shapes | verify | +| 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: the content still grows with Dynamic Type (`spec:AST-082` FR11), up to its owner's growth, since the owner sizes what it holds (Swift slots S4). | 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 | **Tooltip by input (FR8).** The Astryx tooltip's rules apply; this table states Avatar's choices. @@ -129,7 +131,9 @@ states Avatar's choices. ### Transformation and precedence order - **ORD1 — Picture.** `src` → `fallbackSrc` → initials → person symbol. -- **ORD2 — Name.** `alt` → `name` → none, where a blank string is none. +- **ORD2 — Name.** `alt` → `name`, where a blank string is none; then + compose with the status label (AR6), a dot's label before another + element's. - **ORD3 — Control.** `href` (link) → `onClick` (button) → static. ### Performance and resources @@ -140,7 +144,8 @@ states Avatar's choices. ## Accessibility contract - **AR1 — Named or decorative.** A static avatar with a name is one image - element named by `alt`, else `name`; without one it is hidden from VoiceOver. + element named by `alt`, else `name`, composed with its status label (AR6); + without a name and without a status label it is hidden from VoiceOver. A link or button avatar is one element with the link or button trait and the same name; the initials are never an element of their own (Swift accessibility X1, X5). @@ -156,6 +161,10 @@ states Avatar's choices. `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 + core's message composes it ("Ada Lovelace, Online"); a status label alone + names an otherwise unnamed avatar. The label comes from an `AvatarStatusDot` or a labeled `Icon` in the slot, at any depth, as core reads the `label` of the element passed as `status`, and a dot's label wins, as core's reported dot label does; SwiftUI cannot read a label from other + views, so theirs does not join. ## Design relationships @@ -185,15 +194,16 @@ Theme values read: `color.neutral`, `color.textSecondary`, `radius.element`, ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | -| FR1, FR2 | `AvatarTests.swift` initials and fallback tests | core's initials cases; blank and symbol names | a wrong initial or a missing person symbol fails | `audit:swift/Avatar/picture` | -| FR3, FR4 | `AvatarTests.swift` size, text-size growth, shape, and two-theme renders | each size; xx-large and the largest text size; circle and square; light, dark, butter | a wrong side, no growth or growth past 1.6 times, an unclipped corner, or a color of its own fails | `audit:swift/Avatar/look` | -| FR5, AR2, AR4 | `AvatarTests.swift` link and button renders; tap, trait, tap-area, warning, `linkURL`, and web-path tests | static, link, button; malformed, relative, and empty `href` | a link or button that draws differently, a malformed `href` made a link, a web path not flagged, or an unflagged unnamed tap fails | `audit:swift/Avatar/control` | -| FR8 | `AvatarTests.swift` tooltip words, focus and touch, VoiceOver hint, and wire tests; `TooltipPresenterTests.swift` for each input; `contracts.test.ts`; `parseDocType.test.ts` | name, `true`, words, `false`, blank, no name; static, link, button | a wrong tooltip, a static avatar that is not a tab stop or does not open on a tap, a control that loses its tap, or a wire value that is not the web's fails | `audit:swift/Avatar/tooltip` | -| FR6 | `AvatarTests.swift` initializer and hidden tests; `contracts.test.ts` | both initializers; `isHidden`; named sizes | differing props, a drawn hidden avatar, or a pixel size in Swift fails | `audit:swift/Avatar/api` | -| AR1 | `AvatarTests.swift` naming and trait tests | `alt`, `name`, blank, none | a wrong name or a named decorative avatar fails | `audit:swift/Avatar/a11y` | -| AR5 | `AvatarTests.swift` two-theme renders draw the initials in core's tokens | default and neutral, light and dark | initials in tokens other than core's fail | `audit:swift/Avatar/look` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | +| FR1, FR2 | `AvatarTests.swift` initials and fallback tests | core's initials cases; blank and symbol names | a wrong initial or a missing person symbol fails | `audit:swift/Avatar/picture` | +| FR3, FR4 | `AvatarTests.swift` size, text-size growth, shape, and two-theme renders | each size; xx-large and the largest text size; circle and square; light, dark, butter | a wrong side, no growth or growth past 1.6 times, an unclipped corner, or a color of its own fails | `audit:swift/Avatar/look` | +| FR5, AR2, AR4 | `AvatarTests.swift` link and button renders; tap, trait, tap-area, warning, `linkURL`, and web-path tests | static, link, button; malformed, relative, and empty `href` | a link or button that draws differently, a malformed `href` made a link, a web path not flagged, or an unflagged unnamed tap fails | `audit:swift/Avatar/control` | +| FR8 | `AvatarTests.swift` tooltip words, focus and touch, VoiceOver hint, and wire tests; `TooltipPresenterTests.swift` for each input; `contracts.test.ts`; `parseDocType.test.ts` | name, `true`, words, `false`, blank, no name; static, link, button | a wrong tooltip, a static avatar that is not a tab stop or does not open on a tap, a control that loses its tap, or a wire value that is not the web's fails | `audit:swift/Avatar/tooltip` | +| FR6 | `AvatarTests.swift` initializer and hidden tests; `contracts.test.ts` | both initializers; `isHidden`; named sizes | differing props, a drawn hidden avatar, or a pixel size in Swift fails | `audit:swift/Avatar/api` | +| FR7, AR6 | `AvatarStatusDotTests.swift` placement renders of a dot and of other content at its measured place, size, an icon's growth at the largest text size, dot-label-wins, and name tests; the label preference that carries a label into the rendered name is not measured (AR3) | circle and square at `lg`; name with and without a status | a dot that changes the avatar's size, sits off the edge, or leaves the name without its label fails | `audit:swift/Avatar/status` | +| AR1 | `AvatarTests.swift` naming and trait tests | `alt`, `name`, blank, none | a wrong name or a named decorative avatar fails | `audit:swift/Avatar/a11y` | +| AR5 | `AvatarTests.swift` two-theme renders draw the initials in core's tokens | default and neutral, light and dark | initials in tokens other than core's fail | `audit:swift/Avatar/look` | ## Decision log @@ -201,8 +211,7 @@ No component-local decision is recorded. ## Open questions -- **OQ2 — Status dots.** (`human-api`) `status` holds `AvatarStatusDot`, which - has no Swift view, so it stays on the web until one exists. +None. ## Content boundary diff --git a/packages/native/apple/Sources/Astryx/Avatar/Avatar.swift b/packages/native/apple/Sources/Astryx/Avatar/Avatar.swift index d64eaad4559c0..40404ab856f2a 100644 --- a/packages/native/apple/Sources/Astryx/Avatar/Avatar.swift +++ b/packages/native/apple/Sources/Astryx/Avatar/Avatar.swift @@ -11,12 +11,15 @@ import os /// Avatar("Ada Lovelace", src: "https://example.com/ada.jpg") /// Avatar("Ada Lovelace", size: .lg, shape: .rounded) /// Avatar("Ada Lovelace", href: "https://example.com/ada") -/// Avatar("Ada Lovelace") { showProfile() } +/// Avatar("Ada Lovelace") { AvatarStatusDot(variant: .success, label: "Online") } +/// Avatar("Ada Lovelace", onClick: showProfile) /// ``` -public struct Avatar: View { +public struct Avatar: View { /// The contract the view draws, the same for both initializers. public let props: AvatarProps + let status: Status @Environment(\.astryx) private var astryx + @State private var statusLabel: String? /// An avatar from its labeled props. /// - Parameters: @@ -28,6 +31,7 @@ public struct Avatar: View { /// - shape: `circle`, `rounded`, or `square`; `nil` is `circle` (core `shape`). /// - tooltip: Words shown where there is a pointer: `nil` or `true` the name, a string those words, `false` none (core `tooltip`). /// - href: A URL the system opens when the avatar is tapped; the avatar becomes a link (core `href`). + /// - status: An `AvatarStatusDot` in the bottom trailing corner; a single trailing closure is this (core `status`). /// - onClick: Runs when the avatar is tapped; without `href` the avatar becomes a button (core `onClick`). public init( _ name: String? = nil, @@ -38,18 +42,21 @@ public struct Avatar: View { shape: AvatarShape? = nil, tooltip: AvatarTooltip? = nil, href: String? = nil, + @ViewBuilder status: () -> Status = { EmptyView() }, onClick: (() -> Void)? = nil ) { self.init( AvatarProps( src: src, fallbackSrc: fallbackSrc, name: name, alt: alt, size: size, shape: shape, tooltip: tooltip, href: href, onClick: onClick - )) + ), + status: status) } /// An avatar from its generated contract, for generated and wire-driven callers. - public init(_ props: AvatarProps) { + public init(_ props: AvatarProps, @ViewBuilder status: () -> Status = { EmptyView() }) { self.props = props + self.status = status() } public var body: some View { @@ -74,7 +81,9 @@ public struct Avatar: View { assertionFailure( "Avatar href \"\(props.href ?? "")\" is not an absolute URL an app can open; it draws without a link.") } - if Avatar.needsNameWarning(tap: tap, isNamed: accessibilityLabel != nil) { + // The avatar's own name: a status label alone does not name a person. + let ownName = Avatar.meaningful(props.alt) ?? Avatar.meaningful(props.name) + if Avatar.needsNameWarning(tap: tap, isNamed: ownName != nil) { Logger(subsystem: "Astryx", category: "Avatar").warning( "An Avatar with href or onClick has no alt or name, so VoiceOver reads it without a name.") } @@ -184,8 +193,40 @@ public struct Avatar: View { } .frame(width: side, height: side) .clipShape(AvatarShapeOutline(shape: resolvedShape, radius: cornerRadius)) + .overlay { + // Outside the clip, so the status is never cut off. The layout places + // it from its own size; SwiftUI mirrors the placement in right-to-left + // layouts, so it sits on the bottom leading corner there. + AvatarStatusPlacement(side: side, shape: resolvedShape) { + status + .environment(\.astryxAvatarSide, side) + // The owner sizes what it holds (slots S4): status content never + // grows with text faster than the avatar does. + .dynamicTypeSize(...Avatar.statusTypeSizeCap) + } + } + .onPreferenceChange(AvatarStatusLabelKey.self) { label in + statusLabel = label?.text + } + } + + /// How far the status content's bottom trailing corner moves out from the + /// avatar's, sized from the content itself, as core places any status: on a + /// circle its center sits on the edge at the bottom trailing 45° point; on a + /// rounded or square avatar it overhangs the corner by a quarter of its size. + nonisolated static func statusOffset(content: CGSize, side: CGFloat, shape: AvatarShape) -> CGSize { + switch shape { + case .circle: + return CGSize(width: content.width / 2 - side * circleEdgeInset, height: content.height / 2 - side * circleEdgeInset) + case .rounded, .square: + return CGSize(width: content.width / 4, height: content.height / 4) + } } + /// Where a 45-degree line meets a circle, as a fraction of its side from + /// the square's corner: (1 − 1/√2) / 2. + nonisolated static var circleEdgeInset: CGFloat { (1 - 1 / 2.squareRoot()) / 2 } + /// The fallback: initials on the neutral plate, else a person symbol. private var plate: some View { ZStack { @@ -219,6 +260,12 @@ public struct Avatar: View { /// The most an avatar grows with Dynamic Type. nonisolated static var maximumTextScale: CGFloat { 1.6 } + /// The largest text size status content grows to: the largest whose text + /// scale stays within the avatar's own growth (`maximumTextScale`). + nonisolated static var statusTypeSizeCap: DynamicTypeSize { + DynamicTypeSize.allCases.last { astryxTextScale(for: $0) <= maximumTextScale } ?? .large + } + nonisolated static func side(for size: AvatarSize, textScale: CGFloat) -> CGFloat { (points(for: size) * min(max(textScale, 1), maximumTextScale)).rounded() } @@ -248,7 +295,16 @@ public struct Avatar: View { /// The name VoiceOver reads: `alt`, else `name`; `nil` when neither /// carries anything, and then a static avatar is decorative. var accessibilityLabel: String? { - Avatar.meaningful(props.alt) ?? Avatar.meaningful(props.name) + Avatar.accessibleName(name: Avatar.meaningful(props.alt) ?? Avatar.meaningful(props.name), status: statusLabel) + } + + /// The name with the status dot's label: "Ada Lovelace, Online" in the + /// person's language; either alone when the other is absent. + nonisolated static func accessibleName(name: String?, status: String?) -> String? { + switch (name, status) { + case let (name?, status?): AstryxStrings.avatarNameWithStatus(name: name, status: status) + case let (name, status): name ?? status + } } /// The URL the avatar opens, or `nil` when it has no usable `href`. @@ -291,21 +347,21 @@ public struct Avatar: View { /// words that have one, uppercased. Leading punctuation in a word is skipped. nonisolated static func initials(from name: String) -> String { let letters = name.split(whereSeparator: { $0.isWhitespace }).compactMap { word in - word.first(where: canStandForAWord) + word.first(where: avatarCanStandForAWord) } guard let first = letters.first else { return "" } guard letters.count > 1, let last = letters.last else { return String(first).uppercased() } return (String(first) + String(last)).uppercased() } +} - /// A letter, a digit, or an emoji, as core's `\p{L}`, `\p{N}`, - /// `\p{Extended_Pictographic}`, and `\p{Regional_Indicator}`. - nonisolated static func canStandForAWord(_ character: Character) -> Bool { - if character.isLetter || character.isNumber { return true } - guard let scalar = character.unicodeScalars.first else { return false } - // `isEmoji` also covers `#`, `*`, and the digits, which are ASCII. - return scalar.value > 0x7F && scalar.properties.isEmoji - } +/// A letter, a digit, or an emoji, as core's `\p{L}`, `\p{N}`, +/// `\p{Extended_Pictographic}`, and `\p{Regional_Indicator}`. +func avatarCanStandForAWord(_ character: Character) -> Bool { + if character.isLetter || character.isNumber { return true } + guard let scalar = character.unicodeScalars.first else { return false } + // `isEmoji` also covers `#`, `*`, and the digits, which are ASCII. + return scalar.value > 0x7F && scalar.properties.isEmoji } /// Draws the image at a URL: `loading` while it loads, `failure` when it @@ -366,16 +422,37 @@ private struct AvatarShapeOutline: Shape { } } +/// Places the status content at the avatar's bottom trailing corner from the +/// content's own size (`Avatar.statusOffset`), in the same layout pass, so it +/// is in place from the first frame whatever it holds. +private struct AvatarStatusPlacement: Layout { + let side: CGFloat + let shape: AvatarShape + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + proposal.replacingUnspecifiedDimensions(by: CGSize(width: side, height: side)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let offset = Avatar.statusOffset(content: size, side: side, shape: shape) + let origin = CGPoint(x: bounds.maxX - size.width + offset.width, y: bounds.maxY - size.height + offset.height) + subview.place(at: origin, anchor: .topLeading, proposal: ProposedViewSize(size)) + } + } +} + /// One element named by the avatar's label. A static avatar without a label /// is hidden; an interactive one keeps its link or button trait. private struct AvatarName: ViewModifier { let label: String? - let tap: Avatar.Tap + let tap: Avatar.Tap func body(content: Content) -> some View { let isNamed = label != nil - let added = Avatar.addedTraits(tap: tap, isNamed: isNamed) - let removed = Avatar.removedTraits(tap: tap, isNamed: isNamed) + let added = Avatar.addedTraits(tap: tap, isNamed: isNamed) + let removed = Avatar.removedTraits(tap: tap, isNamed: isNamed) if let label { if tap == .none { // One image element named by the name. @@ -400,7 +477,7 @@ private struct AvatarTarget: ViewModifier { let side: CGFloat func body(content: Content) -> some View { - content.contentShape(.interaction, Rectangle().inset(by: -Avatar.tapOutset(side: side))) + content.contentShape(.interaction, Rectangle().inset(by: -Avatar.tapOutset(side: side))) } } diff --git a/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.spec.md b/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.spec.md new file mode 100644 index 0000000000000..c25f0fceab2d0 --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.spec.md @@ -0,0 +1,162 @@ +--- +schema_version: 3 +template_version: 7 +kind: component +id: component:swift/AvatarStatusDot +platform_of: component:AvatarStatusDot +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +owners: [cixzhang] +review_triggers: [public-api, behavior, theming, accessibility] +verified_by: + [ + packages/native/apple/Tests/AstryxTests/AvatarStatusDotTests.swift, + packages/native/src/contract/contracts.test.ts, + ] +modules: [] +families: [] +design_specs: [] +architecture: [] +contributing: [] +system_specs: [spec:AST-082, spec:AST-093] +--- + +# AvatarStatusDot (Swift) component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `AvatarStatusDot(variant: AvatarStatusDotVariant? = nil, label: String? = nil)` and `AvatarStatusDot(_ props: AvatarStatusDotProps)`, a SwiftUI `View`, placed in `Avatar`'s `status` slot. | +| Behavior | Draws core's dot at the tier for its avatar's size: `success` filled, `neutral` a ring, `error` a minus bar, each in theme tokens with a surface border. Its label joins the avatar's name. | +| End-user impact | A person sees who is online, away, or busy by shape as well as color, and VoiceOver reads it with the person's name ("Ada Lovelace, Online"). | +| Builder impact | An app puts the dot in the avatar's trailing closure with a label. A custom icon inside the dot has no Swift form. | +| Compatibility/readiness | Additive: a new view, generated `AvatarStatusDotProps`, and core's name-with-status message in the Swift string catalogs. Draft; built and tested on macOS and the iOS Simulator; device captures are attached. | +| Review checks | Reject a status shown by color alone, a dot that resizes or moves the avatar, a dot that VoiceOver reads separately inside an avatar, or a color that is not a theme token. | +| Governing rules | `spec:AST-082` FR4, FR7; `spec:AST-093` FR1–FR5; the Swift component API, slots (S4), theme environment, and accessibility (X1, X6) records under `packages/native/specs/swift/library/`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +AvatarStatusDot on Apple platforms shows a person's status on their avatar by +shape and color, sized from the avatar, and gives the avatar its words. + +## Compatibility and migration + +- Released default preserved: `not yet released` +- Compatibility class: additive +- Controlled/uncontrolled behavior: not applicable +- Migration decision: none + +Consumer migration instructions belong in consumer docs and release notes. + +## Ownership boundary + +**Owns** + +- The dot's size tier, fill, border, and shape glyph per variant. +- Reporting its label to the avatar that holds it. + +**Does not own / non-goals** + +- Where the dot sits and how the name is composed — owned by + `component:swift/Avatar` (FR7, AR6). +- The meaning of the variants — owned by `component:AvatarStatusDot`. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| ------- | ----------------------------- | -------------------------------------------------------- | ----------------------------------------- | --------- | --------------------------- | ------------ | ---------------------- | +| variant | `success`, `neutral`, `error` | filled dot, ring, minus bar | always | `success` | `component:AvatarStatusDot` | experimental | none | +| tier | small, medium, large | 10, 20, 32 point dot for avatars up to 36, 72, and above | from the avatar; small alone | small | `component:AvatarStatusDot` | experimental | none | + +## Behavioral and layout contract + +| 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 `color.success` 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 `color.error` and draw a round-capped bar in `color.onError` across three quarters of the inner field. Every dot has a `color.backgroundSurface` border. The plates are the strong status fills, `color.success` and `color.error`, and their ink is the matching content role, `color.onSuccess` and `color.onError`, as core's are (`architecture:theme-tokens` INV11). | core `AvatarStatusDot` styles | verify | +| FR3 | Both initializers MUST build the same `AvatarStatusDotProps`. Base props come through `AvatarStatusDotProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2, A3, A7 | verify | + +### Allowed variation + +- **AV1 — Glyph drawing.** The ring and bar are SwiftUI shapes at core's stroke + widths, not core's SVG. + +### Representative states + +| State | Required invariant | Allowed variation | +| -------------------------- | ---------------------------------- | ----------------- | +| each variant | its fill, glyph, and border | AV1 | +| each tier | its dot, border, and stroke | none | +| on a circle and a square | placed by `component:swift/Avatar` | none | +| light and dark, two themes | each theme's colors | none | + +### Transformation and precedence order + +- **ORD1 — Tier.** The avatar's side → small outside an avatar. + +### Performance and resources + +- **PR1 — Shapes only.** The dot draws SwiftUI shapes and loads nothing. + +## Accessibility contract + +- **AR1 — Inside an avatar.** The dot's label reaches VoiceOver through the + avatar's name, and the dot is not a separate element (Swift accessibility + X5). +- **AR2 — Alone.** A labeled dot outside an avatar is one image element named + by its label; an unlabeled one is hidden (X1). +- **AR3 — Not by color alone.** Each variant has its own shape (X6, Differentiate + Without Color). +- **AR4 — Rendered tree not measured.** Tests assert what the view sets; the + rendered accessibility tree is not measured until an app-hosted target + exists. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | ----------------------------------------------- | ------------------------ | -------------- | ------------------ | +| Dot and glyph | Apple Human Interface Guidelines, Accessibility | prescribed by core | supporting | FR1, FR2 | + +The component implements design requirements without copying their rationale. + +Platform control: SwiftUI has no status badge control for an image, so the dot +is drawn from SwiftUI shapes (`spec:AST-093` FR2). Guideline page: +Accessibility in the Apple Human Interface Guidelines. + +Theme values read: `color.success`, `color.error`, `color.onSuccess`, `color.onError`, `color.backgroundSurface`, +`color.textSecondary`. + +## Family and system relationships + +- `component:AvatarStatusDot` owns the variants and tiers; this record states + only how Swift draws them. +- `component:swift/Avatar` owns the dot's placement and the composed name. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| FR1 | `AvatarStatusDotTests.swift` tier test | 36, 48, 128 points; no avatar | a wrong dot, border, or stroke fails | `audit:swift/AvatarStatusDot/tier` | +| FR2 | `AvatarStatusDotTests.swift` variant render, ring sides, and the busy bar in Y2K | each variant at the large tier; neutral, butter, dark; Y2K light, where on-error and the surface differ | a wrong fill, a missing ring side or glyph, a bar not in on-error, or a glyph on success fails | `audit:swift/AvatarStatusDot/look` | +| FR3 | `AvatarStatusDotTests.swift` initializer test; `contracts.test.ts` | both initializers | differing props fail | `audit:swift/AvatarStatusDot/api` | +| AR1, AR2 | `AvatarStatusDotTests.swift` composed-name, standalone-trait, and hidden tests; the label the dot reports to its avatar in a rendered tree is not measured (`component:swift/Avatar` AR3) | name with and without a status; a dot alone with and without a label | a composed name without the status label, or a standalone dot without its label and image trait, fails | `audit:swift/AvatarStatusDot/a11y` | + +## Decision log + +No component-local decision is recorded. + +## Open questions + +- **OQ1 — A custom icon in the dot.** (`human-api`) Core's `icon` is free-form + content; it reaches Swift once the contract has the shared icon-name slot. + +## Content boundary + +This file does not duplicate consumer prop tables/examples, current audit +results, implementation steps, or family/system rules. It links to their owners. diff --git a/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.swift b/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.swift new file mode 100644 index 0000000000000..eb9ece8e163db --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Avatar/AvatarStatusDot.swift @@ -0,0 +1,181 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI + +/// A status dot for an avatar's corner (core `AvatarStatusDot`). Each variant +/// pairs a color with a shape, so status never rests on color alone: a filled +/// dot, a ring, or a dot with a minus bar. Inside an `Avatar` it sizes itself +/// from the avatar and its label joins the avatar's name ("Ada, Online"). +/// +/// ```swift +/// Avatar("Ada Lovelace", size: .lg) { +/// AvatarStatusDot(variant: .success, label: "Online") +/// } +/// ``` +public struct AvatarStatusDot: View { + /// The contract the view draws, the same for both initializers. + public let props: AvatarStatusDotProps + @Environment(\.astryx) private var astryx + @Environment(\.astryxAvatarSide) private var avatarSide + + /// A status dot from its labeled props. + /// - Parameters: + /// - variant: `success` (filled), `neutral` (ring), or `error` (minus bar); `nil` is `success` (core `variant`). + /// - label: What the status means, such as "Online" (core `label`). + public init(variant: AvatarStatusDotVariant? = nil, label: String? = nil) { + self.init(AvatarStatusDotProps(variant: variant, label: label)) + } + + /// A status dot from its generated contract, for generated and wire-driven callers. + public init(_ props: AvatarStatusDotProps) { + self.props = props + } + + public var body: some View { + if props.base.isHidden != true { + let tier = AvatarStatusDot.tier(forAvatarSide: avatarSide) + ZStack { + Circle().fill(plate) + glyph(tier) + } + .frame(width: tier.dot, height: tier.dot) + .overlay(Circle().strokeBorder(astryx.tokens.color.backgroundSurface, lineWidth: tier.border)) + .modifier(DotName(label: label)) + .modifier(DotIdentifier(id: props.base.id)) + .preference(key: AvatarStatusLabelKey.self, value: label.map { AvatarStatusLabel(text: $0, isDot: true) }) + } + } + + @ViewBuilder private func glyph(_ tier: Tier) -> some View { + let field = tier.dot - tier.border * 2 + switch variant { + case .neutral: + // The ring's outer edge lands on the inner field. + Circle() + .inset(by: tier.stroke / 2) + .stroke(ink, lineWidth: tier.stroke) + .frame(width: field, height: field) + case .error: + // A round-capped bar across three quarters of the inner field. + Capsule() + .fill(ink) + .frame(width: field * 0.75, height: tier.stroke) + case .success: + EmptyView() + } + } + + var variant: AvatarStatusDotVariant { props.variant ?? .success } + + /// The label, or `nil` when it is absent or blank. + var label: String? { + guard let label = props.label, !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return label + } + + /// The dot's fill: the variant color, or the surface for the ring. + var plate: Color { + switch variant { + case .success: astryx.tokens.color.success + case .neutral: astryx.tokens.color.backgroundSurface + case .error: astryx.tokens.color.error + } + } + + /// What the glyph draws in, as core pairs each plate with its ink: on-error + /// on the error plate, secondary text on the ring, on-success on success. + var ink: Color { + switch variant { + case .success: astryx.tokens.color.onSuccess + case .neutral: astryx.tokens.color.textSecondary + case .error: astryx.tokens.color.onError + } + } + + /// A labeled dot alone is an image; an unlabeled one is hidden and adds no + /// traits (Swift accessibility, X1). + nonisolated static func traits(label: String?) -> AccessibilityTraits { + label == nil ? [] : .isImage + } + + /// Core's discrete size tiers: dot, border, and glyph stroke in points. + struct Tier: Equatable { + let dot: CGFloat + let border: CGFloat + let stroke: CGFloat + } + + /// The tier for an avatar's side: up to 36 points small, up to 72 medium, + /// larger large. A dot outside an avatar is small, as for an `md` avatar. + nonisolated static func tier(forAvatarSide side: CGFloat?) -> Tier { + switch side ?? 36 { + case ...36: Tier(dot: 10, border: 1, stroke: 1) + case ...72: Tier(dot: 20, border: 2, stroke: 1.5) + default: Tier(dot: 32, border: 4, stroke: 2) + } + } +} + +/// A labeled dot on its own reads as an image with its label; inside an +/// avatar the avatar reads it as part of its own name. +private struct DotName: ViewModifier { + let label: String? + + func body(content: Content) -> some View { + if let label { + content.accessibilityElement(children: .ignore).accessibilityLabel(label) + .accessibilityAddTraits(AvatarStatusDot.traits(label: label)) + } else { + content.accessibilityHidden(true) + } + } +} + +/// The base `id` becomes the accessibility identifier (AST-082 FR7). +private struct DotIdentifier: ViewModifier { + let id: String? + + func body(content: Content) -> some View { + if let id { + content.accessibilityIdentifier(id) + } else { + content + } + } +} + +/// A status label reported to the avatar that holds it: from a dot, or from +/// other labeled content such as an `Icon`. +struct AvatarStatusLabel: Equatable, Sendable { + let text: String + let isDot: Bool +} + +/// The label the avatar's status names: a dot's wins over other content's, +/// wherever each sits, as core's reported dot label wins; else the first. +struct AvatarStatusLabelKey: PreferenceKey { + static let defaultValue: AvatarStatusLabel? = nil + + static func reduce(value: inout AvatarStatusLabel?, nextValue: () -> AvatarStatusLabel?) { + value = AvatarStatusLabelKey.winner(value, nextValue()) + } + + nonisolated static func winner(_ first: AvatarStatusLabel?, _ second: AvatarStatusLabel?) -> AvatarStatusLabel? { + guard let first else { return second } + guard let second else { return first } + return !first.isDot && second.isDot ? second : first + } +} + +private struct AstryxAvatarSideKey: EnvironmentKey { + static let defaultValue: CGFloat? = nil +} + +extension EnvironmentValues { + /// The side of the avatar a status dot sits on; `nil` outside an avatar. + var astryxAvatarSide: CGFloat? { + get { self[AstryxAvatarSideKey.self] } + set { self[AstryxAvatarSideKey.self] = newValue } + } +} diff --git a/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md b/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md index 6adddc48dd719..1088bf04bc093 100644 --- a/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md +++ b/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md @@ -135,6 +135,8 @@ Consumer migration instructions belong in consumer docs and release notes. view is hidden from VoiceOver. With a label, it is one image element named by the label, keeping SwiftUI's image trait and adding none (`spec:AST-082` FR17; Swift accessibility X1, X4). + In an avatar's status slot a labeled icon also names the status, which joins + the avatar's name (`component:swift/Avatar` AR6). - **AR2 — Rendered tree not measured.** Tests assert what the view sets; the rendered accessibility tree is not measured until an app-hosted target exists. diff --git a/packages/native/apple/Sources/Astryx/Icon/Icon.swift b/packages/native/apple/Sources/Astryx/Icon/Icon.swift index fc3635ab695ad..726bbdc9d0239 100644 --- a/packages/native/apple/Sources/Astryx/Icon/Icon.swift +++ b/packages/native/apple/Sources/Astryx/Icon/Icon.swift @@ -11,6 +11,7 @@ import SwiftUI /// Icon(.success, color: .success, label: "Completed") /// ``` public struct Icon: View { + @Environment(\.astryxAvatarSide) private var avatarSide /// The contract the view draws, the same for both initializers. public let props: IconProps @Environment(\.astryx) private var astryx @@ -43,6 +44,9 @@ public struct Icon: View { .modifier(IconTint(color: tint)) .modifier(IconAccessibility(label: accessibilityLabel)) .modifier(IconIdentifier(id: props.base.id)) + // In an avatar's status slot a labeled icon names the status, as + // core reads the `label` of the element passed as `status`. + .preference(key: AvatarStatusLabelKey.self, value: avatarSide == nil ? nil : accessibilityLabel.map { AvatarStatusLabel(text: $0, isDot: false) }) } else { // AST-082 FR17: a name nothing maps draws nothing, never a substitute. let _ = assertionFailure("Astryx has no icon named \"\(props.icon.rawValue)\"") diff --git a/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift b/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift index b1d0f4a2b85b1..d8b3a97d61641 100644 --- a/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift +++ b/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift @@ -7,6 +7,29 @@ import Foundation enum AstryxStrings { /// What a loading button says to assistive technology. static var buttonLoading: String { localized("@astryx.button.loading", "Loading") } + /// An avatar's name with its status, such as "Ada Lovelace, Online". + static func avatarNameWithStatus(name: String, status: String) -> String { + fill(localized("@astryx.avatar.nameWithStatus", "{name}, {status}"), ["name": name, "status": status]) + } + + /// Fills each `{key}` placeholder in a message once, in one pass, as core's + /// messages do, so a value that itself contains a placeholder stays as it is. + /// An unknown placeholder stays in place. + static func fill(_ template: String, _ values: [String: String]) -> String { + var result = "" + var rest = template[...] + while let open = rest.firstIndex(of: "{") { + result += rest[.. String { resourceBundle.localizedString(forKey: key, value: english, table: nil) diff --git a/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings index 813e6c77a5bd6..12fe2829f682b 100644 --- a/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laai tans"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings index dccd508e2ca6d..7ace10d499d13 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}، {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "جارٍ التحميل"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings index 89e09b3a6b817..70271808f1fb4 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "S’està carregant"; diff --git a/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings index 3a7d781bb8742..0a2471f21b059 100644 --- a/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Načítání"; diff --git a/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings index 8ce2a0ea1f032..e98fba8c747f9 100644 --- a/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Indlæser"; diff --git a/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings index ff1bccd25f1fa..cb126f783ceb9 100644 --- a/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Wird geladen"; diff --git a/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings index 0d47b6e858242..a7eded260a386 100644 --- a/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Φόρτωση"; diff --git a/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings index 83b5dd9cd13d5..c0c9a7dd38bfa 100644 --- a/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Loading"; diff --git a/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings index 8f34166c49c64..5e429443e6145 100644 --- a/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Cargando"; diff --git a/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings index 8c9ba77f8892a..4d03fc3ceaac7 100644 --- a/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Ladataan"; diff --git a/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings index dc0a07a62266c..1b52873feb357 100644 --- a/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Chargement"; diff --git a/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings index de3dfc3c00bfa..23631767faae0 100644 --- a/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "טוען"; diff --git a/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings index 9787ef4fc0810..93ad04d8e9e48 100644 --- a/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Betöltés"; diff --git a/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings index 51e91e6968562..c1f1a907c536c 100644 --- a/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Caricamento"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings index 01611ef320c42..210c487cc2a26 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}、{status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "読み込み中"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings index 9d3a31835a866..d7f1cf09567b2 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "로드 중"; diff --git a/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings index 9655829761c09..9b61659136b4e 100644 --- a/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laster"; diff --git a/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings index 830ff8dd7314a..30dc94017b2b5 100644 --- a/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laden"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings index 98cf5710419f4..fd4f32ca983ab 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Ładowanie"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings index 7cbe85335190f..50d6b03dd06eb 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Carregando"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings index e1bb5f1e790f7..3ebbd3226bb65 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "A carregar"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings index 65a69535e7e7e..731b8ead1a280 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Se încarcă"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings index b16c9adf22b20..57d3da1843ae3 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Загрузка"; diff --git a/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings index 27c4980a0fe04..b66737be0d000 100644 --- a/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Учитавање"; diff --git a/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings index 254b3f4ed9d8e..96865cd6587b5 100644 --- a/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Läser in"; diff --git a/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings index 709bb146a5a52..4cfb6c0877992 100644 --- a/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Yükleniyor"; diff --git a/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings index d72686e261f5f..eaf10a273ba39 100644 --- a/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Завантаження"; diff --git a/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings index eeddfd2a3fb72..559c838e97401 100644 --- a/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name}, {status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Đang tải"; diff --git a/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings index c1fe6f230512d..ab37bc74f97a1 100644 --- a/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name},{status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "加载中"; diff --git a/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings index 46768a323c60d..91afa80077de5 100644 --- a/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings @@ -1,4 +1,7 @@ /* Generated by @astryxdesign/native from core's locale catalogs. Do not edit. */ +/* Screen-reader accessible name for an Avatar showing a status indicator; composes the person's name with the status label, e.g. "Jane Doe, Online". {name} = the avatar's name/alt text, {status} = the status dot's label. Adjust separator and order per locale. */ +"@astryx.avatar.nameWithStatus" = "{name},{status}"; + /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "載入中"; diff --git a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift index 8603a38707343..cbcf99b47d6d5 100644 --- a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift +++ b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift @@ -19,7 +19,9 @@ public struct AstryxNodeView: View { case .heading(let heading): Heading(heading.viewProps(send: send)) case .card(let card): Card(card.viewProps(send: send), children: card.props.children ?? [], send: send) case .icon(let icon): Icon(icon.viewProps(send: send)) - case .avatar(let avatar): Avatar(avatar.viewProps(send: send)) + case .avatar(let avatar): + Avatar(avatar.viewProps(send: send)) { AstryxNodeList(nodes: avatar.props.status ?? [], send: send) } + case .avatarStatusDot(let dot): AvatarStatusDot(dot.viewProps(send: send)) default: AstryxUnsupportedNode(component: Self.componentName(node)) } } @@ -28,6 +30,7 @@ public struct AstryxNodeView: View { static func componentName(_ node: AstryxNode) -> String { switch node { case .avatar: "Avatar" + case .avatarStatusDot: "AvatarStatusDot" case .badge: "Badge" case .button: "Button" case .card: "Card" diff --git a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift index d34a5e73b415e..a5cf515dac7c1 100644 --- a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift +++ b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift @@ -8,6 +8,7 @@ import Foundation /// app then keeps drawing the rest of a newer core's tree. public enum AstryxNode: Decodable, Equatable, Sendable { case avatar(AvatarNode) + case avatarStatusDot(AvatarStatusDotNode) case badge(BadgeNode) case button(ButtonNode) case card(CardNode) @@ -26,6 +27,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { public var key: String { switch self { case .avatar(let node): return node.key + case .avatarStatusDot(let node): return node.key case .badge(let node): return node.key case .button(let node): return node.key case .card(let node): return node.key @@ -52,6 +54,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { do { switch component { case "Avatar": self = .avatar(try AvatarNode(from: decoder)) + case "AvatarStatusDot": self = .avatarStatusDot(try AvatarStatusDotNode(from: decoder)) case "Badge": self = .badge(try BadgeNode(from: decoder)) case "Button": self = .button(try ButtonNode(from: decoder)) case "Card": self = .card(try CardNode(from: decoder)) diff --git a/packages/native/apple/Sources/AstryxNative/Generated/Avatar.swift b/packages/native/apple/Sources/AstryxNative/Generated/Avatar.swift index 1743097dcd02e..82bc1c05328b0 100644 --- a/packages/native/apple/Sources/AstryxNative/Generated/Avatar.swift +++ b/packages/native/apple/Sources/AstryxNative/Generated/Avatar.swift @@ -68,6 +68,7 @@ public struct AvatarNode: Decodable, Equatable, Sendable { public var alt: String? public var size: AvatarSize? public var shape: AvatarShape? + public var status: [AstryxNode]? public var tooltip: AvatarTooltip? public var href: String? } @@ -132,6 +133,7 @@ public struct AvatarNode: Decodable, Equatable, Sendable { public var alt: String? public var size: AvatarSize? public var shape: AvatarShape? + public var status: [AstryxNode]? public var tooltip: AvatarTooltip? public var href: String? } diff --git a/packages/native/apple/Sources/AstryxNative/Generated/AvatarStatusDot.swift b/packages/native/apple/Sources/AstryxNative/Generated/AvatarStatusDot.swift new file mode 100644 index 0000000000000..20d9cdf33e2b3 --- /dev/null +++ b/packages/native/apple/Sources/AstryxNative/Generated/AvatarStatusDot.swift @@ -0,0 +1,78 @@ +// Generated by @astryxdesign/native. Do not edit. +public enum AvatarStatusDotVariant: String, CaseIterable, Codable, Sendable { + case success = "success" + case neutral = "neutral" + case error = "error" +} +#if os(iOS) +public struct AvatarStatusDotProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var variant: AvatarStatusDotVariant? + public var label: String? + public init(variant: AvatarStatusDotVariant? = nil, label: String? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.variant = variant + self.label = label + self.base = base + } +} +public struct AvatarStatusDotNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var variant: AvatarStatusDotVariant? + public var label: String? + } + public let key: String + public let base: AstryxBaseProps? + public let props: Props + /// The callbacks the core listens to, by prop name. + public let events: [String]? + + public func listens(_ event: String) -> Bool { events?.contains(event) ?? false } + + /// The contract a AvatarStatusDot view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> AvatarStatusDotProps { + return AvatarStatusDotProps( + variant: props.variant, + label: props.label, + base: base ?? AstryxBaseProps() + ) + } +} +#endif +#if os(macOS) +public struct AvatarStatusDotProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var variant: AvatarStatusDotVariant? + public var label: String? + public init(variant: AvatarStatusDotVariant? = nil, label: String? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.variant = variant + self.label = label + self.base = base + } +} +public struct AvatarStatusDotNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var variant: AvatarStatusDotVariant? + public var label: String? + } + public let key: String + public let base: AstryxBaseProps? + public let props: Props + /// The callbacks the core listens to, by prop name. + public let events: [String]? + + public func listens(_ event: String) -> Bool { events?.contains(event) ?? false } + + /// The contract a AvatarStatusDot view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> AvatarStatusDotProps { + return AvatarStatusDotProps( + variant: props.variant, + label: props.label, + base: base ?? AstryxBaseProps() + ) + } +} +#endif diff --git a/packages/native/apple/Tests/AstryxTests/AvatarStatusDotTests.swift b/packages/native/apple/Tests/AstryxTests/AvatarStatusDotTests.swift new file mode 100644 index 0000000000000..d548bd3882beb --- /dev/null +++ b/packages/native/apple/Tests/AstryxTests/AvatarStatusDotTests.swift @@ -0,0 +1,255 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI +import Testing +@testable import Astryx + +// Swift AvatarStatusDot spec (component:swift/AvatarStatusDot): core's three +// variants and size tiers, placed on the avatar's corner, with the label in +// the avatar's name. + +@Test func theDotTakesCoresTierForTheAvatarSize() { + #expect(AvatarStatusDot.tier(forAvatarSide: 36) == .init(dot: 10, border: 1, stroke: 1)) + #expect(AvatarStatusDot.tier(forAvatarSide: 48) == .init(dot: 20, border: 2, stroke: 1.5)) + #expect(AvatarStatusDot.tier(forAvatarSide: 128) == .init(dot: 32, border: 4, stroke: 2)) + #expect(AvatarStatusDot.tier(forAvatarSide: nil) == .init(dot: 10, border: 1, stroke: 1)) +} + +@MainActor +@Test func statusDotInitializersBuildTheSameProps() { + let labeled = AvatarStatusDot(variant: .error, label: "Busy").props + let fromProps = AvatarStatusDot(AvatarStatusDotProps(variant: .error, label: "Busy")).props + #expect(labeled.variant == fromProps.variant) + #expect(labeled.label == fromProps.label) + #expect(labeled.base == fromProps.base) + #expect(AvatarStatusDot().variant == .success) +} + +@Test func theStatusJoinsTheAvatarsName() { + #expect(Avatar.accessibleName(name: "Ada Lovelace", status: "Online") == "Ada Lovelace, Online") + #expect(Avatar.accessibleName(name: "Ada Lovelace", status: nil) == "Ada Lovelace") + #expect(Avatar.accessibleName(name: nil, status: "Online") == "Online") + #expect(Avatar.accessibleName(name: nil, status: nil) == nil) +} + +@MainActor +@Test func eachVariantPaintsItsTokensAndShape() { + let tokens = Theme.default.light.color + let success = Pixels(AvatarStatusDot(variant: .success).environment(\.astryxAvatarSide, 128).fixedSize()) + let error = Pixels(AvatarStatusDot(variant: .error).environment(\.astryxAvatarSide, 128).fixedSize()) + let neutral = Pixels(AvatarStatusDot(variant: .neutral).environment(\.astryxAvatarSide, 128).fixedSize()) + #expect(success.width == 32 && success.height == 32) + // The center: filled on success, the bar's surface ink on error, the + // ring's empty surface plate on neutral. + #expect(close(success.at(16, 16), rgba(tokens.success))) + #expect(close(error.at(16, 16), rgba(tokens.onError))) + #expect(close(error.at(16, 9), rgba(tokens.error))) + #expect(close(neutral.at(16, 16), rgba(tokens.backgroundSurface))) + // The neutral ring: its 2-point stroke sits just inside the 4-point border, + // on the inner field's edge, in secondary text, on all four sides. + for (x, y) in [(16, 5), (16, 26), (5, 16), (26, 16)] { + #expect(close(neutral.at(x, y), rgba(tokens.textSecondary), within: 12), "ring at \(x), \(y)") + } +} + +@MainActor +@Test func theDotSitsOnTheAvatarsCornerWithoutChangingItsSize() { + let plain = Pixels(Avatar("Ada Lovelace", size: .lg).fixedSize()) + let withDot = Pixels(Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success) }.fixedSize()) + #expect(withDot.width == plain.width && withDot.height == plain.height) + // A 20-point dot on a 48-point avatar: its center on the circle's edge at + // the bottom trailing 45° point, or a quarter over a square's corner. + typealias A = Avatar + let dot = CGSize(width: 20, height: 20) + let inset: CGFloat = 48 * A.circleEdgeInset + let expected: CGFloat = 10 - inset + #expect(abs(A.statusOffset(content: dot, side: 48, shape: .circle).width - expected) < 0.001) + #expect(A.statusOffset(content: dot, side: 48, shape: .square) == CGSize(width: 5, height: 5)) +} + +// Core's FR4 places any status content from its own size, not only a dot. +@Test func otherStatusContentIsPlacedFromItsOwnSize() { + typealias A = Avatar + let icon = CGSize(width: 16, height: 16) + #expect(A.statusOffset(content: icon, side: 48, shape: .square) == CGSize(width: 4, height: 4)) + let onCircle = A.statusOffset(content: icon, side: 48, shape: .circle) + let expected: CGFloat = 8 - 48 * A.circleEdgeInset + #expect(abs(onCircle.width - expected) < 0.001) +} + +/// The columns and rows a red square in the status slot covers, around a +/// 48-point avatar placed 20 points in. +@MainActor +private func redExtent(shape: AvatarShape, side content: CGFloat) -> (maxX: Int, maxY: Int) { + let view = Avatar("Ada Lovelace", size: .lg, shape: shape) { Color.red.frame(width: content, height: content) } + .padding(20).background(Color.white) + let pixels = Pixels(view) + var maxX = -1, maxY = -1 + for y in 0.. 200 && pixels.at(x, y)[1] < 90 { + maxX = max(maxX, x); maxY = max(maxY, y) + } } + return (maxX, maxY) +} + +// Core's FR4 places any status element from its own size: a 40-point square +// overhangs a square avatar's corner by 10 points, not a dot's 5. +@MainActor +@Test func otherStatusContentRendersAtItsMeasuredPlace() { + let square = redExtent(shape: .square, side: 40) + // The avatar spans 20..<68; the content's corner lands 10 points past it. + #expect(square.maxX == 77 && square.maxY == 77) + // On a circle its center sits on the 45° point, whatever its size. + let small = redExtent(shape: .circle, side: 10) + let large = redExtent(shape: .circle, side: 30) + #expect(abs((large.maxX - small.maxX) - 10) <= 1) +} + +// Core's FR5: a dot's label wins over other labeled content in the slot, +// whichever comes first. +@Test func aDotsLabelWinsOverAnIcons() { + let icon = AvatarStatusLabel(text: "Verified", isDot: false) + let dot = AvatarStatusLabel(text: "Online", isDot: true) + #expect(AvatarStatusLabelKey.winner(icon, dot) == dot) + #expect(AvatarStatusLabelKey.winner(dot, icon) == dot) + #expect(AvatarStatusLabelKey.winner(icon, nil) == icon) + #expect(AvatarStatusLabelKey.winner(nil, nil) == nil) +} + +@MainActor +@Test func aDotAloneIsAnImageNamedByItsLabel() { + #expect(AvatarStatusDot(label: "Busy").label == "Busy") + #expect(AvatarStatusDot.traits(label: "Busy") == .isImage) + #expect(AvatarStatusDot(label: " ").label == nil) + #expect(AvatarStatusDot.traits(label: nil) == []) +} + +@MainActor +@Test func aDotRendersFromTwoThemesWithNoColorOfItsOwn() { + let view = AvatarStatusDot(variant: .success).environment(\.astryxAvatarSide, 128).fixedSize() + #expect(close(Pixels(view, theme: .neutral).at(16, 16), rgba(Theme.neutral.light.color.success))) + #expect(close(Pixels(view, theme: .butter).at(16, 16), rgba(Theme.butter.light.color.success))) + #expect(close(Pixels(view, scheme: .dark).at(16, 16), rgba(Theme.default.dark.color.success))) +} + +@MainActor +@Test func aDotTakesTheTierOfItsGrownAvatarAtTheLargestTextSize() { + // At the largest text size an lg avatar grows past 72 points, so its dot + // moves up to the 32-point tier. + let grown = Avatar.side(for: .lg, textScale: 3) + #expect(grown == (48 * Avatar.maximumTextScale).rounded()) + #expect(AvatarStatusDot.tier(forAvatarSide: grown).dot == 32) + #expect(AvatarStatusDot.tier(forAvatarSide: 48).dot == 20) + let large = Pixels(Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success) }.fixedSize()) + let largest = Pixels( + Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success) }.fixedSize(), + typeSize: .accessibility5) + #expect(largest.width == Int(grown) && large.width == 48) +} + +@MainActor +@Test func aHiddenDotDrawsNothingAndNamesNothing() { + let hidden = AvatarStatusDot(AvatarStatusDotProps(variant: .success, label: "Online", base: AstryxBaseProps(isHidden: true))) + let withHidden = Pixels(Avatar("Ada Lovelace", size: .lg) { hidden }.fixedSize()) + let without = Pixels(Avatar("Ada Lovelace", size: .lg).fixedSize()) + let shown = Pixels(Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success) }.fixedSize()) + // Where a shown dot overlaps the avatar, a hidden one leaves the avatar as is. + #expect(withHidden.at(42, 42) == without.at(42, 42)) + #expect(shown.at(42, 42) != without.at(42, 42)) +} + +// AST-082 FR19: an Avatar node's status children draw as its dot. +@MainActor +@Test func anAvatarNodeDrawsItsStatusDot() { + let json = """ + {"component":"Avatar","key":"a","props":{"name":"Ada Lovelace","size":"lg","status":[ + {"component":"AvatarStatusDot","key":"d","props":{"variant":"success","label":"Online"}}]}} + """ + guard let node = try? JSONDecoder().decode(AstryxNode.self, from: Data(json.utf8)) else { + Issue.record("the avatar did not decode") + return + } + let wired = Pixels(AstryxNodeView(node) { _ in }.fixedSize()) + let direct = Pixels(Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success, label: "Online") }.fixedSize()) + let bare = Pixels(Avatar("Ada Lovelace", size: .lg).fixedSize()) + // The dot overlaps the avatar's lower trailing edge. + #expect(wired.at(42, 42) == direct.at(42, 42)) + #expect(wired.at(42, 42) != bare.at(42, 42)) +} + +@MainActor +@Test func anAvatarHoldingALabelledDotDrawsTheDotAndComposesItsName() { + let held = Pixels(Avatar("Ada Lovelace", size: .lg) { AvatarStatusDot(variant: .success, label: "Online") }.fixedSize()) + let bare = Pixels(Avatar("Ada Lovelace", size: .lg).fixedSize()) + #expect(close(held.at(42, 42), rgba(Theme.default.light.color.success), within: 8)) + #expect(held.at(42, 42) != bare.at(42, 42)) + // The name the avatar composes from the dot's label; the rendered + // accessibility tree that carries it is not measured (AR3, AR6). + #expect(Avatar.accessibleName(name: "Ada Lovelace", status: "Online") == "Ada Lovelace, Online") +} + +/// The leftmost and rightmost columns the success fill covers in an avatar +/// with a dot, 20 points in from each side of the render. +@MainActor +private func dotColumns(_ direction: LayoutDirection, shape: AvatarShape) -> (min: Int, max: Int, width: Int) { + let view = Avatar("Ada Lovelace", size: .lg, shape: shape) { AvatarStatusDot(variant: .success, label: "Online") } + .padding(20).background(Color.white).environment(\.layoutDirection, direction) + let pixels = Pixels(view.astryxTheme()) + let green = rgba(Theme.default.light.color.success) + var columns: [Int] = [] + for y in 0...statusTypeSizeCap == .accessibility1) + let view = Avatar("Ada Lovelace", size: .lg, shape: .square) { Icon(.success, color: .success, size: .md) } + .padding(40).background(Color.white) + let pixels = Pixels(view, typeSize: .accessibility5) + let green = rgba(Theme.default.light.color.iconSuccess) + var columns: [Int] = [] + for y in 0.. 0 && width <= 33, "status icon \(width) points wide") +} + +// Core fills its message in one pass: a name that contains a placeholder is +// read as written, never filled again. +@Test func aNameContainingAPlaceholderIsReadAsWritten() { + #expect(Avatar.accessibleName(name: "Fan of {status}", status: "Online") == "Fan of {status}, Online") + #expect(AstryxStrings.fill("{name}, {status}", ["name": "{status}", "status": "{name}"]) == "{status}, {name}") + #expect(AstryxStrings.fill("{name} {unknown}", ["name": "Ada"]) == "Ada {unknown}") + #expect(AstryxStrings.fill("{name", ["name": "Ada"]) == "{name") +} + diff --git a/packages/native/apple/Tests/AstryxTests/AvatarTests.swift b/packages/native/apple/Tests/AstryxTests/AvatarTests.swift index 6eddb1be762c7..124ff18c39b3b 100644 --- a/packages/native/apple/Tests/AstryxTests/AvatarTests.swift +++ b/packages/native/apple/Tests/AstryxTests/AvatarTests.swift @@ -30,7 +30,7 @@ import Testing ("# *", ""), ] for (name, expected) in cases { - #expect(Avatar.initials(from: name) == expected, "\(name)") + #expect(Avatar.initials(from: name) == expected, "\(name)") } } @@ -69,12 +69,12 @@ import Testing @MainActor @Test func namedSizesDrawCoresPointSizes() { - #expect(Avatar.points(for: .xsm) == 20) - #expect(Avatar.points(for: .sm) == 24) - #expect(Avatar.points(for: .md) == 36) - #expect(Avatar.points(for: .lg) == 48) - #expect(Avatar.points(for: .xl) == 128) - #expect(Avatar.side(for: .md, textScale: 1) == 36) + #expect(Avatar.points(for: .xsm) == 20) + #expect(Avatar.points(for: .sm) == 24) + #expect(Avatar.points(for: .md) == 36) + #expect(Avatar.points(for: .lg) == 48) + #expect(Avatar.points(for: .xl) == 128) + #expect(Avatar.side(for: .md, textScale: 1) == 36) let drawn = Pixels(Avatar("Ada", size: .lg).fixedSize()) #expect(drawn.width == 48 && drawn.height == 48) } @@ -109,7 +109,7 @@ import Testing @Test func aLinkOrButtonAvatarDrawsTheSameAsAStaticOne() { let still = Pixels(Avatar("Ada Lovelace", size: .sm).fixedSize()) let link = Pixels(Avatar("Ada Lovelace", size: .sm, href: "https://example.com/ada").fixedSize()) - let button = Pixels(Avatar("Ada Lovelace", size: .sm) {}.fixedSize()) + let button = Pixels(Avatar("Ada Lovelace", size: .sm, onClick: {}).fixedSize()) #expect(link.width == still.width && link.height == still.height) #expect(button.width == still.width && button.height == still.height) #expect(close(link.at(12, 2), still.at(12, 2))) @@ -131,19 +131,19 @@ import Testing #expect(Avatar("Ada", onClick: {}).tap == .button) #expect(Avatar("Ada", href: "https://example.com", onClick: {}).tap == .link) #expect(Avatar("Ada", href: "not a url", onClick: {}).tap == .button) - #expect(Avatar.addedTraits(tap: .none, isNamed: true) == .isImage) + #expect(Avatar.addedTraits(tap: .none, isNamed: true) == .isImage) // A link avatar is a link, not the button SwiftUI reports for `Link`. - #expect(Avatar.addedTraits(tap: .link, isNamed: true) == .isLink) - #expect(Avatar.removedTraits(tap: .link, isNamed: true) == .isButton) - #expect(Avatar.removedTraits(tap: .button, isNamed: true) == []) - #expect(Avatar.addedTraits(tap: .button, isNamed: true) == []) + #expect(Avatar.addedTraits(tap: .link, isNamed: true) == .isLink) + #expect(Avatar.removedTraits(tap: .link, isNamed: true) == .isButton) + #expect(Avatar.removedTraits(tap: .button, isNamed: true) == []) + #expect(Avatar.addedTraits(tap: .button, isNamed: true) == []) } @Test func aLinkOrButtonAvatarWithoutANameIsWarnedAbout() { - #expect(Avatar.needsNameWarning(tap: .button, isNamed: false)) - #expect(Avatar.needsNameWarning(tap: .link, isNamed: false)) - #expect(!Avatar.needsNameWarning(tap: .button, isNamed: true)) - #expect(!Avatar.needsNameWarning(tap: .none, isNamed: false)) + #expect(Avatar.needsNameWarning(tap: .button, isNamed: false)) + #expect(Avatar.needsNameWarning(tap: .link, isNamed: false)) + #expect(!Avatar.needsNameWarning(tap: .button, isNamed: true)) + #expect(!Avatar.needsNameWarning(tap: .none, isNamed: false)) } @MainActor @@ -152,9 +152,9 @@ import Testing let larger = Pixels(Avatar("Ada Lovelace", size: .md).fixedSize(), typeSize: .xxLarge) let largest = Pixels(Avatar("Ada Lovelace", size: .md).fixedSize(), typeSize: .accessibility5) #expect(large.width == 36) - #expect(larger.width == Int(Avatar.side(for: .md, textScale: 1.23))) - #expect(largest.width == Int((36 * Avatar.maximumTextScale).rounded())) - #expect(Avatar.side(for: .xl, textScale: 0.82) == 128) + #expect(larger.width == Int(Avatar.side(for: .md, textScale: 1.23))) + #expect(largest.width == Int((36 * Avatar.maximumTextScale).rounded())) + #expect(Avatar.side(for: .xl, textScale: 0.82) == 128) } @MainActor @@ -174,11 +174,11 @@ import Testing } @Test func aWebPathIsAnHrefTheAppCannotOpen() { - #expect(Avatar.isUnusableHref("/people/ada")) - #expect(Avatar.isUnusableHref("not a url")) - #expect(!Avatar.isUnusableHref("https://example.com/ada")) - #expect(!Avatar.isUnusableHref("")) - #expect(!Avatar.isUnusableHref(nil)) + #expect(Avatar.isUnusableHref("/people/ada")) + #expect(Avatar.isUnusableHref("not a url")) + #expect(!Avatar.isUnusableHref("https://example.com/ada")) + #expect(!Avatar.isUnusableHref("")) + #expect(!Avatar.isUnusableHref(nil)) } @MainActor @@ -211,12 +211,12 @@ private func onWhite(_ color: Color) -> [Int] { @Test func aStaticAvatarIsATabStopAndOpensOnATapAndAControlKeepsBoth() { // Core's `auto` touch resolves from focus: a static avatar opens on a tap, // a control keeps its tap. - #expect(Avatar.tooltipFocus(.none) == .tabStop) - #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.none)) == .tap) - #expect(Avatar.tooltipFocus(.button) == .control) - #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.button)) == .none) - #expect(Avatar.tooltipFocus(.link) == .control) - #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.link)) == .none) + #expect(Avatar.tooltipFocus(.none) == .tabStop) + #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.none)) == .tap) + #expect(Avatar.tooltipFocus(.button) == .control) + #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.button)) == .none) + #expect(Avatar.tooltipFocus(.link) == .control) + #expect(TooltipPresenter.resolvedTouch(.auto, focus: Avatar.tooltipFocus(.link)) == .none) } @MainActor @@ -232,11 +232,11 @@ private func onWhite(_ color: Color) -> [Int] { // xsm, sm, and md grow their tap area to 44 points; a static avatar with // no tooltip takes no taps and adds nothing. for side in [CGFloat(20), 24, 36] { - let target = Avatar.tapTargetSide(side, takesTaps: true) - #expect(target + 2 * Avatar.tapOutset(side: target) == 44) + let target = Avatar.tapTargetSide(side, takesTaps: true) + #expect(target + 2 * Avatar.tapOutset(side: target) == 44) } - #expect(Avatar.tapOutset(side: Avatar.tapTargetSide(20, takesTaps: false)) == 0) - #expect(Avatar.tapOutset(side: 128) == 0) + #expect(Avatar.tapOutset(side: Avatar.tapTargetSide(20, takesTaps: false)) == 0) + #expect(Avatar.tapOutset(side: 128) == 0) } diff --git a/packages/native/scripts/swift-ios-strings.mjs b/packages/native/scripts/swift-ios-strings.mjs index ff34ee9a0d7f7..fbdaa1f9b0ddc 100644 --- a/packages/native/scripts/swift-ios-strings.mjs +++ b/packages/native/scripts/swift-ios-strings.mjs @@ -26,7 +26,10 @@ export const SWIFT_IOS_STRINGS_DIR = path.join( ); /** The catalog messages the Swift components speak. */ -export const SWIFT_IOS_STRING_KEYS = ['@astryx.button.loading']; +export const SWIFT_IOS_STRING_KEYS = [ + '@astryx.avatar.nameWithStatus', + '@astryx.button.loading', +]; // Apple names a localization by its language alone unless one language has // several catalogs (pt-BR, pt-PT), and writes Chinese by script. diff --git a/packages/native/specs/swift/library/component-api.md b/packages/native/specs/swift/library/component-api.md index e66cb79d81e82..bd2926c6a54a8 100644 --- a/packages/native/specs/swift/library/component-api.md +++ b/packages/native/specs/swift/library/component-api.md @@ -148,10 +148,12 @@ same generated contract. (SwiftUI views with core's names, `Button`, `Text`, and so on, re-exporting SwiftUI and the contracts) and the `AstryxNative` product (generated contract types, unprefixed: `ButtonProps`, `ButtonVariant`). -- Avatar, Card, Heading, Icon, and Text take their generated `Props`; Button and +- Avatar, AvatarStatusDot, Card, Heading, Icon, and Text take their generated + `Props`; Button and TextInput still take hand-written parameters until each moves onto its generated props; Banner has no generated contract. -- `AstryxNodeView` draws Avatar, Card, Heading, Icon, and Text wire nodes. Any other +- `AstryxNodeView` draws Avatar (with its status dot), AvatarStatusDot, Card, + Heading, Icon, and Text wire nodes. Any other node, HStack and VStack included, renders in its place as unsupported, and its siblings still render, until the logic host draws stacks (A9). - The generated iOS `SwitchProps` still declares `size`, and `HStackProps` and diff --git a/packages/native/src/components/Avatar.ts b/packages/native/src/components/Avatar.ts index b28b1b5ac037b..ea26c059e7782 100644 --- a/packages/native/src/components/Avatar.ts +++ b/packages/native/src/components/Avatar.ts @@ -13,6 +13,8 @@ import type {NativeAdditions} from '../contract/fromComponentDoc.js'; export const avatar: NativeAdditions = { name: 'Avatar', baseTarget: 'root', + // `status` holds one AvatarStatusDot in the bottom trailing corner. + slots: {status: 'content'}, callbacks: ['onClick'], sameMeaning: {onClick: 'a press, as in Compose'}, // The named sizes every platform draws; the pixel sizes stay on the web. @@ -22,6 +24,5 @@ export const avatar: NativeAdditions = { webOnly: { target: 'HTML link target', rel: 'HTML link relationship', - status: 'holds AvatarStatusDot, which has no native view yet', }, }; diff --git a/packages/native/src/components/AvatarStatusDot.ts b/packages/native/src/components/AvatarStatusDot.ts new file mode 100644 index 0000000000000..d5f715203f273 --- /dev/null +++ b/packages/native/src/components/AvatarStatusDot.ts @@ -0,0 +1,20 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file components/AvatarStatusDot.ts + * @input None + * @output What @astryxdesign/native adds to the core AvatarStatusDot contract + * @position Native additions read by fromComponentDoc together with core's + * AvatarStatusDot documentation + */ + +import type {NativeAdditions} from '../contract/fromComponentDoc.js'; + +export const avatarStatusDot: NativeAdditions = { + name: 'AvatarStatusDot', + doc: {file: 'Avatar/AvatarStatusDot'}, + baseTarget: 'root', + webOnly: { + icon: 'free-form content; a native icon needs the shared icon-name slot', + }, +}; diff --git a/packages/native/src/components/index.ts b/packages/native/src/components/index.ts index c59523985d6d3..ee8eda9080c77 100644 --- a/packages/native/src/components/index.ts +++ b/packages/native/src/components/index.ts @@ -10,6 +10,7 @@ import type {NativeAdditions} from '../contract/fromComponentDoc.js'; import {avatar} from './Avatar.js'; +import {avatarStatusDot} from './AvatarStatusDot.js'; import {badge} from './Badge.js'; import {button} from './Button.js'; import {card} from './Card.js'; @@ -26,6 +27,7 @@ import {vStack} from './VStack.js'; export const NATIVE_COMPONENTS: readonly NativeAdditions[] = [ avatar, + avatarStatusDot, badge, button, card, diff --git a/packages/native/src/contract/contracts.test.ts b/packages/native/src/contract/contracts.test.ts index f0352f2db5d4b..17346bdec9ce1 100644 --- a/packages/native/src/contract/contracts.test.ts +++ b/packages/native/src/contract/contracts.test.ts @@ -161,10 +161,24 @@ describe('native contracts', () => { // `string | boolean`: words, or on (the name) and off. expect(avatar!.core.tooltip?.type).toEqual({kind: 'textOrBoolean'}); expect(avatar!.webOnly.map(prop => prop.name)).toEqual( - expect.arrayContaining(['target', 'rel', 'status', 'as']), + expect.arrayContaining(['target', 'rel', 'as']), ); }); + it('gives Avatar a status slot and AvatarStatusDot its variants and label', async () => { + const {contracts} = await buildContracts(loadCoreDoc); + const avatar = contracts.find(contract => contract.name === 'Avatar'); + expect(avatar!.core.status?.type).toEqual({kind: 'content'}); + const dot = contracts.find(contract => contract.name === 'AvatarStatusDot'); + expect(dot).toBeDefined(); + expect(dot!.core.variant?.type).toEqual({ + kind: 'enum', + values: ['success', 'neutral', 'error'], + }); + expect(dot!.core.label?.type).toEqual({kind: 'string'}); + expect(dot!.webOnly.map(prop => prop.name)).toContain('icon'); + }); + it('reports naming and portability errors', () => { const bad: ComponentContract = { name: 'Bad',