- A pull request that changes anything a user can see needs before and after screenshots on iOS and Android, each in light and dark mode. Anything that moves (animations, transitions, sheets, scroll behaviour) needs recordings as well.
- Activate the
ui-screenshotsskill as soon as you start a change like that. The before shots are taken before you edit anything, so don't leave it until the PR. - Never commit screenshots or recordings to the pull request branch.
php artisan screenshots:publishputs them on an orphan branch and prints the table for the description. - Open pull requests with the sections in
.github/pull_request_template.md.gh pr create --bodyskips the template, so copy its headings yourself. - If nothing visible changed, tick "No visible change" in the template instead of leaving the section empty. A CI check fails ready-for-review PRs that touch screens without either.
=== foundation rules ===
This application is a Laravel application running on PHP 8.4. Always use the APIs that match the installed major version of each package — do not assume a version.
Before relying on a package's API, confirm its installed version:
- PHP packages: run
composer show --directto list direct dependencies with versions, orcomposer show <vendor/package>for a single package. - JS packages: check
package.jsonfor the installed versions.
This project has domain-specific skills available in **/skills/**. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example,
isRegisteredForDiscounts, notdiscount(). - Check for existing components to reuse before writing a new one.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
- If a frontend change doesn't show in the UI or you get a "Unable to locate file in Vite manifest" error, run
npm run buildor ask the user to runnpm run devorcomposer run dev.
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use
database-queryto run read-only queries against the database instead of writing raw SQL in tinker. - Use
database-schemato inspect table structure before writing migrations or models. - Use
get-absolute-urlto resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. - Use
browser-logsto read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
- Use
search-docsbefore changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. - Pass a
packagesarray to scope results when you know which packages are relevant. - Use multiple broad, topic-based queries:
['rate limiting', 'routing rate limiting', 'routing']. Expect the most relevant results first. - Do not add package names to queries because package info is already shared. Use
test resource table, notfilament 4 test resource table.
- Use words for auto-stemmed AND logic:
rate limitmatches both "rate" AND "limit". - Use
"quoted phrases"for exact position matching:"infinite scroll"requires adjacent words in order. - Combine words and phrases for mixed queries:
middleware "rate limit". - Use multiple queries for OR logic:
queries=["authentication", "middleware"].
- This project contains committed, area-grouped rules in
.ai/ruleswhen that directory exists, including path-scoped framework guidelines under.ai/rules/boost. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and rungrep -rin 'keyword' .ai/rulesto catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If.ai/rulesdoes not exist, continue without it.
- Run Artisan commands directly via the command line (e.g.,
php artisan route:list). Usephp artisan listto discover available commands andphp artisan [command] --helpto check parameters. - Inspect routes with
php artisan route:list. Filter with:--method=GET,--name=users,--path=api,--except-vendor,--only-vendor. - Read configuration values using dot notation:
php artisan config:show app.name,php artisan config:show database.default. Or read config files directly from theconfig/directory.
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion:
php artisan tinker --execute 'Your::code();'- Double quotes for PHP strings inside:
php artisan tinker --execute 'User::where("active", true)->count();'
- Double quotes for PHP strings inside:
=== php rules ===
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion:
public function __construct(public GitHub $github) { }. Do not leave empty zero-parameter__construct()methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters:
function isAccessible(User $user, ?string $path = null): bool - Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
- Laravel can be deployed using Laravel Cloud, which is the fastest way to deploy and scale production Laravel applications.
=== tests rules ===
- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage.
- Pure copy, styling, and layout-only changes do not require new or updated tests.
- When test coverage applies, run the affected tests and ensure they pass.
- Test the changed behavior and its important failure modes, but do not add tests beyond them.
- Read the
testing-best-practicesskill before writing tests.
=== laravel/core rules ===
- Use
php artisan make:commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands usingphp artisan listand check their parameters withphp artisan [command] --help. - If you're creating a generic PHP class, use
php artisan make:class. - Pass
--no-interactionto all Artisan commands to ensure they work without user input. You should also pass the correct--optionsto ensure correct behavior.
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using
php artisan make:model --helpto check the available options.
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
- When generating links to other pages, prefer named routes and the
route()function.
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as
$this->faker->word()orfake()->randomDigit(). Follow existing conventions whether to use$this->fakerorfake(). - When creating tests, make use of
php artisan make:test [options] {name}to create a feature test, and pass--unitto create a unit test. Most tests should be feature tests.
=== pint/core rules ===
- If you have modified any PHP files, you must run
vendor/bin/pint --dirty --format agentbefore finalizing changes to ensure your code matches the project's expected style. - Do not run
vendor/bin/pint --test --format agent, simply runvendor/bin/pint --format agentto fix any formatting issues.
=== pest/core rules ===
- This project uses Pest. Create tests with
php artisan make:test --pest {name}. - Do not include the test suite directory in
{name}. UseSomeFeatureTest, notFeature/SomeFeatureTest. - Read the
testing-best-practicesskill for guidance on coverage, naming, structure, dependency isolation, and review. - Do not delete tests or test files without approval. They are part of the application.
- Run the narrowest set of tests that covers the change. Pass a file path or
--filter=testNametophp artisan test --compact. - Rerun a test after each change to it.
- Run
vendor/bin/pestto call the test runner directly. It accepts the same file path and--filter=testNamearguments. - After the feature tests pass, ask the user to run the complete suite with
php artisan test --compact.
=== nativephp/mobile/core rules ===
- NativePHP Mobile is a Laravel package for building fully native iOS and Android apps with PHP. Screens are rendered as real SwiftUI (iOS) and Jetpack Compose (Android) UI — driven entirely by PHP via SuperNative components and EDGE Blade elements. A full PHP runtime runs directly on the device with SQLite — no web server required.
- Documentation:
https://nativephp.com/docs/mobile/4/** - IMPORTANT: Always activate the
nativephp-mobileskill every time you work on any NativePHP functionality. - New app:
laravel new my-app --using=nativephp/mobile-starter --no-node. A screen is aNativeComponentclass, a Blade view inresources/views/native/(php artisan native:make Namecreates both) and aRoute::native()line inroutes/mobile.php. - Check behaviour with Pest and
Native\Mobile\Testing\Native::test(). It needs no device and takes seconds, where a device build takes minutes. The skill has a worked screen, its test, and where builds put the .app and .apk.
Always build screens with native UI: NativeComponent classes registered via Route::native(), rendering EDGE
elements (native:column, native:text, native:button, …). This is the way to build NativePHP apps.
- Never scaffold new screens as web views, Blade-over-WebView pages, Livewire components, or Inertia pages.
- The web view (the
native:web-viewelement) is a legacy/edge-case escape hatch for embedding web content — never the foundation of a screen. If the user asks for a webview-based screen, build it natively with EDGE instead and explain why; only fall back to the web view if they explicitly insist. - If the app contains legacy webview screens, proactively suggest converting them to native UI (see the
nativephp-webview-to-nativeskill). - EDGE elements are rendered by the
nativephp/mobile-uiplugin, evencolumnandtext. It must be installed and registered (php artisan native:plugin:register nativephp/mobile-ui) or elements disappear from the screen. Text fields areoutlined-text-input,filled-text-inputandbare-text-input; there is notext-input. - Style EDGE elements with Tailwind utility classes via
class="..."/:class="..."only — never inline CSSstyle="..."attributes or ad-hoc styling props. - Compose screens from nested child components: any
NativeComponentunderapp/NativeComponentsmounts as a tag (UserCard→<native:user-card :user="$u" key="user-{{ $u->id }}" @saved="onSaved" />) with live props, its own persistent state, andemit()events bubbling to@eventtag bindings /#[On('event')]listeners. Prefer extracting a reusable child component over duplicating Blade across screens; give list children a stable domainkey(never the loop index). - Use
native:icon(SF Symbols on iOS, Material Icons on Android) for iconography — never emoji characters in UI text, labels, or buttons, unless the user explicitly asks for emojis. Prefer the typed icon enums (App\Icons\Ios,App\Icons\Android,App\Icons\AndroidOutlined) bound via the:ios/:androidattributes, e.g.:ios="Ios::Gearshape" :android="Android::Settings", importing each enum into the view with Blade's use directive first. The enums are generated, not shipped — ifapp/Icons/doesn't exist yet, runphp artisan native-ui:generate-iconsfirst (safe to run yourself).
Every app's visual identity belongs in config/native-ui.php (publish with
php artisan vendor:publish --tag=native-ui-config), not scattered through the markup. When building or
reviewing screens, enforce all three:
- Theme tokens over hardcoded colors. Define the palette once in the config's
themeblock, then style withbg-theme-*/text-theme-*/border-theme-*classes (bg-theme-surface,text-theme-on-surface,border-theme-outline). Never sprinklebg-[#1E2021]-style arbitrary values for what is really a theme role — they can't be re-skinned and don't get automatic dark-mode pairs. Arbitrary color values are for genuine data-driven color (per-category identity colors, map imagery, chart series), and those belong in one PHP home (an enum or model method), never inline per view. Two capabilities that prevent hex fallbacks:- The token map is open-ended. When a design needs a role the shipped set lacks (a success green, an
outline-variant), add it to bothlightanddarkblocks —bg-theme-successworks immediately; no package change required. - Theme classes take opacity modifiers just like palette classes:
bg-theme-primary/15is the correct tonal-fill idiom (applies to the dark companion too) — never approximate with a hardcoded alpha hex.
- The token map is open-ended. When a design needs a role the shipped set lacks (a success green, an
- Font aliases over file tokens. Register semantic aliases in the config's
fontsarray ('headline' => 'ArchivoNarrow-Bold','mono' => 'JetBrainsMono-Regular','default' => …for the app-wide font) and writefont="headline"in views — neverfont="ArchivoNarrow-Bold". Swapping a typeface must be a one-line config change. - Native chrome via composable chrome elements (layouts optional). Author nav bars, tab bars, fabs, and
side navs directly in the screen's Blade —
<native:top-bar>(+top-bar-action),<native:bottom-nav>(+bottom-nav-item),<native:fab>,<native:bottom-bar>,<native:side-nav>. They hoist onto the real NavigationStack/TabView chrome (edge-swipe back, predictive back, large titles, Liquid Glass/Material You), and their attributes are Blade expressions over screen state, so badges/subtitles/icons are reactive. ANativeLayout(attached viaRoute::native(...)->layout(...)orRoute::nativeGroup(...)) is optional — reach for one only when many screens share identical chrome (e.g. one tabs layout for a tab section); an inline chrome element on a screen always overrides the layout's bar for that slot. Add thecustomattribute to a chrome tag only for designs the system bars genuinely can't express — it renders in-tree as an ordinary drawn element. Never hand-roll top bars or bottom navs out of rows and pressables — that forfeits native back gestures, safe-area handling, and Liquid Glass/Material You. Chrome colors take theme tokens (inline: theme classes /theme()-fed attributes; builders:->activeColor(theme('primary'))) — never pasted hex. Bar icons take the platform enums via:ios-icon/:android-iconwith a plainiconstring as cross-platform fallback; bar fonts take config aliases (font="mono"/->font('mono')). Only screens rendered without any chrome (no layout AND no inline bars) may usesafe-areaclasses.
If the app needs native functionality or a UI component that core and native-ui don't provide:
- Look for an existing plugin first. Check the plugin marketplace (
https://plugins.nativephp.com) and the official core plugins. (If a marketplace-lookup MCP tool is available in your session, use it.) - If no plugin exists, build a custom plugin with
php artisan native:plugin:create— plugins bundle Swift/Kotlin bridge functions, events, permissions, and can even ship their own native EDGE components. - Never fall back to the web view to fill a native gap. A missing capability is a reason to write a plugin, not a reason to build a webview screen.
Requiring a plugin with Composer is NOT enough — an installed-but-unregistered plugin does nothing. Every plugin install must follow all three steps:
composer require vendor/plugin-namephp artisan vendor:publish --tag=nativephp-plugins-provider— publishes the app'sNativeServiceProvider(needed once, before the first plugin registration; harmless to re-run)php artisan native:plugin:register vendor/plugin-name— adds it to theNativeServiceProviderphp artisan native:plugin:list— verify it shows as registered
Then rebuild with native:run (see Build and Run below; native code only compiles in at build time). If
native:run warns "The following plugins are installed but not registered", go back to
step 3.
On-device there is no db:seed — NativePHP runs migrations on app start (once each, tracked, versioned).
Whenever asked to seed the database, use the migration trick: create a dedicated migration
(php artisan make:migration seed_app_settings) and put the inserts in up(). If a Seeder class helps organize
the data, still create it — but invoke it from the migration's up() (e.g. (new CategorySeeder)->run()),
never rely on db:seed being run. Seed migrations must be safe for both fresh installs and updates of existing
user databases.
Check behaviour with Native::test() first. When a change needs a build to take effect or to be checked, run it
yourself from the Laravel root against a booted simulator or running emulator:
xcrun simctl list devices booted # iOS simulator UDIDs
adb devices # Android serials
php artisan native:run ios <UDID> --no-tty -n
php artisan native:run android <SERIAL> --no-tty -nUse the platform the user named; otherwise use whichever device is running. If no simulator or emulator is available, say so and give the user the command instead. It returns once the app is installed and launched.
Ask first, or leave to the user:
--watchandnative:watchnever exit, so don't block on them. Start one in the background only when the user wants hot reload.- Release builds (
--build=release|bundle), signing,native:packageand store uploads.
Other commands: native:open (Xcode or Android Studio), native:install (install or upgrade the native shell).
The ./native shortcut wraps the native: namespace. The Vite dev server is opt-in in v4: add --vite only
when the app uses JS/CSS HMR; native UI screens hot-reload without it. npm run build -- --mode=ios|android is only
needed for apps with web-view assets.
=== nativephp/mobile-browser/core rules ===
Open URLs in system browser, in-app browser, and OAuth authentication sessions.
Use the Browser facade:
// Open in in-app browser (keeps users in your app) Browser::inApp('https://nativephp.com/mobile');
// Open in system browser (leaves your app) Browser::open('https://nativephp.com/mobile');
// OAuth authentication with automatic redirect handling Browser::auth('https://provider.com/oauth/authorize?client_id=123&redirect_uri=nativephp://127.0.0.1/auth/callback');
import { browser } from '#nativephp';// Open in in-app browser await browser.inApp('https://nativephp.com/mobile');
// Open in system browser await browser.open('https://nativephp.com/mobile');
// OAuth authentication await browser.auth('https://provider.com/oauth/authorize?client_id=123&redirect_uri=nativephp://127.0.0.1/auth/callback');
Browser::inApp(string $url)- Opens in embedded browser (SFSafariViewController/Chrome Custom Tabs)Browser::open(string $url)- Opens in device's default browserBrowser::auth(string $url)- Opens OAuth authentication browser with automatic redirect handling
inApp(): Keep users within your app for documentation, help pages, or related contentopen(): Use when full browser features are required for complex web applicationsauth(): Implement OAuth authentication flows with secure, automatic redirect handling
=== nativephp/mobile-ui/core rules ===
Native UI components for NativePHP Mobile. Every element renders as a real
platform primitive — Material3 on Android, SwiftUI on iOS — not a webview
widget. Elements are declared in Blade with <native:*> tags or built
programmatically with the fluent Native\Mobile\UI\Elements\* API; both
paths serialize to the same wire tree.
- Activate the
nativephp-mobile-uiskill when writing a screen's view. It lists each element's props and events, list-item swipe actions, and the text input traps. - Text fields are
outlined-text-input,filled-text-inputandbare-text-input. There is notext-input. Bind them withnative:model.debounce.300msand also read the text@submitpasses as the handler's last argument; live binding can drop characters under fast typing. <native:list-item>props are camelCase as written (leadingCheckbox,trailingIconButton). Swipe actions only work on list items that are direct children of<native:list>.- Visual styling is theme-driven ("Model 3"): buttons, inputs, toggles, and
other controls take their colors, radii, and typography from the theme
(
Native\Mobile\UI\Theme). Use semantic props likevariant="primary"instead of per-instance colors — per-instance visual overrides on these controls are intentionally ignored. - Bind state with
native:model="property"(works on toggle, checkbox, chip, slider, select, radio-group, button-group, tab-row, and the text inputs). Use.live/.blur/.debounce.Xmsmodifiers to control sync frequency. - Wire callbacks with event attributes (
@tap,@change,@submit,@dismiss) pointing at public methods on the component. - Image sources —
<native:image src>, andleadingAvatar/leadingImageon<native:list-item>— all resolve the same way: a remote URL (https://…), a device file path (/var/mobile/…/photo.jpg, what the camera and gallery hand you), or a RELATIVE path, which resolves against the app'spublic/directory on device —src="img/logo.png"renderspublic/img/logo.png. Unlike the web, a leading slash means a device filesystem path, NOT the public root: writeimg/logo.png, never/img/logo.png. - Text inputs also take
@selectionChangefor caret / selection reporting: the handler is called asmethod(string $text, int $selectionStart, int $selectionEnd)with offsets in Unicode code points (start === endfor a plain caret). Events are coalesced natively (150ms default; tune withselection-debounce-ms). Never fired onsecureinputs. Use it for typeahead / mention triggers where@changecan't tell you where the user is typing. - Each
@selectionChangeevent carries the FULL current text and costs a component re-render, independent of thenative:modelsync mode — don't reach for it when plain@changewould do. - Add
revealablealongsidesecurefor the in-field eye that lets the user check what they typed. It is native-only — tapping it never round-trips to PHP and never touches the bound value — so don't hand-roll a Show / Hide button bound to a component property. Honored onoutlined-text-inputandfilled-text-input;bare-text-inputdraws no chrome, so supply your own control there. <native:date-picker>handles dates, times, and both. Setmodetodate(default),time, ordatetime. Values are always wall-clock ISO strings —2026-07-25,14:30,2026-07-25T14:30— never offsets or epoch numbers, so they feed straight intoCarbon::parse().value,min, andmaxalso accept anyDateTimeInterface.- On the picker,
timezone(IANA) names the calendar the user picks in and never rewrites the value;locale(BCP-47) is display-only. Usepicker-style=compact(default) /inline/wheel(NOTdisplay, which is flex/layout display).title,confirm-label, andcancel-labelare Android-only dialog chrome — pass translated strings.min/maxare NOT supported formode="time"(they throw), and sync-mode modifiers (native:model.blur) throw too — a picker always commits on selection. - In tests, drive pickers with the
pickDate()/pickTime()/pickDateTime()/clearPicker()macros and assert withassertPicker()/assertPickerValue()/assertPickerEmpty().
- Everywhere a color is authored — theme tokens in
config/native-ui.php, element color props (->color(),headline-color, badgecolor, swipetint), and arbitrary-value classes (bg-[#…]) — the same grammar applies:- Tailwind palette names:
red-300,orange-800 - Special names:
white,black,transparent - CSS hex:
#F00,#B91C1C, and with alpha#8B5CF680(#RRGGBBAA order) - Opacity modifiers on any of the above:
red-300/20,#8B5CF6/50
- Tailwind palette names:
- Alpha-bearing hex is always authored in CSS
#RRGGBBAAorder; PHP converts to the native wire order — never hand-author Android-style#AARRGGBB. - Dark mode: theme tokens carry a
darkblock (auto-derived when omitted), andbg-theme-*/text-theme-*/border-theme-*classes emit both modes automatically. This works for Blade-declared AND programmatically built elements (Element->class()). - The theme token map is open-ended: add any semantic role your design
needs (
success,warning,outline-variant, …) to bothlightanddarkblocks and the matching*-theme-*classes resolve immediately — never fall back to hardcoded hex for a missing role. success/on-successandoutline-variantare first-class tokens: the native theme stores parse them (with fallbacks), andvariant="success"works on<native:button>and<native:badge>for confirm / "safe to proceed" actions — prefer it over green hex or misusingprimary.- Theme classes accept opacity modifiers like every other color class:
bg-theme-primary/15is the tonal-fill idiom (alpha applies to the dark companion too). - In PHP — layout chrome builders (
->activeColor(),->backgroundColor()), dynamic styling — read tokens with the appearance-awaretheme()helper (theme('primary')) instead of rawconfig()paths or pasted hex. - Disabled controls use the
surface-variant(fill) +on-surface-variant(label) tokens on both platforms — tune disabled contrast by adjusting those two tokens, not per-component. outlined-text-inputhas NO fill by default — its box is transparent, so on a colored screen it stops reading as a field. Declare the optionalinput-fill/on-inputtoken pair to give it one;on-inputrecolors everything drawn inside the box. Do not reach forsurface/surface-variantfor this, or every card moves with the field.- Buttons render their variant token solid; for a softer tonal fill set
opacity on the token itself (e.g.
'secondary' => 'fuchsia-500/70'). <native:icon>accepts platform enum overrides as attributes —:ios="Ios::House"/:android="Android::Home"— matching the programmaticIcon::make(ios: …, android: …).
- Custom fonts. Drop
.ttf/.otf/.ttcfiles into the app'sresources/fonts/and reference one by its filename (minus extension) with thefontattribute:font="Inter-Bold"forresources/fonts/Inter-Bold.ttf. Works on<native:text>,<native:button>, and the text inputs; also fluent as->font('Inter-Bold'). The build'scopy_assetshook bundles the files (iOS registers them by PostScript name, Android loads fromassets/fonts/); an unresolved name falls back to the system font. Font size/weight still come fromtext-*/font-*classes and the theme. - Downloading fonts.
php artisan native:font Lobster(ornative:font "Rock Salt") downloads one Google Fonts family intoresources/fonts/with ready-to-use token names — no API key. Interactively pick styles; non-interactively only Regular is fetched when available. Useful flags:--default(set as app-wide default),--force(overwrite files / skip reset confirm),--clear-cache,--reset(wipe bundled fonts when present and restorefonts.defaulttoSystem). - Font aliases + app-wide default. Name fonts semantically in
config/native-ui.php:'fonts' => ['default' => 'Inter-Regular', 'accent' => 'DynaPuff-Regular']. Use an alias anywhere a font token works (font="accent", chrome->font(), layout$font); thedefaultalias applies app-wide (superseding the olderfont-familytoken). Per-elementfontattributes andfont-serif/font-monoclasses still win over the default.native:font Inter --defaultsets it for you. - Line height (leading).
leading-none|tight|snug|normal|relaxed|loose(unitless multipliers of the font size), plus arbitraryleading-[1.4](multiplier) andleading-[24px](absolute). Applies to<native:text>and the text inputs; button labels are single-line so it has no visible effect there. Only affects multi-line text. iOS caveat: SwiftUI'sTextonly exposes additive line spacing, so increasing leading (relaxed/loose, or a largeleading-[…px]) is exact, but tightening below the font's natural line height (none/tight) is limited — measured against the actual font, so custom fonts aren't over-spaced. Android is exact both ways.
Screen-reader support rides on two props that every element accepts:
a11y-label (what VoiceOver / TalkBack announces; maps to
accessibilityLabel on iOS and contentDescription on Android) and
a11y-hint (supplementary usage guidance, read after the label; maps to
accessibilityHint on iOS and is appended to the content description on
Android). Both are also available fluently as ->a11yLabel() / ->a11yHint().
- ALWAYS set
a11y-labelon icon-only buttons, chips, and tabs — with no visible text there is nothing for the screen reader to announce. - Icons are decorative by default: an
<native:icon>withouta11y-labelis silent to screen readers. Give it a label only when the icon itself carries meaning. - Use
alton<native:image>for meaningful images; omit it for purely decorative ones. - Use
a11y-hintsparingly, for supplementary guidance the label doesn't cover ("Double-tap to reorder"). Never repeat the label in the hint. - List items with a trailing icon button take
trailing-a11y-labelto label that button separately from the row, andon-trailing-pressfor its handler. - Text scales with the user's system font size on both platforms automatically — don't hardcode layouts that break at larger type sizes.
Button::make() ->icon('plus') ->a11yLabel('Add item') ->a11yHint('Adds a new item to the list') ->onPress('addItem');