From bfe340e85bfcb8e0ab99fa7fc4502f52c3f4598d Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 12 Sep 2026 09:49:03 -0400 Subject: [PATCH] Prepare nativephp/web-ui for public alpha Rename the package from nativephp/mobile-web to nativephp/web-ui to match the GitHub repo, switch the license to MIT (same LICENSE file as the other MIT plugins), and require nativephp/mobile ^4.4. Drop the private notice from the README and replace the path-repo install line with a dev-main require. Add CONTRIBUTING.md, CHANGELOG.md, a .gitignore, and the .github funding, security policy, and Pint workflow the sibling plugins ship. Remove the two internal planning docs and the empty .junie directory; the rendering guide no longer references a local machine path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015cMEafmkzTAm7PZFdzmfgq --- .github/SECURITY.md | 13 ++ .github/funding.yml | 1 + .github/workflows/pint.yml | 33 ++++ .gitignore | 6 + CHANGELOG.md | 19 +++ CONTRIBUTING.md | 71 +++++++++ LICENSE | 21 +++ README.md | 33 ++-- composer.json | 29 +++- docs/design-validation.md | 262 -------------------------------- docs/how-web-rendering-works.md | 4 +- docs/livewire-parity-map.md | 232 ---------------------------- 12 files changed, 215 insertions(+), 509 deletions(-) create mode 100644 .github/SECURITY.md create mode 100644 .github/funding.yml create mode 100644 .github/workflows/pint.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE delete mode 100644 docs/design-validation.md delete mode 100644 docs/livewire-parity-map.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..72d04ad --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).** + +## Reporting a Vulnerability + +If you discover a security vulnerability within this package, please send an +email to support@nativephp.com. All security vulnerabilities will be promptly +addressed. + +This package ships a browser-facing transport (sealed snapshots, hashed +endpoints, uploads). Reports touching snapshot integrity, endpoint +enumeration, or upload handling are especially welcome. diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..f91f9af --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1 @@ +open_collective: nativephp diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml new file mode 100644 index 0000000..2b28cf0 --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,33 @@ +name: Pint + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + pint: + name: Code style (Pint) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + # Pint needs no project vendor/ (a full install would drag in the + # private nativephp/mobile auth), but install it under the SAME + # constraint as composer.json's require-dev so CI and a local + # `composer lint` agree on the version instead of drifting apart. + - name: Install Pint + run: composer global require "laravel/pint:^1.24" --no-interaction + + - name: Check code style + run: pint --test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8237d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.lock +.phpunit.result.cache +.phpunit.cache/ +.DS_Store +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ca5d10 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to `nativephp/web-ui` are documented here. The format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +First public alpha, installable from `main`. Renders `Route::native()` +screens as HTML in a browser from the same Blade source the mobile app +ships. + +- Stateless Livewire-style protocol: sealed HMAC snapshots, APP_KEY-hashed + endpoints, uploads with progress. +- Client runtime (`edge-web.js`): keyed DOM morph, event queue, SPA nav, + effects, polls, lazy boot, virtual-list windowing, overlay focus trap. +- `edge:css` build-time Tailwind compilation and `edge:watch` live updates. +- Browser drivers standing in for device APIs, with per-action loading and + dirty tracking. +- Time-travel replay viewer for recorded tree sessions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c1959b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to nativephp/web-ui + +Thanks for helping out. This package is in **alpha**: the wire format, the +`WebRunner` contract surface, and the renderer registry are all still moving. +Small, focused pull requests land fastest. + +## Before you start + +- Read `docs/how-web-rendering-works.md` first. It explains the tree → HTML + pipeline and where each layer's responsibility ends. +- Behaviour a component author writes against belongs in core + (`nativephp/mobile`); this package contributes only transport and HTML. If a + change needs both, open the core PR first and link it. +- Open an issue before starting anything that touches `src/Protocol/` (the + snapshot and endpoint scheme) so we can agree on the wire-level change. + +## Local setup + +This package cannot be developed in isolation: it requires `nativephp/mobile` +and its tests run inside the core suite. + +1. Clone `nativephp/mobile` and this repo as **siblings**, so this repo sits at + `../plugins/nativephp/web` relative to core. Core's dev autoload maps + `Native\Mobile\Edge\Web\` to that path. +2. To try changes in a real app, add this repo to the app's `composer.json` as + a `path` repository and require `nativephp/web-ui:@dev` **from the app**. + +Do **not** run `composer install` or `composer update` inside this repo. It +creates a nested `vendor/nativephp/mobile` that breaks the iOS build of any +app consuming this checkout. If you need Pint locally, install it globally: + +``` +composer global require laravel/pint +``` + +## Running tests + +Tests for this package live in the core repo under `tests/Feature/Edge/` and +`tests/Unit/Edge/` (files prefixed `Web`). From the core checkout: + +``` +vendor/bin/pest --filter Web +``` + +Core's architecture tests also assert that `src/Renderer/` never imports +`Protocol` or `Bridge`. Keep it that way; the renderer is meant to be reusable +by a future desktop shell with its own transport. + +## Code style + +Laravel Pint, default preset. CI runs `pint --test` on every PR. + +``` +pint +``` + +## Pull requests + +- One concern per PR. A rename and a behaviour change are two PRs. +- Add or update a test in the core suite for any behaviour change. Link the + core PR from this one. +- Update `docs/` when you change how something works, not just what it does. +- Add a line to `CHANGELOG.md`. +- Mention the consuming app you verified against (browser and, if relevant, + device) in the PR description. + +## Reporting bugs + +Include the `nativephp/mobile` version, the browser, and a minimal Blade +screen that reproduces the issue. For anything security-related, see +`.github/SECURITY.md` and email instead of opening an issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..26a4c4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Bifrost Technology LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 79e9cc1..4ad8d6c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# nativephp/mobile-web +# nativephp/web-ui -**PRIVATE — do not publish.** The EDGE web render target: the same -`Route::native()` screens an app ships to phones, rendered as HTML in a -browser from the identical Blade source. +> **Alpha.** APIs and wire format may change between releases. + +The EDGE web render target: the same `Route::native()` screens an app +ships to phones, rendered as HTML in a browser from the identical Blade +source. ## How it plugs in @@ -33,9 +35,22 @@ package changes nothing about the native app. ## Installing in an app -Path repository + `composer require nativephp/mobile-web:@dev` — run -composer in the **consuming app**, never in this repo (see -`~/Herd/plugins/CLAUDE.md`). +``` +composer require nativephp/web-ui:dev-main +``` + +Requires `nativephp/mobile` ^4.4. The service provider is auto-discovered; +nothing else to register. + +Contributors working from a local checkout: add it as a path repository +and run composer in the **consuming app**, never in this repo. + +## Contributing + +See `CONTRIBUTING.md`. Tests live in the core repo's suite +(`tests/Feature/Edge/WebUpdateTest.php` etc.) via a dev-only autoload of +this package's `src/`. + +## License -Tests live in the core repo's suite (`tests/Feature/Edge/WebUpdateTest.php` -etc.) via a dev-only autoload of this package's `src/`. +MIT. See `LICENSE`. diff --git a/composer.json b/composer.json index fa5961f..a44c325 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,16 @@ { - "name": "nativephp/mobile-web", + "name": "nativephp/web-ui", "description": "EDGE web render target for NativePHP — the same screens as HTML in a browser", + "keywords": [ + "nativephp", + "laravel", + "edge", + "mobile", + "web", + "ssr" + ], "type": "library", - "license": "proprietary", + "license": "MIT", "authors": [ { "name": "NativePHP", @@ -11,7 +19,7 @@ ], "require": { "php": "^8.2", - "nativephp/mobile": "^4.0" + "nativephp/mobile": "^4.4" }, "autoload": { "psr-4": { @@ -24,5 +32,18 @@ "Native\\Mobile\\Edge\\Web\\WebServiceProvider" ] } - } + }, + "require-dev": { + "laravel/pint": "^1.24" + }, + "scripts": { + "lint": "pint", + "lint:test": "pint --test" + }, + "funding": [ + { + "type": "other", + "url": "https://nativephp.com/sponsor" + } + ] } diff --git a/docs/design-validation.md b/docs/design-validation.md deleted file mode 100644 index 544a475..0000000 --- a/docs/design-validation.md +++ /dev/null @@ -1,262 +0,0 @@ -# Design: form validation for EDGE components - -Status: **draft for review** — nothing built. Companion to -`livewire-parity-map.md` (phase 1). Most of this lands in core -(`nativephp/mobile`); mobile-ui and mobile-web get small follow-ups. - -## Goals - -- `$this->validate()` in a handler behaves like every Laravel dev - expects, **identically on device and web**. -- Error display fully in the author's hands (Livewire semantics): - nothing renders without `@error`/`@nativeError` markup or explicit - `error`/`supporting` attributes. Elements that display errors do it - through structured slots (`is_error`/`supporting` wire props). -- `$errors` available in Blade, shaped like Laravel's `ViewErrorBag`, so - `@error('email')` muscle memory works. -- Real-time (per-keystroke) validation with no new wire machinery — - the `native:model` sync path already exists. - -Non-goals for v1: Form objects (separate phase), `TemporaryUpload` rules -(uploads phase), custom Rule objects beyond what Laravel's validator -already accepts (they just work), per-rule styling metadata on the wire. - -## Author API (what a screen looks like) - -```php -use Native\Mobile\Attributes\Validate; - -class RegisterScreen extends NativeComponent -{ - #[Validate('required|email')] - public string $email = ''; - - #[Validate('required|min:8')] - public string $password = ''; - - public array $tags = []; // rules can also live in rules() - - protected function rules(): array - { - return ['tags.*' => 'string|max:20']; - } - - public function save(): void - { - $validated = $this->validate(); // attribute + rules() merged - - // ...or ad-hoc, Livewire-style: - // $validated = $this->validate(['email' => 'required|email']); - - User::create($validated); - $this->navigate('/welcome'); - } -} -``` - -```blade - - {{-- error appears inline automatically: is_error + supporting --}} - - - - {{-- classic Blade also works --}} - @error('email') - {{ $message }} - @enderror - - - -``` - -Behavior: `save()` calls `validate()`; on failure the handler aborts at -that line, state is untouched, the frame re-renders with errors set, and -the email input shows "The email field is required." in its supporting -slot, error-styled. On device and web identically. - -## Core design - -### 1. The trait: `Concerns\ValidatesProps` on `NativeComponent` - -- `validate(?array $rules = null, array $messages = [], array $attributes = []): array` - — builds the data array from `getPublicProperties()`, merges rule - sources (below), runs Laravel's `Validator`, throws Laravel's own - `Illuminate\Validation\ValidationException` on failure, returns the - validated subset on success. Passing `$rules` validates only those. -- `validateOnly(string $prop)` — one property (supports wildcards: - `validateOnly('tags.0')` matches a `tags.*` rule), replaces only that - field's errors, leaves the rest of the bag alone. -- `addError(string $key, string $message)` / `resetValidation(?string $key = null)` - / `getErrorBag(): MessageBag` — manual control, Livewire-compatible - names. -- Rule sources, merged in this order (later wins on key collision): - `#[Validate]` attributes on public props → `rules()` method → the - `$rules` argument. `messages()` / `validationAttributes()` methods for - customization, mirroring Livewire. -- Rule OBJECTS (`Rule::in()`, `Password::min()`, custom `ValidationRule` - classes, closures) work anywhere rules are arrays — `rules()` and - inline `validate([...])`. They can't appear inside `#[Validate]` - because PHP attribute arguments must be compile-time constants (same - limitation as Livewire). -- `validate()` also accepts a **FormRequest class-string** and harvests - its `rules()`/`messages()`/`attributes()` — one definition shared by - an HTTP controller and a screen. Harvesting only: the request is - instantiated bare, `authorize()` is never called, and `rules()` must - not touch `$this->input()/route()/user()`. (Livewire deliberately - rejected full FormRequest integration over exactly that HTTP - coupling — this is the uncontroversial subset.) - -Bag storage: a `MessageBag` property on the component. Deliberately NOT -a public prop (it must not be author-assignable or hit the generic -snapshot path) — it's internal state with its own carriage (below). - -### 2. `#[Validate]` attribute — `Native\Mobile\Attributes\Validate` - -`#[Validate('required|email')]` or `#[Validate(['required', 'email'], as: 'email address')]`. -Sits alongside `Computed`/`Poll`/`Lazy` in `src/Attributes/`. - -**Attribute rules auto-run on model sync** (Livewire parity): at the end -of `__syncProperty()`, if the synced prop has attribute rules, run -`validateOnly($prop)` catching the exception (a failed live validation -must not abort the sync — the value stays, the error shows). This gives -per-keystroke validation for free on both targets, respecting the -author's `native:model` modifier (`.blur` = validate on blur, `.live` = -per keystroke, debounce = debounced). `rules()`-method rules do NOT -auto-run on sync — that's the author's opt-out lever (attribute = eager, -method = on-demand), same split Livewire ended up with. - -### 3. Dispatch-cycle handling (the parity-critical part) - -`ValidationException` must be caught at every event entry point, with -identical semantics: **handler aborts, component state stays, the frame -renders, errors are set on the bag.** - -- Shared guard in core: a small `runGuarded(callable)` on - `NativeComponent` that catches `ValidationException`, stores - `$e->validator->errors()` into the bag, and swallows. Both device - entry points (UI events from the bridge, native events) route their - handler invocation through it. -- Web: `WebScreenRunner::update()` wraps its `dispatch($event)` call in - the same guard (it delegates to component internals via `scoped()`, - so it reuses the core guard — no web-specific catch logic). -- `mount()` is NOT guarded: a validation failure during mount is a - programming error, let it throw. -- Any other exception still propagates (the web error overlay / device - crash reporting handle those — validation is the only *control-flow* - exception). - -Bag lifecycle: `validate()` replaces the whole bag; `validateOnly($p)` -replaces only `$p`'s entries; a *successful* `validate()`/`validateOnly` -clears what it covered. Errors otherwise persist across frames — device -gets this free (persistent instance), web via snapshot carriage. - -### 4. `$errors` in Blade - -Every view-data merge site (`view()`, `fromView()`, `fromViewPartial()`, -the layout path — they all do `array_merge($this->getPublicProperties(), $data)`) -additionally injects `'errors' => $viewErrorBag` (a `ViewErrorBag` -wrapping the component's bag as the default bag) **unless the key is -already present**. Centralize in one `viewData()` helper since the merge -is currently copy-pasted across ~5 sites. Child components inject their -OWN bag into their own views — error scope = component instance. - -### 5. Error display is EXPLICIT-ONLY (revised 2026-08-08, Shane's call) - -Originally this design auto-injected `is_error` + the first message as -`supporting` onto `native:model`-bound elements. That was REMOVED: the -Livewire model — nothing renders until the author says so — is what -devs know, and the injected default locked in presentation (the control -tint wasn't even cleanly suppressible; `:error="false"` parses as -absent) and forced `supporting=""` tricks on anyone with custom error -UI. - -What the author writes is what appears: - -- `@error('field') … @enderror` — arbitrary Blade, any styling, any - placement, on every target. -- `@nativeError('field', '#hex')` — one-line message leaf. -- `error` / `supporting` attributes on elements that display them - (text inputs + Select/DatePicker/Checkbox/RadioGroup since the - error-display audit) — the Material-style slot, opted into per - element: - - - -`compileNativeModel()` still emits `model-prop` as metadata (stripped -in the collector, drives nothing) for devtools/future targets. The -`is_error`/`supporting` prop names remain the documented wire -vocabulary for elements that render an error slot. - -## Web-target carriage (this repo — deliberately small) - -- **Snapshot**: add an `errors` key to the sealed data - (`{prop: [messages]}`), captured after render, restored into the bag - before dispatch on the next update. Inside the HMAC seal like - everything else — a client can't forge or clear errors. Empty bag = - empty object, negligible size. -- **Renderer**: nothing. `is_error`/`supporting` already render. -- **`edge-web.js`**: nothing. Errors arrive as ordinary re-rendered - HTML; the morph patches them in. A future nicety (focus the first - errored input after a failed submit) is a follow-up, not v1. - -## Styling errors (dev contract — Shane-confirmed 2026-08-08) - -NOTHING renders without author markup — Livewire semantics, guaranteed: - -1. A failed validation only records state (`$errors`, the bag); it - never changes any element's props or appearance. -2. `@error('field')` renders arbitrary custom UI, any styling, any - placement, on every target. -3. `error`/`supporting` attributes opt a displaying element into its - built-in Material-style slot; theme tokens (`destructive`, - `on-surface-variant`) restyle that app-wide. - -Any future change that breaks one of these layers — especially #1, the -no-surprise-rendering rule — is a regression, not a redesign. - -## Decisions made here (flag if you disagree) - -1. Laravel's own `ValidationException` — not a custom one. Authors can - `throw ValidationException::withMessages([...])` from anywhere in a - handler and it Just Works. -2. Attribute rules auto-validate on sync; `rules()` rules don't. -3. Bag is internal state with dedicated snapshot carriage, not a public - prop. -4. ~~Injection replaces `supporting` while errored~~ — SUPERSEDED: - there is no injection; display is explicit-only (section 5). -5. `is_error`/`supporting`/`model_prop` become documented wire-level - props, not mobile-ui-private ones. -6. Error scope is the component instance (child components have their - own bags), matching callback ownership. - -## Open questions - -1. `#[Validate(as: '...')]` and `message:` sugar in v1, or start with - rules-only and add sugar when asked? (Lean: rules-only v1.) -2. Should a failed **live** validation on `.live` mode validate on every - keystroke or debounce the *validation* even when sync is live? - (Lean: validate whenever sync fires — the sync modifier IS the - cadence control.) -3. Web-only: after a failed submit, scroll-to/focus first errored input? - (Lean: v1.1 with a `data-edge-error` attr on errored inputs.) - -## Rollout - -Three PRs, independently shippable, in order: - -1. **core**: `ValidatesProps` trait + `#[Validate]` + guarded dispatch + - `$errors` injection + `model-prop` emission + prop injection at the - collector. Tests: unit (bag semantics, rule merging) + feature - (dispatch aborts, elements untouched on failure, sync auto-validation, - child-component scoping). -2. **mobile-web** (this repo): snapshot `errors` carriage + restore. - Tests ride core's web suite (update cycle: failed validate → same - response carries errored HTML + sealed errors; next update round-trips - the bag; tamper still 419s). -3. **mobile-ui**: audit inputs so every form element honors - `is_error`/`supporting` consistently (most already do); docs. - -Docs for authors land with PR 1 (core docs) — per the standing note that -mobile-ui features keep merging without docs. diff --git a/docs/how-web-rendering-works.md b/docs/how-web-rendering-works.md index 4411706..d6ae4f6 100644 --- a/docs/how-web-rendering-works.md +++ b/docs/how-web-rendering-works.md @@ -652,8 +652,8 @@ if (class_exists(HtmlRendererRegistry::class)) { ## 10. Gotchas checklist -- **Never run composer in this repo** — see `~/Herd/plugins/CLAUDE.md`. - Install into a consuming app via a path repository. +- **Never run composer in this repo** (see `CONTRIBUTING.md`). Install into + a consuming app via a path repository. - Dynamic Tailwind classes built at runtime (`w-[{{ $x }}%]`) won't exist in a production `edge:css` build — use `style=""` (`web_style`) for runtime-computed values. diff --git a/docs/livewire-parity-map.md b/docs/livewire-parity-map.md deleted file mode 100644 index f94e39c..0000000 --- a/docs/livewire-parity-map.md +++ /dev/null @@ -1,232 +0,0 @@ -# Livewire-parity feature map - -Planning doc — nothing here is built yet. Maps each Livewire feature we -want onto the EDGE architecture: where it lives, what exists already, -what's new, and in what order to build. - -## The one rule that shapes everything - -**If a component author writes it, it must behave identically on device -and web.** That means almost every feature below is a *core* -(`nativephp/mobile`) feature first, with mobile-web contributing only -transport and HTML. A web-only `validate()` would fork the programming -model — the exact thing EDGE exists to avoid. - -Consequence: most of this work is core PRs + mobile-ui PRs, with small -web-plugin PRs riding behind them. Web can prototype first (it's the -cheapest target to iterate on), but the API contract gets designed as a -core contract from day one. - -Where things live: - -| Layer | Owns | -|---|---| -| core (`nativephp/mobile`) | Author-facing API: `validate()`, error bag, form objects, upload value object, attributes (`#[Validate]`, `#[Url]`, …), dispatch-cycle semantics | -| mobile-ui | Element vocabulary: `file_input`, paginator, error display slots (inputs already have `label` / `supporting` / `is_error`) | -| mobile-web (this repo) | Wire transport, HTML renderings, browser drivers, snapshot carriage | - ---- - -## 1. Form validation — build first - -Everything else leans on this (uploads validate, forms wrap it). - -**What exists:** nothing in core — no `validate()`, no error bag. On the -display side, mobile-ui inputs already have `is_error` + `supporting` -props, and the web renderer already styles them. - -**Core (new):** -- `$this->validate($rules)` and `validateOnly($prop)` wrapping Laravel's - Validator; `#[Validate('required|email')]` on public props as sugar. -- `ValidationException` caught by the dispatch cycle (device run loop AND - web runner): abort the handler, keep state, proceed to render — the - frame after a failed validation just renders with errors set. -- An error bag on the component, exposed to Blade as `$errors` - (ViewErrorBag-shaped so existing Blade muscle memory works). -- Real-time validation falls out for free: `updatedX()` hooks already - fire on `native:model` sync — `updatedEmail() { $this->validateOnly('email'); }`. - -**~~Core render-time auto-wiring~~ — built, then REMOVED (2026-08-08, -Shane's call):** the Livewire model is what devs know. Nothing renders -without explicit `@error`/`@nativeError` markup or `error`/`supporting` -attributes; a failed validation never touches an element's props. See -design-validation.md §5 for the reasoning (locked-in presentation, -un-suppressible tint). - -**Web (small):** carry the error bag in the sealed snapshot so errors -survive *unrelated* subsequent updates (Livewire persists them too; -device parity — a persistent instance naturally keeps them). Dehydrate as -plain `{prop: [messages]}`. - -**Decision needed:** none blocking — this design has an obvious shape. - -## 2. File uploads — BUILT + verified in xclone (revised: no new author API) - -The author-facing API already exists and must not be duplicated: -`Camera::pickImages()` (gallery, images/videos, multiple) → the -`MediaSelected` event; `Camera::getPhoto()` / `recordVideo()` for -capture; `File::move()/copy()` for doing something with the result; the -camera plugin's `CameraPreview` on top. The gap is purely that none of -it works in a browser. - -**Web work (all in this repo + small core touch):** -1. **Client drivers for `Camera.*`** — the same ClientEffect + - result-event pattern geolocation uses. `Camera.PickImages` → hidden - ``, pick piped through the - existing `window.EdgeUpload`, `MediaSelected` dispatched back. - `Camera.GetPhoto` → `` - (mobile browsers open the camera natively for that). -2. **Path normalization** — device events carry real filesystem paths; - the web driver's payload carries `{path, signature}` descriptors. - The server resolves + verifies them (`EdgeUpload::validatePath()`) - while handling the update, so the author's listener receives a real - readable temp path on BOTH targets. Same handler code everywhere. -3. **Extras, in order of value:** signed preview-URL route → XHR upload - progress (`data-edge-uploading`) → done. - -**Deliberately not building:** a `file_input` element, a -`TemporaryUpload` value object — the facade + events ARE the API. -Later, if apps need arbitrary-file picking (PDFs…), that's a method on -the existing `File` facade, not a new system. Validation-rule sugar for -picked paths is an optional nicety, not load-bearing. - -**Discovered while building (2026-08-07):** -- The `{type:'native_event'}` result-return path was previously DROPPED - server-side (dispatch only resolved callback ids) — now handled in - `WebScreenRunner::update()`; this also fixed geolocation/dialog - result events, not just camera. -- The fluent chain works on web in ALL forms, including `$this`-bound - closures (`->onSuccess(function ($e) { $this->… })` — everything in - one method). The carrier trick in `NativeCallbacks::register()`: - fire-time rebinding to the live component means the binding never - needs to survive serialization, so `$this` closures are rebound to a - throwaway `stdClass` and serialized (code + `use` vars) into the - durable cache tier. Bonus: closures are now kill-resilient on device - too. New `onSuccess()` sugar on `HandlesNativeCallbacks` names the - success event generically. Constraints: serializable `use` vars, no - eval'd closures, persistent cache store on web (file/redis, not - `array`). Method-name strings and `#[On(Event::class)]` also work. -- Core's bridge README still shows the stale `#[On('native:FQCN')]` - form — `On`'s constructor now adds the prefix itself; pass the bare - event class. Worth a core docs fix. -- Signed file route BUILT: `` is now - target-identical — native loads the file path directly; WebRenderer - rewrites storage/app paths to HMAC-signed `/file?p=…&s=…` URLs via an - injected resolver (renderer stays transport-free). Only storage/app - files sign; tampered/out-of-tree requests 404. -- Storage/File parity BUILT: mobile-web registers the same Laravel disks - core registers on device (`mobile_public` → storage/app/public, `temp` - → edge-tmp, mirroring NativeServiceProvider::registerFilesystems), and - `File.Move`/`File.Copy` got real web drivers — previously the File - facade REPORTED success on web while doing nothing (unhandled bridge - method answers null; the facade's fallthrough returned true). The - blessed persistence pattern is now - `Storage::disk('mobile_public')->path(…)` + `File::copy(…)` — identical - author code on both targets. -- Remaining extra not yet built: XHR upload progress. - -## 3. Cheap wins — batch third - -- **`native:confirm`** (wire:confirm): core parses a `confirm="…"` - attribute on pressables into a prop; device renderers show a native - dialog; web intercepts before `enqueue()` using the existing - `showAlert()`. Tiny on every layer. -- **Flash messages:** core `flash()` helper writing screen-scoped data - that survives one navigation — device already passes `navigate(uri, - data)` payloads; web rides the session (cookie already travels on SPA - nav fetches). Mostly a convention + docs. -- **Loading states** (wire:loading/wire:target): web already emits - `data-edge-busy` (root), `data-edge-loading` + `disabled` (origin). - Add the callback id to the busy root (`data-edge-busy=""`) so - CSS can target *which* action is in flight. Pure web, no core change. - Document the Tailwind recipes. -- **Dirty tracking** (wire:dirty): pure JS — compare live input values - against server-rendered values, set `data-edge-dirty`. No core change. - -## 4. Bigger, later - -- **Form objects** (Livewire `Form` classes): core class with props + - rules + `fill()`/`reset()`; dehydrates as a nested prop bag in the - snapshot. Wait until validation has settled. -- **`#[Url]` / query-string binding:** web is easy - (`history.replaceState` sync). The hard part is defining what it means - on device (route params? ignored?) — don't ship until that's answered. -- **Pagination:** mostly falls out of existing machinery (paginator in - a prop + press callbacks) + a mobile-ui paginator element + `#[Url]` - for page state. -- **Partial/region re-rendering** (islands): payload-size optimization — - every update currently re-sends the whole screen. Design only when it - measurably hurts; the morph makes it invisible to authors, so it can - land any time without API changes. -- **Real-time / websockets — not an immediate need.** The device story - already exists: `nativephp/mobile-vibe` (Pusher protocol via native - SDKs, `Vibe::channel()->on(...)` in PHP). Web parity, when wanted, is - a browser driver for the *same Vibe API* (pusher-js/Echo in the - browser feeding the component's listeners, triggering an update) — not - a new mechanism, and not `wire:stream`-style SSE. Polls cover current - needs. - -## Author JS on the web target - -Author-written JS **is allowed on web** — it just has to be thought -about differently for mobile: on device, JS-shaped needs route through -elements (mobile-ui vocabulary) or plugins (e.g. vibe), so web JS is a -web-enhancement layer, not the place screen behavior lives. A screen -should still *work* on device without it. - -What exists today: `window.EdgeDrivers` (override/add client effect -drivers) and page-level scripts loaded before the runtime. What doesn't -exist yet is a sanctioned way for an app to ship its own JS with a -screen — design item, not urgent: - -- app-level: a `WebShell` hook to inject app script/asset tags (cheap, - probably first); -- screen-level: a `@web`-guarded script slot or element, if per-screen - behavior turns out to be wanted. - -## Open questions (decide before building #2) - -1. **Upload dev API shape:** element + `native:model` binding (proposed, - Livewire-like) vs imperative bridge API (`FilePicker::open()` → - event, camera-like). Element feels right for forms; the bridge API - may still be wanted for programmatic flows. Could ship both on the - same `TemporaryUpload` plumbing. -2. **Where `TemporaryUpload` lives** — core needs it (validation rules, - author code touches it on device too), but the web signing scheme is - this repo's. Likely: contract + value object in core, per-target - backends. -3. **Error-bag snapshot shape** — flat `{prop: [messages]}` vs carrying - the failed rule names too (needed only if native renderers want - per-rule styling). -4. **Does auto-wiring errors into `supporting` clobber** an - author-provided supporting text on error, or append? (Livewire - equivalent: replaces. Recommend: replaces while errored.) - -## Suggested sequencing - -Each phase is shippable alone; tests ride core's suite as today. - -1. ~~Validation core + web snapshot carriage + explicit-only error display - errors~~ — DONE (core PRs #301/#302, tested). -2. ~~Uploads via the existing Camera facade~~ — DONE (web drivers, - native_event seam, signed file serving, Storage/File parity). - Remaining: XHR upload progress. -3. **Error-display audit** — DONE (2026-08-08). The premise that most - elements "already had" error slots was wrong: only TEXT inputs - displayed errors, on every target. Select/DatePicker/Checkbox/ - RadioGroup now render is_error/supporting on iOS + Android + web and - accept the attrs in PHP (mobile-ui branch `feat/error-display-audit`, - unpushed; native halves NOT yet device-built — verify on a real - build). Slider/Toggle/Chip/ButtonGroup deliberately skipped: rarely - validated, same pattern applies when needed. -4. Cheap-wins batch — web-side trio DONE (2026-08-08, branch - `feat/web-cheap-wins` stacked on phase 1): per-action loading - (`data-edge-busy` carries the callback id), dirty tracking - (`data-edge-dirty`, morph-self-cleaning), and XHR upload progress - (`onProgress` + `edge-upload-progress` event + `data-edge-uploading`). - Browser-behavior features — verified by syntax + contract harnesses; - a visual browser pass is still owed. FLASH deferred: it needs core, - and core is frozen for review (#301/#302 awaiting Simon). Confirm - stays demoted to backlog. -5. Form objects, then `#[Url]` + pagination once device semantics are - agreed.