Skip to content

feat(native): Swift Item draws core's row with typed slots, as a link or a button - #8321

Merged
cixzhang merged 1 commit into
mainfrom
native/swift-item
Nov 1, 2026
Merged

cixzhang merged 1 commit into
mainfrom
native/swift-item

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User need

Almost every iOS screen is rows: people lists, settings, menus, search results. There is no Swift Item, so each app builds its own row with its own padding, truncation, selected color, and tap handling. The person using the app gets rows that don't line up with each other or with the web, tap targets that are too small, and VoiceOver reading a row as several unrelated pieces.

Problem and solution fit

  • Item view. Core's row: marker, start content, the label over or beside its description, end content, with core's density padding, alignment (center, start, firstLine), line limits, and the theme's highlighted, selected, and disabled looks.
  • Taps the platform's way. With href the row is a SwiftUI Link the system opens; with only onClick a SwiftUI Button. It draws the same either way, is at least 44 pt tall when tappable, and a disabled link or button stays reachable, reads as dimmed, and does nothing (component API A4).
  • Typed slots. Start content, end content, and marker take any SwiftUI view through generic @ViewBuilder closures with empty defaults (slots S3), so an Avatar, an Icon, or a plain view fits without AnyView. An Item node sent by a logic core draws its marker, start content, and end content nodes the same way (AST-082 FR19).
  • Safe destinations. A javascript:, vbscript:, or data:text/html href leaves the row inert, never a link, through the same check Swift Button uses (core's navigation destinations FR4). The development check that stops on a web-path href now lives beside it and is shared with Swift Avatar; Item's check reads the href as its link does (trimmed). Avatar's behavior is unchanged: it renders and links exactly as before for every href.

Current specification

  • spec:AST-082 FR3 (only props a platform honors), FR4 (typed slots), FR7.
  • spec:AST-093 FR1–FR5.
  • Swift library records: component API (A2–A4, A7, A9), slots (S1, S3–S5), theme environment, accessibility (X1, X2, X5, X7).
  • New: component:swift/Item at docs/specs/components/Item/Item.swift.spec.md (draft, platform_of: component:Item), beside core's Item record per AST-093 FR1. Its DEC-1 records that swipe actions are web-only for now.

Public delta and caller burden

import Astryx

Item("Ada Lovelace", description: "Mathematician") {
  Avatar("Ada Lovelace")
} endContent: {
  Icon(.chevronRight, color: .secondary)
}
Item("Notifications", onClick: openNotifications)
Item("Archive") { Icon(.upload) } onClick: { archive() }
Item("Calendar", href: "https://example.com/calendar", isSelected: true)
Item(ItemProps(label: "Ada", density: .compact))
  • New: Item (Astryx) and generated ItemProps, ItemAlign, ItemDensity, ItemLayout (AstryxNative).
  • A single trailing closure is the start content; onClick comes last, by label or as a trailing onClick: closure. A caller who sets marker passes every slot by label.
  • Not in Swift: swipeActions and swipeBehavior, which are web-only for now (DEC-1: SwiftUI's swipe actions work only inside a SwiftUI List, so a row elsewhere could not honor them), target, rel, data-testid (the base id is the accessibility identifier).
  • An href must be an absolute URL; a web path stops a development build with its value and draws without a link in release. A link row does not run onClick. In core the link is the label and a click elsewhere on the row runs onClick; a SwiftUI Link takes the whole row, so the Swift row has no such area (FR5).
  • No change to any existing view.

Design and alternatives

  • SwiftUI List does not fit: it is a scrolling container that owns its rows, while core's Item sits in any container (AST-093 FR2). The row uses Link and Button for taps and draws its own layout.
  • Each line takes the theme's leading over the system font's own line height, so rows match core's heights.
  • A tappable row grows to 44 pt; a static row keeps core's height.

Evidence

Capture set: iPhone 16 simulator, iOS 18.5, Xcode 16.4, built from this PR's head, with Icon and Avatar taken from their open pull requests for the slot content. Each capture shows people rows (button, selected, disabled), settings rows (highlighted, link), the three densities, the three alignments, the inline layout, a two-line label, and two themes.

Light Dark Light, largest text Light, largest text, end Dark, largest text Dark, largest text, end
Item, Light Item, Dark Item, Light, largest text Item, Light, largest text, end Item, Dark, largest text Item, Dark, largest text, end

Captures are from before the rebase onto the new spec layout; the views draw the same, and the record's location, DEC-1, the safe-destination check, and the wire slots are covered by the tests below.

Open-work search

Searched current records and open pull requests before creating component:swift/Item, by canonical id (component:swift/Item, component:Item), paths (packages/native/apple/Sources/Astryx/Item/, docs/specs/components/Item/, packages/native/src/components/Item.ts), symbols (Item, ItemProps, List, ListItem, swipeActions), and terms (native, swift, item). No other record or open pull request owns this platform component; the Swift views moved onto core names in #8299 and the Icon slot in #8302 are its neighbors.

Scope

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

Writing rubric results

  • R1 narration: none; the record states what the view does.
  • R2 research: none.
  • R3 evidence: bases name specs, core's Item, and tests; no pull request or commit.
  • R4 rejected alternatives: one line on SwiftUI List, which AST-093 FR2 requires the spec to name.
  • R5 change references: none.
  • R6 open questions: none; swipe actions are decided as web-only for now (DEC-1).

Testing

  • check:knowledge passes with the record at its new path.
  • vitest run packages/native/src/contract: the Item contract (text and content slots, press, link, swipes and HTML attributes on the web) passes; the committed Swift binding matches the contracts.
  • swiftc -typecheck of AstryxNative, Astryx, and the tests passes. An immediate-mode render checks density padding, the 44 pt tappable rows, selected over highlighted in the default and two other themes, the dimmed disabled row, start and center alignment, a three-line label, and that a hidden row draws nothing. Tests also check that refused hrefs never make a link and that an Item wire node draws its content slots.
  • The Swift tests run in CI on macOS and on the iOS Simulator.
  • Screenshot states for the per-component screenshot target, once it lands: each density, both layouts, each alignment, selected, highlighted, disabled, static, link, button, light and dark, and the largest text size.

Every changed file is under packages/native/, plus the new record in docs/specs/components/Item/.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 1, 2026 1:11pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 1:51 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 1f7ab52

Storybook · Sandbox · built from this PR's head

Package size: measured; main has no measurement of the merge base to compare yet.

API changes: core +8 · ~20 · −0 (report stale: run pnpm api:report); richtext +0 · ~1 · −0 (report stale: run pnpm api:report)

29 API changes (advisory)
Package Where Signature Usage
+ core . · Types · interface FieldStatusProps message: string import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {message: …}
+ core . · Types · interface FieldStatusProps ref?: React.Ref<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {ref: …}
+ core . · Types · interface FieldStatusProps type: InputStatusType import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {type: …}
+ core . · Types · interface FieldStatusProps variant?: FieldStatusVariant = 'attached' import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {variant: …}
+ core . · Types · interface FieldStatusProps xstyle?: StyleXStyles import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {xstyle: …}
+ core . · Types · interface FieldStatusProps inherits AriaAttributes, DOMAttributes, HTMLAttributes import type {FieldStatusProps} from '@astryxdesign/core'
+ core . · Types · interface SearchContext interface SearchContext<T extends SearchableItem = SearchableItem> import type {SearchContext} from '@astryxdesign/core'
+ core ./Typeahead · Also exported from an earlier entry point · SearchContext SearchContext (.) import {SearchContext} from '@astryxdesign/core/Typeahead'
~ core . · Components · component ComplexSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <ComplexSelector statusVariant="attached" />
~ core . · Components · component DateInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateInput statusVariant="attached" />
~ core . · Components · component DateRangeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateRangeInput statusVariant="attached" />
~ core . · Components · component Field prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Field statusVariant="attached" />
~ core . · Components · component FieldStatus component FieldStatus(props: FieldStatusProps) → component FieldStatus @deprecated <FieldStatus />
~ core . · Components · component FieldStatus prop variant?: FieldStatusVariant = 'attached' → prop variant: FieldStatusVariant = 'attached' <FieldStatus variant="attached" />
~ core . · Components · component FileInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <FileInput statusVariant="attached" />
~ core . · Components · component MultiSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <MultiSelector statusVariant="attached" />
~ core . · Components · component NumberInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <NumberInput statusVariant="attached" />
~ core . · Components · component PowerSearch prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <PowerSearch statusVariant="attached" />
~ core . · Components · component Selector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary/ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary/ghost selectors <Selector statusVariant="attached" />
~ core . · Components · component TextArea prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextArea statusVariant="attached" />
~ core . · Components · component TextInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextInput statusVariant="attached" />
~ core . · Components · component TimeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TimeInput statusVariant="attached" />
~ core . · Components · component Tokenizer prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Tokenizer statusVariant="attached" />
~ core . · Components · component Typeahead prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Typeahead statusVariant="attached" />
~ core . · Types · interface FieldStatusProps interface FieldStatusProps extends BaseProps<HTMLDivElement> (props of FieldStatus) → interface FieldStatusProps extends BaseProps<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'
~ core . · Types · interface FieldStatusVariantMap tooltip: true → tooltip: true @deprecated import type {FieldStatusVariantMap} from '@astryxdesign/core'; const x: FieldStatusVariantMap = {tooltip: …}
~ core . · Types · interface SearchSource search: (query: string) => Promise<T[]> | T[] → search: (query: string, context?: SearchContext<T>) => Promise<T[]> | T[] import type {SearchSource} from '@astryxdesign/core'; const x: SearchSource = {search: …}
~ core . · Types · interface UseInputStatusIconOptions statusVariant?: FieldStatusVariant → statusVariant?: FieldStatusPlacement import type {UseInputStatusIconOptions} from '@astryxdesign/core'; const x: UseInputStatusIconOptions = {statusVariant: …}
~ richtext . · Components · component RichTextEditor prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusVariant | 'tooltip' = 'attached' <RichTextEditor statusVariant="tooltip" />

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of the Swift contract. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries the fixes below, and the author may do so as an engineering owner. Please also fix these on that head:

  • align: .firstLine leaves a slot taller than one label line top-aligned (Item.swift:181–182), so it draws like start instead of centering on the first line as core Item's FR2–FR4 require; your own capture shows it with the 24 pt avatar.
  • A static disabled row exposes no disabled state to VoiceOver (Item.swift:116–119), while core marks every disabled row aria-disabled and the record's AR2 says it reads as dimmed.
  • ItemTests.swift needs the trait and state assertions and the largest Dynamic Type check that AST-093 FR5 requires, plus a first-line case with a slot taller than one line.

[Automated review]

var resolvedAlign: ItemAlign { props.align ?? .center }

private var rowAlignment: VerticalAlignment {
resolvedAlign == .center ? .center : .top

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With firstLine this aligns the row .top, so a slot taller than the 20 pt first line keeps its top on the words' top and its center lands (height − 20) / 2 pt low; core Item's first-line (FR2–FR4) centers every slot on the first line and moves the text down. A custom VerticalAlignment guide that the words set at bodyLineHeight / 2 and each slot sets at its own center would match core.

Comment on lines +116 to +119
} else {
row(minHeight: nil)
.accessibilityElement(children: .combine)
.modifier(ItemAccessibility(isSelected: isSelected))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The static branch never applies .disabled(isDisabled), so a static disabled row is dimmed but exposes no disabled state to VoiceOver. Core sets aria-disabled on every disabled row, and the record's AR2 says a disabled row reads as dimmed.

if let url = linkURL {
SwiftUI.Link(destination: url) { row(minHeight: Item.minimumTarget) }
.buttonStyle(ItemPressStyle(radius: astryx.tokens.radius.element, pressed: astryx.tokens.color.overlayHover))
// `onClick` runs as the link opens, as core's does.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core does not do this: with both href and onClick, core's anchor navigates and its container click handler skips onClick for clicks inside the anchor (packages/core/src/Item/Item.tsx:735). Running onClick on open is new behavior under FR5 for the owner to approve, so this comment should not claim parity.

}

@MainActor
@Test func alignPlacesTheSlotsOnTheWords() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This covers start and center only. Please add a firstLine case with a slot taller than one line (for example 40 pt), with and without a description, plus the trait and state assertions and the .accessibility5 render that AST-093 FR5 requires.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of the native contracts. The earlier first-line and disabled-state issues are fixed on this head, and no other change is required. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the owner should know that FR5 runs onClick on every link open and FR4 dims the start content, which core's web Item does not do.

  • Optional: the comment at Item.swift:104 still says core runs onClick as the link opens, and the package README's "Not here yet" list still says no view uses the generated contract types.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries the fix below, and the author may do so as an engineering owner. The owner should know that FR5 never runs onClick beside a URL href and stops development builds on a web path such as /calendar, and that FR4 paints a pressed row with color.overlayHover where core uses color.overlayPressed. Please also fix this on that head:

  • layout: .inline without a description fixes the label at full width (Item.swift:188–190), so long labels never truncate where core's row does (packages/core/src/Item/Item.tsx:689); take the inline branch only when a description is present and add a render test for it.

[Automated review]

.modifier(
ItemLeading(extra: extraLeading(astryx.tokens.font.supportingLeading, size: astryx.tokens.font.supportingSize)))
}
if (props.layout ?? .stacked) == .inline {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This branch also runs when there is no description, so the label is fixed at its full width and a long label never truncates; it pushes the end content past the row's edge. Core applies inline only with a description (packages/core/src/Item/Item.tsx:689), and the record gives layout "with a description". Consider if (props.layout ?? .stacked) == .inline, props.description != nil { plus a render test with a long label and no description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract, and FR5 departs from core's web Item by never running onClick beside a URL href and by stopping development builds on a web path such as /calendar. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries the fix below, and the author may do so as an engineering owner. The earlier inline-layout and pressed-colour issues are fixed on this head; please also address:

  • In the Neutral theme a selected link or button row shows no press, because ItemPressStyle paints overlayPressed behind the row's opaque accentMuted fill (Item.swift:293–298), while core's touch press paints above the selected colour.
  • Optional: anInlineLabelWithoutADescriptionStillTruncates also passes on the pre-fix code; asserting that an end-content swatch stays inside the 160 pt frame would make it guard the fix.

[Automated review]

Comment on lines +294 to +298
configuration.label
.background(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(configuration.isPressed ? pressed : .clear)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This press fill sits behind configuration.label, and the label is the row, whose own fill is accentMuted when selected. In the Neutral theme that fill is opaque (0xF1F1F1FF light, 0x262626FF dark), so a selected link or button row shows no press; core paints its touch press above the selected colour. Drawing the state fill and the press fill together here, behind the label, would match core.

Comment on lines +183 to +184
#expect(inline.width == 160)
#expect(inline.height == stacked.height)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With .frame(width: 160) the width is 160 whatever the label does, and a one-line label is as tall when it overflows as when it truncates, so this also passes on the pre-fix code. Asserting that an end-content swatch is drawn inside the 160 pt frame would fail there.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract. The press now shows above a selected row's fill and the inline-layout test guards its fix, so no code change is required on this head. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the decision should weigh that FR5 never runs onClick beside an absolute-URL href and stops development builds on a web path such as /calendar, which core's web Item does not do.

[Automated review]

| FR2 | The label MUST draw in the body type and `color.textPrimary`, the description in the supporting type and `color.textSecondary`, each limited to `labelLines` or `descriptionLines` lines (one by default) and truncating after them. Each line takes the theme's leading, grown with Dynamic Type. With `inline` and a description, the description sits beside the label with a gap of `space.s1` and the label does not shrink; without a description the label keeps its line limit. | core `Item` styles | verify |
| FR3 | `center` MUST center each slot on the words; `start` MUST put each slot's top on the words' top; `firstLine` MUST center each slot on the label's first line. | core `Item` `align` | verify |
| FR4 | `isSelected` MUST paint `color.accentMuted` behind the row and `isHighlighted` `color.overlayHover`, with selected winning, in the element radius. `isDisabled` MUST draw the words and the end content at half opacity, as core does; the marker and start content keep their own look. A pressed button or link row paints `color.overlayPressed` above its selected or highlighted fill, as core does. | core `Item` styles | verify |
| FR5 | With an `href` that is an absolute URL (it has a scheme), the row MUST be a SwiftUI `Link`, and `onClick` does not run, as core's link row skips it. 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 row without a link. With only `onClick`, a SwiftUI `Button`. A tappable row is at least 44 points tall; a static row keeps core's height. A disabled link or button stays reachable, reads as dimmed, and does nothing. | Swift component API A4; Swift accessibility X2, X7 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core skips onClick only for a tap inside the label's link (packages/core/src/Item/Item.tsx:730–739); a tap on the start content, end content or padding still runs it. So "as core's link row skips it" holds only for the words, and approving FR5 decides that the Swift row drops onClick for the whole row.

| FR3 | `center` MUST center each slot on the words; `start` MUST put each slot's top on the words' top; `firstLine` MUST center each slot on the label's first line. | core `Item` `align` | verify |
| FR4 | `isSelected` MUST paint `color.accentMuted` behind the row and `isHighlighted` `color.overlayHover`, with selected winning, in the element radius. `isDisabled` MUST draw the words and the end content at half opacity, as core does; the marker and start content keep their own look. A pressed button or link row paints `color.overlayPressed` above its selected or highlighted fill, as core does. | core `Item` styles | verify |
| FR5 | With an `href` that is an absolute URL (it has a scheme), the row MUST be a SwiftUI `Link`, and `onClick` does not run, as core's link row skips it. 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 row without a link. With only `onClick`, a SwiftUI `Button`. A tappable row is at least 44 points tall; a static row keeps core's height. A disabled link or button stays reachable, reads as dimmed, and does nothing. | Swift component API A4; Swift accessibility X2, X7 | verify |
| FR6 | Both initializers MUST build the same `ItemProps`. Slots are generic `@ViewBuilder` closures with empty defaults, never `AnyView`; a single trailing closure is the start content and `onClick` comes last. Base props come through `ItemProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; Swift slots S3 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR6 cites component API A3 and slots S3 as its basis, but both draft notes give the props initializer the slot's generated node type, while Item.swift:79–89 takes @ViewBuilder closures. FR6 or those notes needs to change so the two agree.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract. No code change is required on this head: the new line-count clamp is tested and the earlier fixes hold. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the decision should weigh that FR5 never runs onClick beside an absolute-URL href and stops development builds on a web path such as /calendar, which core's web Item does not do.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract, and FR5 departs from core's web Item by never running onClick beside a URL href, refusing relative paths such as /calendar, and turning javascript: into a link. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries the fix below, and the author may do so as an engineering owner. The latest push leaves this fix open:

  • AstryxNodeView builds an Item wire node from its props alone (AstryxNodeView.swift:25), so its marker, startContent and endContent children are never drawn, against spec:AST-082 FR19; pass them through AstryxNodeList as the Avatar case does, and add a wire test whose node carries a content slot.

[Automated review]

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))
case .item(let item): Item(item.viewProps(send: send))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This passes only the node's props, so an Item node's marker, startContent and endContent children (Generated/Item.swift:52–55) are never drawn, against spec:AST-082 FR19 ("A content slot (FR4) carries child nodes"; "A host draws every node it can read"). Passing each slot through AstryxNodeList, as the Avatar case above does for its status slot, fixes it:

case .item(let item):
  Item(item.viewProps(send: send)) {
    AstryxNodeList(nodes: item.props.startContent ?? [], send: send)
  } endContent: {
    AstryxNodeList(nodes: item.props.endContent ?? [], send: send)
  } marker: {
    AstryxNodeList(nodes: item.props.marker ?? [], send: send)
  }

Please add a wire test whose node carries a content slot; anItemNodeDrawsAsAnItem sends a label only.

| FR2 | The label MUST draw in the body type and `color.textPrimary`, the description in the supporting type and `color.textSecondary`, each limited to `labelLines` or `descriptionLines` lines (one by default) and truncating after them. Each line takes the theme's leading, grown with Dynamic Type. With `inline` and a description, the description sits beside the label with a gap of `space.s1` and the label does not shrink; without a description the label keeps its line limit. | core `Item` styles | verify |
| FR3 | `center` MUST center each slot on the words; `start` MUST put each slot's top on the words' top; `firstLine` MUST center each slot on the label's first line. | core `Item` `align` | verify |
| FR4 | `isSelected` MUST paint `color.accentMuted` behind the row and `isHighlighted` `color.overlayHover`, with selected winning, in the element radius. `isDisabled` MUST draw the words and the end content at half opacity, as core does; the marker and start content keep their own look. A pressed button or link row paints `color.overlayPressed` above its selected or highlighted fill, as core does. | core `Item` styles | verify |
| FR5 | With an `href` that is an absolute URL (it has a scheme), the row MUST be a SwiftUI `Link`, and `onClick` does not run, as core's link row skips it. 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 row without a link. With only `onClick`, a SwiftUI `Button`. A tappable row is at least 44 points tall; a static row keeps core's height. A disabled link or button stays reachable, reads as dimmed, and does nothing. | Swift component API A4; Swift accessibility X2, X7 | verify |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core skips onClick only for a tap inside the label's link (packages/core/src/Item/Item.tsx:730–739, anchor at :766–779); a tap on the start content, end content or padding still runs it. So "as core's link row skips it" holds only for the words, and approving FR5 also decides that a relative href is refused and that any scheme, javascript: included, becomes a Link, where core's row follows the navigation-destinations rules.

var linkURL: URL? {
// Only an absolute URL the system can open; a malformed or relative
// `href` leaves the row static or a button.
guard let href = props.href, !href.isEmpty, let url = URL(string: href), url.scheme != nil else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any URL with a scheme passes this guard, so javascript:, vbscript: and data:text/html become a Link handed to the app's openURL, while core's row renders them inert (useLinkComponent.ts:33). FR5 should say whether the Swift row applies that rule; the owner's FR5 decision settles it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the new draft record component:swift/Item: no current spec yet states its requirements (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4) or OQ1's choice to keep swipeActions and swipeBehavior out of every native contract, and FR5 departs from core's web Item by never running onClick beside a URL href and by refusing relative paths such as /calendar. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries the fixes below, and the author may do so as an engineering owner. Please fix these on that head:

  • Item.swift:265 turns javascript:, vbscript: and data:text/html into a SwiftUI Link, but spec:AST-005 FR3 and FR6 require those destinations to render inert, as core's row does; refuse them after FR2's normalization and add a test per prefix.
  • AstryxNodeView.swift:26 builds an Item wire node from its props alone, so its marker, startContent and endContent children are never drawn, against spec:AST-082 FR19; pass them through AstryxNodeList as the Avatar case does and add a wire test with a content slot.

[Automated review]

var linkURL: URL? {
// Only an absolute URL the system can open; a malformed or relative
// `href` leaves the row static or a button.
guard let href = props.href, !href.isEmpty, let url = URL(string: href), url.scheme != nil else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any URL with a scheme passes this guard, so javascript:, vbscript: and data:text/html (in any letter case) become a SwiftUI Link handed to the app's openURL. spec:AST-005 FR1, FR3 and FR6 require Astryx to render those destinations inert, as core's useLinkComponent does, and a native spec cannot change the core contract (spec:AST-093 FR1), so approving FR5 does not settle it. Normalizing as AST-005 FR2 says and refusing the three prefixes here, with a test per prefix, fixes it; the row then draws as its Button when onClick is set.

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 0b070d5

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the draft component:swift/Item record: the earlier /approve-spec named the previous head, so an engineering owner (the author may, as one) records it again by commenting /approve-spec <full head SHA> on the head that carries the fixes below. Independently of that decision, this head needs changes:

  • Keep javascript:, vbscript: and data:text/html hrefs inert (Item.swift:265), as Swift Button.safeDestination already does, ideally through one shared helper rather than a copy of Avatar's check.
  • Draw an Item wire node's marker, startContent and endContent (AstryxNodeView.swift:28) and add a wire test with a content slot.
  • Say in FR5 and the description that core runs onClick for a tap outside the label, point the description at the new record path and DEC-1, and set the record's schema_version and template_version to 3 and 7.

[Automated review]

var linkURL: URL? {
// Only an absolute URL the system can open; a malformed or relative
// `href` leaves the row static or a button.
guard let href = props.href, !href.isEmpty, let url = URL(string: href), url.scheme != nil else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any URL with a scheme passes this guard, so javascript:, vbscript: and data:text/html become a SwiftUI Link handed to the app's openURL (spec:AST-005 FR6). Button.safeDestination (Button.swift:91–105) already applies core's normalization; routing href through it, or one shared helper, keeps those destinations inert.

Avatar(avatar.viewProps(send: send)) { AstryxNodeList(nodes: avatar.props.status ?? [], send: send) }
case .avatarStatusDot(let dot): AvatarStatusDot(dot.viewProps(send: send))
case .thumbnail(let thumbnail): Thumbnail(thumbnail.viewProps(send: send))
case .item(let item): Item(item.viewProps(send: send))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the props reach the view, so an Item node's marker, startContent and endContent children are decoded and never drawn (spec:AST-082 FR19). Passing each slot through AstryxNodeList, as the Avatar case above does, fixes it.

@@ -0,0 +1,192 @@
---
schema_version: 5

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No v5 knowledge schema exists and the component template is schema_version: 3, template_version: 7, as every sibling Swift record uses.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the draft component:swift/Item record (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4 and DEC-1, including FR5's whole-row link that never runs onClick and its refusal of relative hrefs); the earlier /approve-spec named a previous head, so an engineering owner, the author included, records it by commenting /approve-spec <full head SHA> on the head that carries the fix below. Blocked href schemes now stay inert and Item wire nodes draw their slots, as asked last time.

  • Item.swift:101–114 copies Avatar's isUnusableHref and development check; please move that check beside astryxSafeDestination in Base/SafeDestination.swift, call it from both Avatar and Item, and give Item's call the same normalized href its link uses (today https://example.com opens as a link while a development build stops on it).

[Automated review]

Comment on lines +101 to +114
private func checkInDevelopment() {
#if DEBUG
if Item.isUnusableHref(props.href) {
assertionFailure("Item href \"\(props.href ?? "")\" is not an absolute URL an app can open; it draws without a link.")
}
#endif
}

/// A non-empty `href` that is not an absolute URL.
nonisolated static func isUnusableHref(_ href: String?) -> Bool {
guard let href, !href.isEmpty else { return false }
guard let url = URL(string: href) else { return true }
return url.scheme == nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This check and isUnusableHref repeat Avatar.isUnusableHref and Avatar's development check line for line (Avatar.swift:78–83, :111–115). They also read the raw href, while linkURL (:267) reads the trimmed, control-stripped value, so https://example.com draws a working link while a development build stops on it. Could this move beside astryxSafeDestination in Base/SafeDestination.swift as one function both views call, with Item passing it props.href.flatMap(astryxSafeDestination)?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the draft component:swift/Item record (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4 and DEC-1, including FR5's whole-row link that never runs onClick and the refused relative href), which no current spec yet states. Separately, the Swift tests no longer compile: aWebPathIsAnItemHrefTheAppCannotOpen in ItemTests.swift builds an Item without @MainActor, so test-native-swift fails before running any test. Once that is fixed, an engineering owner records the decision by commenting /approve-spec <full head SHA> on the fixed head, and the author may do so if they are an engineering owner.

[Automated review]

// The check reads the href as the link does: a padded absolute URL opens
// as a link and is not flagged.
#expect(!Item<EmptyView, EmptyView, EmptyView>.isUnusableHref(" https://example.com "))
#expect(Item("Ada", href: " https://example.com ").linkURL == URL(string: "https://example.com"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test is not main-actor isolated, and Item(...) and linkURL are, so under Swift 6 this line does not compile and test-native-swift stops before running any Swift test. Adding @MainActor to aWebPathIsAnItemHrefTheAppCannotOpen (line 172), as the other view-building Item tests have, or moving this line into hrefMakesALinkAndStatesReadFromTheContract, fixes it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the draft component:swift/Item record (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4 and DEC-1), including FR5's two departures from core's link row: a row with an absolute-URL href never runs onClick, and a relative href is refused. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Before that, please map Item's id and isHidden through the shared astryxBase in Base/BaseProps.swift instead of the private ItemIdentifier copy, so a later fix to base-prop mapping also reaches Item rows. The Swift test compile error is fixed, and all Item tests now pass on macOS and the iOS Simulator.

[Automated review]

public var body: some View {
if props.base.isHidden != true {
control
.modifier(ItemIdentifier(id: props.base.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This and ItemIdentifier (lines 366–377) repeat the base-prop mapping that astryxBase(_:) in Base/BaseProps.swift:7 already owns, which Button, Card, Heading and Text call. Could body draw control.onAppear(perform: checkInDevelopment).astryxBase(props.base) and drop ItemIdentifier, so a later fix to how id and isHidden map reaches Item rows too?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on the draft component:swift/Item record (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4 and DEC-1), including FR5's two departures from core's link row: a row with an absolute-URL href never runs onClick, and a relative href is refused. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Before that, please limit label and description lines through the shared astryxLineLimit in Text/TypeStyle.swift instead of Item's own lines copy, so labelLines: 0 shows every line as core's row does and a later fix to line counts reaches Item too. Base props now go through the shared astryxBase, which resolves the earlier finding.

[Automated review]

Comment on lines +288 to +291
nonisolated static func lines(_ value: Double?) -> Int {
guard let value, value >= 1 else { return 1 }
return Int(min(value, Double(maximumLines)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This repeats the line-count conversion astryxLineLimit already owns (Text/TypeStyle.swift:140, called by Text and Heading), and it has drifted: lines(0) is one line, while core's Item clamps only when labelLines > 1 and draws every line for 0 (packages/core/src/Item/Item.tsx:739–741). Could lines 179 and 185 call .lineLimit(astryxLineLimit(props.labelLines ?? 1)) and .lineLimit(astryxLineLimit(props.descriptionLines ?? 1)), with lines and maximumLines deleted and lineCountsComeFromTheContract asserting the 0 case against core?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No code changes are needed on this head: line counts now go through the shared astryxLineLimit, so labelLines: 0 shows every line as core's Item does. Approval waits on an engineering owner's decision on the draft component:swift/Item record (FR1–FR6, AV1–AV2, ORD1–ORD2, PR1, AR1–AR4 and DEC-1), including FR5's two departures from core's link row: a row with an absolute-URL href never runs onClick, and a relative href such as /calendar is refused. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as one), or names the requirement that should change.

[Automated review]

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 1f7ab52

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is ready to approve: an engineering owner approved the component:swift/Item record on this exact head, and the Swift Item view, its generated binding and its wire case match that record and the current native specs. CI is green, including the Swift tests on macOS and the iOS Simulator, and no changes are needed.

[Automated review]

@cixzhang
cixzhang merged commit d545bc5 into main Nov 1, 2026
57 checks passed

This branch was successfully deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant