Transient Feedback
use FirstlightUI\Facades\Feedback;
$feedbackId = Feedback::success('Appointment saved')
->action('Undo', 'undo-save')
->send();
This publishes one app-level native message and returns its stable string ID. There is no Blade tag and no host installation step: installing Firstlight registers the package-owned feedback host automatically.
Transient Feedback is for brief outcomes that may cross screen navigation. Use Callout for persistent content in the authored layout, Alert Dialog when the user must acknowledge one message, and Confirmation Dialog when work must pause for a confirm/cancel decision.
Factories and builder
The public identity is FirstlightUI\Facades\Feedback.
| API | Return | Contract |
|---|---|---|
Feedback::message(string $message) |
PendingFeedback |
Creates default-tone feedback. |
Feedback::success(string $message) |
PendingFeedback |
Creates success feedback. |
Feedback::warning(string $message) |
PendingFeedback |
Creates warning feedback. |
Feedback::danger(string $message) |
PendingFeedback |
Creates danger feedback. |
->id(string $id) |
new PendingFeedback |
Uses an application-owned stable ID. |
->action(string $label, string $key) |
new PendingFeedback |
Adds one visible labelled action and its application key. |
->hold() |
new PendingFeedback |
Keeps the item until action, manual dismissal, or programmatic removal. |
->send() |
string |
Publishes the item and returns its authored or generated ID. |
Feedback::dismiss(string $id) |
bool |
Removes a pending or visible item; returns whether it existed. |
The builder is immutable. Calling a modifier returns a new builder and does not change the instance it was called on. When id() is omitted, send() generates a UUID string.
Testing
Use Feedback::fake() before running application code to capture sends without publishing to the native host:
use FirstlightUI\Facades\Feedback;
use FirstlightUI\Feedback\FeedbackRecord;
use FirstlightUI\Feedback\FeedbackTone;
Feedback::fake();
Feedback::success('Appointment saved')->action('Undo', 'undo-save')->send();
Feedback::assertSent(fn (FeedbackRecord $record) =>
$record->tone === FeedbackTone::Success && $record->actionKey === 'undo-save'
);
Feedback::assertNotSent(fn (FeedbackRecord $record) => $record->tone === FeedbackTone::Danger);
Feedback::assertNothingSent() asserts an empty history. Feedback::sent() returns a collection of readonly FeedbackRecord values in send order; sent($callback) filters it. Records expose id, message, tone, hold, actionLabel, and actionKey. Assertions use PHPUnit and fail when their expectation is unmet.
Only send() records feedback, not builder creation. Same-ID updates are separate history entries; dismiss() affects the fake queue but never erases send history. Each fake() call starts fresh. The fake retains the real immutable builder and validation rules and uses an isolated store; it does not simulate native presentation, timers, or user events.
Tones
The four factories are the complete tone API:
message()usesdefaultfor neutral application information.success()communicates a completed outcome.warning()communicates a recoverable risk or interruption.danger()communicates a failed or destructive outcome.
Tone controls native colour and a decorative semantic symbol. It does not change queue, timing, or event behaviour.
Application events
Register normal Laravel listeners in a service provider when the application needs to react to user outcomes:
use FirstlightUI\Events\FeedbackActionPressed;
use FirstlightUI\Events\FeedbackDismissed;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(function (FeedbackActionPressed $event): void {
// $event->id and $event->actionKey
});
Event::listen(function (FeedbackDismissed $event): void {
// $event->id and $event->reason
});
}
FeedbackActionPressed has public readonly string $id and string $actionKey properties. FeedbackDismissed has public readonly string $id and FeedbackDismissReason $reason. The dismissal reasons are Timeout, Manual, and Action, with wire values timeout, manual, and action.
Pressing an action removes the item, dispatches FeedbackActionPressed, then dispatches FeedbackDismissed with FeedbackDismissReason::Action. The dismissal event is still dispatched if an action listener throws, and the listener exception is not swallowed. An automatic timeout dispatches only FeedbackDismissed with FeedbackDismissReason::Timeout. A manual native dismissal dispatches only FeedbackDismissed with FeedbackDismissReason::Manual.
Feedback::dismiss($id) is programmatic reconciliation. It dispatches neither event, including when it removes the visible item.
Undo on the active screen
The package-owned feedback child also delivers both outcomes through NativePHP's synchronous emit() lifecycle to the active screen's #[On] handlers. Use named payload parameters, not an event-object parameter: string $id, string $actionKey for actions and string $id, FeedbackDismissReason $reason for dismissals. The normal interaction then re-renders the screen; do not call render() yourself.
This complete in-memory example archives one item and restores it when Undo is pressed:
namespace App\Native;
use FirstlightUI\Elements\Button;
use FirstlightUI\Events\FeedbackActionPressed;
use FirstlightUI\Events\FeedbackDismissed;
use FirstlightUI\Facades\Feedback;
use FirstlightUI\Feedback\FeedbackDismissReason;
use FirstlightUI\NativeComponent;
use Native\Mobile\Attributes\On;
use Native\Mobile\Edge\Element;
use Native\Mobile\Edge\Elements\Column;
use Native\Mobile\Edge\Elements\Text;
final class Inbox extends NativeComponent
{
public bool $archived = false;
public ?string $undoFeedbackId = null;
public function archive(): void
{
if ($this->archived) {
return;
}
$this->archived = true;
$this->undoFeedbackId = Feedback::success('Item archived')
->action('Undo', 'undo-archive')->send();
}
#[On(FeedbackActionPressed::class)]
public function undo(string $id, string $actionKey): void
{
if ($id === $this->undoFeedbackId && $actionKey === 'undo-archive') {
$this->archived = false;
}
}
#[On(FeedbackDismissed::class)]
public function feedbackDismissed(string $id, FeedbackDismissReason $reason): void
{
if ($id === $this->undoFeedbackId) {
// Action has already run; Timeout and Manual simply end Undo.
$this->undoFeedbackId = null;
}
}
public function render(): Element
{
return $this->response(Column::make(
Text::make($this->archived ? 'Item archived' : 'Active item'),
Button::make('Archive')->onPress('archive'),
));
}
}
Laravel listeners run first for each outcome, then screen handlers, in this order: Laravel action → screen action → Laravel dismissal → screen dismissal. Avoid performing the same Undo in both places. If a Laravel listener throws, that outcome's screen delivery is skipped; action dismissal still runs in finally. A throwing screen action also runs dismissal before propagating. A dismissal-listener exception can supersede an action exception, following normal PHP finally semantics.
Screen delivery targets the currently mounted screen, not a retained reference to the screen that sent the message. The example's state is intentionally screen-local: for Undo that must work after navigation, store an authorized, durable undo token in your application and handle it in a Laravel listener instead (then reload screen state as needed). event(new FeedbackActionPressed(...)) on its own is still only a Laravel event, not a screen notification.
No BroadcastsGlobally marker is needed: that Mobile interface forwards incoming device events to Laravel, not Laravel events to screens. Feedback outcomes already arrive through the package child's EDGE callbacks; Firstlight uses Mobile's existing child-to-screen event support rather than a parallel dispatcher.
Queue and stable-ID updates
Feedback is displayed one item at a time in first-in, first-out order. Sending a new ID appends it. Sending an existing ID replaces that semantic record in place, so it keeps its current queue position:
Feedback::message('Connecting')->id('connection')->send();
Feedback::warning('Connection interrupted')->id('connection')->hold()->send();
An update can change message, tone, action, and held state. Updating the visible ID does not restart elapsed automatic time; the native host recalculates the allowed duration from the current content and accessibility policy. Completed IDs are protected from stale native frames and cannot dismiss or act on the next item.
Use a stable authored ID when later code must update or remove an item. Use the generated ID returned by send() for one-off feedback.
Lifetime and dismissal
Automatic feedback starts with a four-second minimum. Longer text may increase message time up to ten seconds, and an action adds two seconds. Platform accessibility policy may extend that duration.
Automatic feedback with an action, such as Undo, also presents a native dismiss control next to the action, so the person can clear it before it times out instead of waiting for it to stop covering the bottom of the screen. Its timeout still applies. Pressing the dismiss control dispatches only FeedbackDismissed with FeedbackDismissReason::Manual. Automatic feedback without an action has no visible dismiss control.
hold() disables automatic timeout. A held item always presents the dismiss control; its optional application action remains available. Either user action advances the queue. Feedback::dismiss() removes held and automatic items alike without an application event.
The dismiss control shows the package chrome label Dismiss with an × symbol on iOS and an × icon on Android, and both platforms expose the accessible label Dismiss feedback. There is no swipe-to-dismiss gesture. There is no public duration, position, animation, close-label, or stacking option.
Navigation, background, and process lifecycle
The feedback store and native host belong to the installed package, not the screen that called the facade. Pending and visible items therefore survive normal NativePHP screen navigation. Each publication refreshes package-owned native callback IDs, so an outcome routes to the feedback service rather than to a screen that has been replaced.
Automatic time counts only while the app is active and its feedback controls do not hold accessibility focus. Moving the app to the background pauses the remaining duration; returning to the foreground resumes it. Held items do not accrue timeout time.
The store is process-local memory. Process termination clears the queue; Transient Feedback is not durable storage and does not restore messages after relaunch.
Accessibility
Each newly visible semantic ID is announced once. Updating the same ID changes the visible content without repeating the announcement. Tone symbols are hidden from VoiceOver and TalkBack, while message text and labelled actions retain native semantics.
Actions and the dismiss control meet the platform baseline of at least 44 points on iOS and 48 dp on Android. Long text wraps, controls reflow under constrained width or large text, and both platforms preserve dark mode, contrast, and right-to-left layout. Automatic timing pauses while an action or the dismiss control owns accessibility focus.
iOS doubles the automatic duration while VoiceOver or Switch Control is active and uses an opacity-only transition with Reduce Motion. Android passes the content type and base duration to AccessibilityManager.getRecommendedTimeoutMillis, available at the package's API 29 floor, and retains Material snackbar accessibility semantics.
Validation and failure behaviour
Messages passed to a factory and values passed to id() or action() must be non-empty after trimming. Invalid values throw InvalidArgumentException before publication; surrounding non-blank whitespace is preserved. The action label and key are always authored together through action(). Feedback::dismiss() returns false for an unknown ID, including a blank ID, rather than publishing an event.
Defensive native decoding treats incomplete native action metadata as no action; an otherwise eligible item remains visible without an action. Blank or invalid identity, message, or tone, duplicate IDs in one frame, and a missing required lifetime callback make an item ineligible. Repeated or stale callbacks produce no duplicate events.
Disabled, loading, model binding, rich content, multiple actions, icons, per-platform copy, arbitrary colours, and renderer-specific styling do not apply. There is no consumer-authored feedback-center host and no supported direct construction of the internal wire elements.
Platform behaviour
iOS presents a bottom SwiftUI material notice with semantic SF Symbols, native buttons, safe-area spacing, Dynamic Type, and a reduced-motion-aware transition. Android presents a bottom Material 3 Snackbar with semantic tone treatment, navigation-bar and IME padding, responsive action layout, and TalkBack live-region behaviour.
Both platforms own one visible item and the same FIFO/event contract. Native expression differs by platform; Firstlight guarantees behavioural parity rather than identical geometry. Held and actionable items expose a dismiss control whose visible and accessibility labels are package chrome; see Localize chrome.
Compatibility
Transient Feedback supports the package versions and platform floors in the current compatibility reference: PHP 8.4, NativePHP Mobile 4.7, NativePHP Mobile UI 0.9, iOS 18.2 or later, and Android API 29 or later. Both Firstlight native hosts must be compiled into the application.
Screenshots
The screenshot manifest reserves /captures/transient-feedback and the four paths below. bin/check-transient-feedback --development reports missing showcase, image, and review evidence without blocking documentation work. Release mode requires clean package and showcase Git worktrees, the exact Task 6 route/component/view/test fixture, structural assertions for the capture screen and deterministic feedback record, and a passing manifest-focused test. That test runs with a short timeout and continuously drained 4,000-byte diagnostic ceiling. The gate incrementally decodes each differentiated PNG, including bounded files and chunks, CRCs, streamed IDAT zlib data, exact non-interlaced or Adam7 phone-sized scanlines, and IEND at end of file.
The current spec/reviews/transient-feedback-alpha.md must name a real reviewed package commit that is an ancestor of package HEAD; only the four screenshot files and that review may change afterward. Its showcase revision must equal the clean showcase HEAD. The review records date, reviewer, both release targets, all four exact image paths, and substantive PASS or APPROVED details for capture, platform runtime, lifecycle, VoiceOver, TalkBack, physical-device checks, and visual approval. Placeholder words embedded anywhere in those identity or evidence fields block release.
| Platform | Light | Dark |
|---|---|---|
| iOS | ![]() |
![]() |
| Android | ![]() |
![]() |



