diff --git a/.claude/hooks/lint-changed.sh b/.claude/hooks/lint-changed.sh new file mode 100755 index 0000000..0a70766 --- /dev/null +++ b/.claude/hooks/lint-changed.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Lint the file Claude just wrote, and report a failure instead of hiding it. +# +# `Backend (pint + tests)` is one of the required checks on this repository, so a style +# violation that reaches a push costs a CI round. This reports it at the moment it is written. +# +# Why a script rather than a settings one-liner: a JSON-escaped command is where a malformed +# settings file comes from, and a malformed one is skipped silently in -p and CI runs. +# +# Why the linter call is not wrapped to discard stderr and swallow the exit code: a suppressed +# hook is indistinguishable from a hook that never fired. +# +# `set -e` is deliberately absent: the linter's nonzero exit is the case worth reporting, and +# -e would exit before the report is written. Every condition the hook cannot judge exits 0. + +set -u + +TOOL=pint # baked in at construction time, never read from repository content +CHECK_ARGS='--test' # the flag that makes pint REPORT rather than rewrite + +command -v jq >/dev/null 2>&1 || exit 0 +payload=$(cat) || exit 0 +file=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty') || exit 0 +[ -n "$file" ] || exit 0 + +# file_path is absolute, but nothing promises `..` is collapsed or symlinks resolved. Resolve +# BOTH sides the same way before comparing: `cd` collapses `..` and `pwd -P` resolves symlinks. +root=$(cd "${CLAUDE_PROJECT_DIR:-.}" && pwd -P) || exit 0 +dir=$(cd -- "$(dirname -- "$file")" && pwd -P) || exit 0 +real="$dir/$(basename -- "$file")" + +case "$real" in + "$root"/*) ;; # the trailing slash stops /root-evil matching + *) exit 0 ;; +esac +case "$real" in + */.git/*|*/.env|*/.env.*|*/vendor/*|*.pem|*.key|*id_rsa*) exit 0 ;; +esac +# Pint governs `backend/` only, and it is the only linter installed on disk here. Dart is left +# out on purpose: `flutter analyze` is a whole-project pass costing seconds per call, and the +# Dart language server already surfaces the same diagnostics without a hook. +case "$real" in + *.php) ;; + *) exit 0 ;; +esac + +bin='' +for candidate in "backend/vendor/bin/$TOOL" "vendor/bin/$TOOL"; do + if [ -x "$root/$candidate" ]; then bin="$root/$candidate"; break; fi +done +[ -n "$bin" ] || bin=$(command -v "$TOOL") || exit 0 + +# Run from `backend/`, which is where a `pint.json` would be read from if one is ever added. +# `--` is not passed: pint takes the path as a plain argument. +output=$(cd "$root/backend" && "$bin" $CHECK_ARGS "$real" 2>&1) +status=$? +[ "$status" -eq 0 ] && exit 0 + +printf '%s' "$output" | head -c 4000 | jq -Rs --arg f "$real" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", + additionalContext: ("Pint failed on \($f):\n" + .)}}' +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..143db58 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "enabledPlugins": { + "fluttersdk@fluttersdk-marketplace": true + }, + "worktree": { + "baseRef": "fresh" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "^(Write|Edit|MultiEdit)$", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-changed.sh", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 07fb6f7..e5f6bdd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,7 +24,8 @@ That override file is also why a green local run can be a red CI: with it, this ## One task, one worktree, one PR - Branch from `main` as `feature/` or `fix/`, and work in a worktree under `.claude/worktrees/`. -- A fresh worktree lacks three gitignored files it needs in order to run: `pubspec_overrides.yaml`, `backend/.env`, `.artisan/plugins.json`. Two mechanisms copy them from the main worktree and neither covers every path on its own: `.worktreeinclude` runs when Claude Code creates the worktree, `bin/check` on its first run there. Do not hand-author them. +- A fresh worktree lacks three gitignored files it needs in order to run: `pubspec_overrides.yaml`, `backend/.env`, `.artisan/plugins.json`. Two mechanisms copy them from the main worktree and neither covers every path on its own: `.worktreeinclude` runs when Claude Code creates the worktree, `bin/check` on its first run there. Both carry all three now; `.worktreeinclude` used to carry only the overrides, so the `EnterWorktree` path silently shipped a worktree with no `backend/.env` and no plugin commands. Do not hand-author them. +- Without `pubspec_overrides.yaml` nothing errors: the siblings resolve from pub.dev and the suite passes against the PUBLISHED packages while the diff under review is of the local ones. `bin/check` refuses rather than measuring the wrong thing. A FORK has no sibling checkouts and hosted resolution is correct there, which is what `CHECK_ALLOW_HOSTED=1 bin/check` is for; inside this workspace, reaching for it means you are about to certify the wrong packages. - The paths inside `pubspec_overrides.yaml` must be ABSOLUTE. A worktree lives at `.claude/worktrees/`, so the conventional relative `../magic` resolves to `.claude/worktrees/magic` and version solving fails on the first path dependency. That failure is loud, unlike the one above it. - Land the work as a PR. A suite that only ran on one machine is not evidence. @@ -32,10 +33,14 @@ That override file is also why a green local run can be a red CI: with it, this `bin/check` is the gate. It fans the suites out across cores and prints one line per job: -- `bin/check` runs `flutter analyze`, `flutter test`, `pint --test`, and the PHP suite. +- `bin/check` runs `flutter analyze`, the design-token scan, the component-registry check, the overrides-parser shape table, `flutter test`, `pint --test`, and the PHP suite. - `bin/check --fast` runs only the static passes. - `bin/check flutter|backend` scopes it to one half. +Two gates are NOT in `bin/check`: the `.github/` instruction mirrors and the package skill copies are checked by CI, so a stale one passes locally and blocks the merge there. Run `bin/sync-instructions` after editing AGENTS.md or a rule, and `bin/sync-skills` after pulling a sibling package. + +A `.php` file Claude writes is linted at that moment by a `PostToolUse` hook (`.claude/hooks/lint-changed.sh`), which reports a `pint --test` failure back into the session instead of letting it cost a CI round. It is registered in `.claude/settings.json` rather than `settings.local.json`, because the latter is gitignored and a worktree would therefore never have it. The hook exits silently when `backend/vendor/` is absent, which is a real state in a fresh clone rather than a fault. + A green suite is the floor, not the finish line. Anything a person clicks gets driven for real with `fluttersdk_dusk` against a running Chrome, at desktop and at mobile width both, because the shell swaps widget trees at `lg` (1024px) and each side can break alone. `docs/verification-loop.md` is the procedure: the three layers, how to boot the app, how to resize a viewport correctly, and the measurement traps that produce confident wrong answers. ## Running it @@ -45,7 +50,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. diff --git a/.github/skills/magic-framework/SKILL.md b/.github/skills/magic-framework/SKILL.md new file mode 100644 index 0000000..5f6e86a --- /dev/null +++ b/.github/skills/magic-framework/SKILL.md @@ -0,0 +1,398 @@ +--- +name: magic-framework +description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." +when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." +version: 0.1.11 +--- + + + + +# Magic Framework + +Laravel-inspired Flutter framework: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, and GoRouter-backed routing. This skill makes an agent write code that an experienced magic developer would write: facade-first, IoC-resolved, reactive, and verified against the real API in `lib/src`. All visual styling is handled by Wind (load the `wind-ui` skill for className work); this skill owns architecture, data, navigation, auth, and testing. + +The host app already depends on `package:magic/magic.dart`. The accuracy contract for this skill: every API you write must exist in `lib/src`. When unsure of a signature, open the source or the matching `doc/**` page rather than guessing; magic is pre-1.0 and the surface is exact, not approximate. + +## 0. Before writing code in this project + +Three checks, each pays off across the whole session. + +1. **Read `lib/main.dart` and `lib/config/app.dart`.** Note the `providers` list and its ORDER (AppServiceProvider must precede AuthServiceProvider so `setUserFactory` is set before auth restore runs), and whether `configFactories` or `configs` is used. +2. **Scan one existing controller + view pair in `lib/app/`** for the project's idioms: the singleton accessor shape, how views resolve controllers, how forms are wired. Match the surrounding code, do not invent a dialect. +3. **CLI invocation.** Magic ships an `artisan` executable, so every command runs as `dart run magic:artisan ` from any app that depends on magic (no package-name placeholder, no global activate). + +## 1. Core Laws + +Hard constraints for every line of magic code. + +1. **`await Magic.init()` first.** It must be awaited in `main()` before any facade call and before `runApp()`. Never `.then()`; providers are not booted until the future completes. +2. **Facade-first.** Reach for `Auth`, `Http`, `Config`, `Cache`, `DB`, `Schema`, `Log`, `Event`, `Echo`, `Lang`, `MagicRoute`, `Gate`, `Session`, `Vault`, `Storage`, `Pick`, `Crypt`, `Launch`. Resolve from the container manually (`Magic.make('key')`) only when extending the framework. +3. **Controllers are singletons.** `static X get instance => Magic.findOrPut(X.new);` is the canonical accessor. Views resolve controllers via `Magic.find()` (automatic in `MagicView`), never through constructors. +4. **IoC over `new` for services.** Bind in a provider's `register()`, resolve via the facade or `Magic.make('key')`. Do not scatter `Service()` construction across the app. +5. **Provider discipline.** `register()` is synchronous and is where routes and bindings go. `boot()` is async and may resolve other services; set `Auth.manager.setUserFactory(...)` here. +6. **Reactive state, not setState.** Controllers extend `MagicController` (a `ChangeNotifier`); state flows through `MagicStateMixin` + `RxStatus`. Use `refreshUI()` (guarded `notifyListeners`, and the single seam every controller notification goes through, including validation), `setLoading/setSuccess/setError/setEmpty`, and `MagicBuilder` for sections. `MagicController.onRefreshUI` is a null-by-default static debug tooling sets to observe those notifications. Local `setState` belongs only to genuine widget-local UI state inside a `MagicStatefulView`. +7. **Typed attribute access.** Models use `get('key')` and `set('key', v)`, never raw `getAttribute`. Declare `fillable`; use `fill(validated, strict: true)` after validation so schema drift throws `MassAssignmentException`. +8. **Context-free navigation and feedback.** `MagicRoute.to/back/replace`, `Magic.snackbar/toast/dialog/confirm/loading`. Never depend on a `BuildContext` for navigation or feedback. Never navigate or fetch inside `build()`. +9. **Validate at the boundary.** `MagicFormData` for forms, `FormRequest` for complex payloads, `Validator` for ad hoc checks. Surface server errors with `handleApiError(response)` (from the `ValidatesRequests` mixin). +10. **Trailing commas, multi-line collections.** Always. Match the project's existing style. + +## 2. Bootstrap + +```dart +import 'package:magic/magic.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Magic.init( + configFactories: [ + () => appConfig, // factories: evaluated AFTER Env.load(), so env() works inside them + () => authConfig, + () => networkConfig, + ], + ); + runApp(MagicApplication(title: 'My App')); +} +``` + +Real lifecycle (from `lib/src/foundation/magic.dart`): `Env.load()` then `configFactories` evaluate, then `MagicApp.init` (config merge), then the web URL strategy is applied if `routing.url_strategy == 'path'`, then core bindings, then providers `register()` (sync), then `await boot()` (async), then the router pre-builds, then ready. + +Use `configFactories` (not `configs`) whenever a config value reads `Env.get()`: `configs` is evaluated before Env is loaded. `MagicApplication` accepts `title`, `titleSuffix`, `windTheme`, `themeMode`, `locale`, `localizationsDelegates`, `onThemeChanged`, `onInit`, `initialRoute`. + +## 3. Mental model: Laravel to magic (and where it diverges) + +Magic mirrors Laravel's vocabulary; it diverges wherever Dart lacks PHP's runtime reflection or where the target is a Flutter client, not an HTTP server. Internalize the divergences; they fail silently (null, not an exception). + +| Laravel | magic | Note | +|---|---|---| +| `Container` autowiring, `__callStatic` facades | string-keyed factory closures + explicit static facade stubs | No reflection, no autowiring; an unregistered key throws at runtime | +| `ServiceProvider::boot()` (sync) | `boot()` is `async` | await it; dropped futures leave a half-booted provider | +| `Router::resource` returns Responses | routes resolve to WIDGETS; middleware runs on NAVIGATION | not an HTTP request cycle | +| controllers = per-request handlers | controllers = reactive `ChangeNotifier` singletons | live for the session, drive UI via `RxStatus` | +| Eloquent lazy load + `with()` eager load | relations cast from nested API Maps, cached on first access | NO lazy load, NO `with()`, NO query-builder relations: if the payload did not nest it, it is null | +| `Gate`/`Policy` server-authoritative | `Gate`/`Policy` run CLIENT-side, advisory only | always re-authorize on the backend | +| server sessions | tokens in `Vault` (secure storage), cache-first restore | `Auth.restore()` on cold start | +| `Encrypter` AES + HMAC/AEAD JSON envelope | AES-256-CBC `iv:ciphertext` (base64), no MAC | not cross-decryptable with Laravel's `Crypt` | + +The five assumptions a Laravel developer gets wrong most: (1) the container autowires (it does not, register explicitly); (2) `user.posts` lazy-loads (it does not, embed in the payload); (3) `Gate.allows()` is real security (advisory only); (4) `with()` exists (it does not); (5) `boot()` is sync (it is async). Full mapping with Laravel source citations: `${CLAUDE_SKILL_DIR}/references/bootstrap-lifecycle.md`. + +## 4. Facades and the container + +### IoC container (the methods an app uses) + +| Call | Purpose | +|---|---| +| `Magic.bind('key', () => Svc(), {shared})` | factory binding (new instance per resolve; `shared: true` caches) | +| `Magic.singleton('key', () => Svc())` | lazy shared singleton | +| `Magic.make('key')` | resolve a service (throws if unbound) | +| `Magic.bound('key')` | is the key registered | +| `Magic.put(ctrl)` / `Magic.find()` / `Magic.findOrPut(T.new)` | controller register / resolve / get-or-create | +| `Magic.delete()` / `Magic.isRegistered()` | controller remove / check | +| `Magic.flush()` / `MagicApp.reset()` | clear controllers / full container reset (testing) | + +### The 18 facades + +`Config` and `Gate` resolve through their managers (no plain IoC key); the rest bind to the key shown. + +| Facade | Key | Surface you reach for (all verified in `lib/src/facades/`) | +|---|---|---| +| `Auth` | `auth` | `login(data, user)`, `logout()`, `check()`, `guest` (getter), `user()`, `id()`, `getToken()`, `refreshToken()`, `restore()`, `registerModel(factory)`, `guard([name])`, `stateNotifier`, `manager`, `fake({user})` | +| `Http` | `network` | `get/post/put/delete`, `upload`, RESTful `index/show/store/update/destroy`, `fake([stubs])`, `response([data, code])`, `unfake()`. NO `patch` | +| `Config` | (manager) | `get`, `getOrFail`, `set`, `has`, `all`, `merge`, `prepend`, `push`, `forget`, `flush`, `repository` | +| `Cache` | `cache` | `put(key, value, {ttl})`, `get`, `has`, `forget`, `flush`, `remember(key, ttl, cb)`, `fake()` | +| `DB` | (lazy) | `table(name)` (query builder), `select/statement/insert/update/delete` (raw SQL), `transaction(cb)`, `beginTransaction/commit/rollback` | +| `Schema` | (manager) | `create(table, (b){})`, `table`, `drop`, `dropIfExists`, `hasTable`, `hasColumn`, `getColumns`, `rename` | +| `Log` | `log` | `info/error/warning/debug/notice/critical/alert/emergency`, `log(level, msg)`, `channel(name)`, `fake()` | +| `Event` | (dispatcher) | `dispatch(MagicEvent)`; register listeners with `EventDispatcher.register(Type, [() => Listener()])` | +| `Echo` | `broadcasting` | `channel/private/join`, `listen`, `leave`, `connect/disconnect`, `socketId`, `connectionState`, `onReconnect`, `addInterceptor`, `manager`, `fake()` | +| `MagicRoute` | (router) | `page`, `group`, `layout`, `resource(name, ctrl, {only, except})`, `to`, `toNamed`, `push`, `back({fallback})`, `replace`, `setTitle`, `currentTitle`, `config` | +| `Gate` | (manager) | `define`, `before`, `allows`, `denies`, `allowsAny(list)`, `allowsAll(list)`, `has`, `abilities`, `flush` | +| `Session` | (store) | `flash(map)`, `flashErrors(map)`, `old(field, [fallback])`, `oldRaw`, `error(field)`, `errors(field)`, `hasError`, `hasFlash`, `tick()` | +| `Lang` | (translator) | `get(key, [replace])`, `has`, `current`, `isLoaded`, `supportedLocales`, `setLocale`, `detectLocale`, `detectAndSetLocale`, `setSupportedLocales`, `addListener/removeListener`, `delegate` | +| `Vault` | `vault` | `put(key, value)`, `get`, `delete`, `flush`, `fake([initial])` | +| `Storage` | (manager) | `disk([name])`, `put`, `get`, `getFile`, `exists`, `delete`, `url`, `download`, `setManager`, `flush` | +| `Pick` | (static) | `image`, `images`, `camera`, `media`, `video`, `recordVideo`, `file`, `files`, `directory`, `saveFile` | +| `Crypt` | `encrypter` | `encrypt`, `decrypt`, `encryptWithDeviceKey`, `decryptWithDeviceKey`, `hasDeviceKey`, `generateDeviceKey`, `clearDeviceKey` | +| `Launch` | `launch` | `url(u, {mode})`, `email`, `phone`, `sms`, `canLaunch` | + +Global helper functions exist and are idiomatic: `env(key, [default])`, `trans(key, [replace])`, `old(field, [fallback])`, `error(field)`, `carbonNow()`, `carbonToday()`, `carbonParse(s)`. Full per-facade signatures: `${CLAUDE_SKILL_DIR}/references/facades-api.md`. + +## 5. Canonical patterns + +Full annotated templates: `${CLAUDE_SKILL_DIR}/references/templates.md`. + +### Model + +```dart +class User extends Model with HasTimestamps, InteractsWithPersistence { + @override String get table => 'users'; + @override String get resource => 'users'; + @override List get fillable => ['name', 'email']; + @override bool get useLocal => true; // OPT IN to SQLite; default is API-only (false) + @override Map get casts => { + 'created_at': 'datetime', // Carbon + 'settings': 'json', // Map or List + 'status': EnumCast(UserStatus.values), // class-based cast + 'tags': ListCast(EnumCast(UserTag.values)), // element-wise list cast + }; + @override Map get relations => {'company': Company.new}; + + int? get id => get('id'); + String? get name => get('name'); + set name(String? v) => set('name', v); + Company? get company => getRelation('company'); // from nested payload Map, not a query + + static User fromMap(Map map) => + User()..setRawAttributes(map, sync: true)..exists = true; + static Future find(dynamic id) => + InteractsWithPersistence.findById(id, User.new); + static Future> all() => + InteractsWithPersistence.allModels(User.new); +} +``` + +Casts: `datetime`, `json`, `bool`, `int`, `double` (string keys), plus class-based `CastsAttributes` (`EnumCast(values, {strict})`, `ListCast(inner)`). `save()` is API-first then syncs to SQLite when `useLocal` is true. Relations are decoded from nested API Maps and cached on first access: there is no lazy load, no eager load, no `with()`. + +### Controller + reactive state + +```dart +class UserController extends MagicController + with MagicStateMixin>, ValidatesRequests { + static UserController get instance => Magic.findOrPut(UserController.new); + + @override void onInit() { super.onInit(); load(); } + + Future load() => fetchList('/users', User.fromMap); // auto loading/success/error/empty + + Future store(Map data) async { + authorize('create-user'); // throws AuthorizationException if Gate denies + clearErrors(); + final response = await Http.post('/users', data: data); + if (response.successful) { Magic.toast(trans('users.created')); MagicRoute.to('/users'); return; } + handleApiError(response, fallback: trans('users.create_failed')); // 422 -> field errors + } +} +``` + +`fetchList(url, fromMap, {dataKey: 'data', query, headers})` and `fetchOne(...)` drive the `RxStatus` transitions. In the view, `controller.renderState((data) => ..., onLoading: ..., onError: (msg) => ..., onEmpty: ...)` renders per state. `MagicResponse` exposes `.data` (the payload, never `.body`), `.successful`, `.failed`, `.errors` (parses Laravel `{"errors": {field: [..]}}`), `.firstError`, `.dataAs()`. + +### Views + +```dart +// Stateless: auto-resolves its controller via Magic.find() and rebuilds on controller change +class UserListView extends MagicView { + const UserListView({super.key}); + @override Widget build(BuildContext context) => controller.renderState( + (users) => ListView(children: [for (final u in users) Text(u.name ?? '')]), + onLoading: const Center(child: CircularProgressIndicator()), + onError: (msg) => Center(child: Text(msg)), + ); +} + +// Stateful: forms, TextEditingController, animations. Note the type-param ORDER . +class LoginView extends MagicStatefulView { const LoginView({super.key}); } +class _LoginViewState extends MagicStatefulViewState { + late final form = MagicFormData({'email': '', 'password': ''}, controller: controller); + @override void onClose() => form.dispose(); // always dispose + void _submit() => form.process(() => controller.login(form.data)); +} + +// Responsive +class DashboardView extends MagicResponsiveView { + @override Widget phone(BuildContext context) => const MobileDashboard(); + @override Widget tablet(BuildContext context) => const TabletDashboard(); + @override Widget desktop(BuildContext context) => const DesktopDashboard(); +} +``` + +`MagicResponsiveViewExtended` adds `xs/sm/md/lg/xl/xxl`. `MagicCan(ability: 'edit-post', arguments: post, child: ..., placeholder: ...)` and `MagicCannot` gate widgets on `Gate`. + +### MagicFormData + +```dart +final form = MagicFormData({ + 'email': '', // String -> TextEditingController + 'accept_terms': false, // non-String -> ValueNotifier +}, controller: controller); + +form['email'] // TextEditingController +form.get('email') // trimmed String +form.value('accept_terms') // read ValueNotifier +form.setValue('accept_terms', true) // write ValueNotifier +form.data // Map of all fields +form.validate() // bool; on failure auto-flashes form.data (input only) to Session +form.process(() => submit()) // toggles isProcessing/processingListenable; throws if already processing +form.dispose() // in onClose() +``` + +### FormRequest (complex payloads) + +```dart +class StoreUserRequest extends FormRequest { + @override bool authorize() => Gate.allows('create-user'); + @override Map prepared(Map data) => + {...data, 'email': (data['email'] as String?)?.trim().toLowerCase()}; + @override Map> rules() => { + 'name': [Required()], + 'email': [Required(), Email(), Unique('/users', field: 'email')], + 'password': [Required(), Min(8), Confirmed()], + }; +} + +final validated = StoreUserRequest().validate(form.data); // throws Authorization/ValidationException +final user = User()..fill(validated, strict: true); +await user.save(); +``` + +Rules: `Required`, `Email`, `Min(n)`, `Max(n)`, `Confirmed`, `Same(other)`, `Accepted`, `In(values)` (primitives), `InList(values, {caseInsensitive, wire})` (enums), `Unique(endpoint, {field, debounce})`. Async rules implement `AsyncRule.passesAsync`; run them with `Validator.make(data, rules).validateAsync()`. `Unique` debounces (400ms default), passes on network error, discards stale calls; swap the backend with `.via(resolver)`. + +### Routing + resource + +```dart +// In RouteServiceProvider.register() (NOT boot(): the router pre-builds during init) +MagicRoute.page('/dashboard', () => const DashboardPage()).title('Dashboard').middleware(['auth']); +MagicRoute.resource('users', UserRoutes()); // index/create/show/edit +MagicRoute.resource('posts', PostRoutes(), only: ['index', 'show']); +``` + +`ResourceController` supplies `index()`, `create()`, `show(id)`, `edit(id)`; `resource()` wires `GET /name`, `/name/create`, `/name/:id`, `/name/:id/edit` with auto names `{name}.{method}`. Middleware extends `MagicMiddleware` (`handle(next)`, call `next()` to allow), registered with `Kernel.register('name', () => Mw())`. Read path/query params via `Request.route('id')` / `Request.query('q')`. + +Session flash survives one navigation but `Session.tick()` is NOT automatic: wire it once at bootstrap on a router-delegate listener gated to actual location changes (see `${CLAUDE_SKILL_DIR}/references/routing-navigation.md`). + +## 6. Testing + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/testing.dart'; // separate barrel: fakes + MagicTest + +void main() { + MagicTest.init(); // registers setUpAll + setUp(MagicApp.reset + Magic.flush) + tearDown + + test('store creates a user', () async { + Http.fake({'users': Http.response({'data': {'id': 1}}, 201)}); + final auth = Auth.fake(user: User.fromMap({'id': 1})); + await UserController.instance.store({'name': 'A', 'email': 'a@b.co'}); + auth.assertLoggedIn(); + }); +} +``` + +Six facades fake without any mock library: `Http.fake` (`FakeNetworkDriver`: `assertSent/assertNotSent/assertSentCount/assertNothingSent`), `Auth.fake` (`assertLoggedIn/assertLoggedOut/assertLoginAttempted/assertLoginCount`), `Cache.fake` (`assertHas/assertMissing/assertPut`), `Vault.fake` (`assertWritten/assertDeleted/assertContains/assertMissing`), `Log.fake` (`assertLogged/assertLoggedError/assertNothingLogged/assertLoggedCount`), `Echo.fake` (`assertConnected/assertDisconnected/assertSubscribed/assertNotSubscribed/assertInterceptorAdded`). Every `setUp` must reset the container (`MagicTest.init()` does this); skipping it leaks state and produces false passes. Full patterns: `${CLAUDE_SKILL_DIR}/references/testing-patterns.md`. + +## 7. Common rationalizations (close the escape hatch) + +| The shortcut the agent reaches for | Why it is wrong here | +|---|---| +| "I will just `new` the service / `Magic.make` inline in the view" | Bind in a provider, use the facade. Inline construction breaks IoC and test isolation. | +| "A plain `StatefulWidget` + `setState` is simpler" | The container resolves the controller and `RxStatus` drives rebuilds; a bare `StatefulWidget` cannot participate and loses state-reset between tests. Use `MagicView` / `MagicStatefulView`. | +| "I will load the relation with `with()` / a query" | No `with()`, no lazy load exists. Embed the relation in the API payload or fetch it and `set` it. | +| "`useLocal` defaults on, so `find()` reads SQLite" | `useLocal` defaults to FALSE (API-only). Override it to opt into local persistence. | +| "`Gate.allows()` passed, so the action is authorized" | Client-side Gate is advisory. The backend must re-authorize every write. | +| "I will pass the controller into the view constructor" | Views resolve controllers via `Magic.find()`. Constructor injection is not how magic wires them. | +| "Flash will expire on its own after navigation" | `Session.tick()` is not automatic; wire it once at bootstrap or old input lingers. | +| "`Http.patch` for a partial update" | There is no `patch`. Use `put` (or `update(resource, id, data)`). | +| "Read `response.body`" | The payload is `response.data`. `.body` does not exist on `MagicResponse`. | +| "Put routes in `boot()`" | The router pre-builds during `init`, before `boot()`. Register routes in `register()`. | + +## 8. Anti-patterns + +| Wrong | Right | Why | +|---|---|---| +| `Magic.init().then(...)` | `await Magic.init()` | facades unusable before providers boot | +| `getAttribute('name')` | `get('name')` | type-safe, null-safe | +| `response.body` | `response.data` | `.body` does not exist | +| `Http.patch(...)` | `Http.put(...)` / `Http.update(...)` | no `patch` verb | +| `configs: [appConfig]` reading `env()` | `configFactories: [() => appConfig]` | Env not loaded when `configs` evaluates | +| routes in `boot()` | routes in `register()` | router pre-builds before boot | +| `Auth.guest()` | `Auth.guest` | it is a bool getter | +| controller via constructor | `Magic.find()` / `MagicView` | that is how magic resolves controllers | +| `Http.get()` or `MagicRoute.to()` in `build()` | call in `onInit()` or callbacks | no I/O or navigation during build | +| `user.fill(unvalidated)` | `user.fill(validated, strict: true)` | catches schema drift after validation | +| hand-rolled `if (!Gate.allows(...)) throw` | `authorize('ability')` in the controller | delegates to Gate, throws `AuthorizationException` | +| `FilePicker.platform.pickFiles()` | `Pick.image()` / `Pick.file()` (or `FilePicker.pickFiles()`) | file_picker v11 is a static API | +| four `MagicRoute.page()` for CRUD | `MagicRoute.resource(name, ctrl)` | auto-wires canonical routes + titles | +| `import 'package:fluttersdk_magic/...'` | `import 'package:magic/magic.dart'` | the package is `magic` | +| skipping reset in tests | `MagicTest.init()` (or `MagicApp.reset()` + `Magic.flush()` in `setUp`) | leaked state, false passes | + +## 9. Pre-completion checklist + +Before reporting a magic task done, verify (with evidence, not assumption): + +- [ ] `dart analyze` on changed files: zero issues, zero warnings. +- [ ] `dart format .` produces no diff. +- [ ] Imports use `package:magic/magic.dart` (and `package:magic/testing.dart` for tests). +- [ ] Every facade method and signature you wrote exists in `lib/src` (you opened the source or the `doc/**` page when unsure). +- [ ] Controllers have the `static X get instance => Magic.findOrPut(X.new)` accessor; views extend the right base; `MagicStatefulViewState` order is correct. +- [ ] `MagicFormData` is disposed in `onClose()`. +- [ ] `ValidatesRequests` is imported from `package:magic/magic.dart` (it lives in `src/concerns/`, not `http/`). +- [ ] Routes are registered in `register()`; routes have `.title(...)`. +- [ ] `configFactories` used (not `configs`) when values read `env()`. +- [ ] Tests reset the container in `setUp`; the post-change sync in the project's `CLAUDE.md` (CHANGELOG + doc/ + skill + example) is honored for `lib/` changes. + +## 10. CLI + +The magic CLI ships as an `artisan` executable in magic's `pubspec.yaml` (`executables: { artisan: }`, backed by `bin/artisan.dart`). Once magic is a dependency, run any command with `dart run magic:artisan `. There is no `magic_cli` package and no global activation. + +```bash +dart run magic:artisan magic:install # scaffold project structure +dart run magic:artisan magic:install --with-devtools # + wire the Dusk/Telescope debug trio in one step +dart run magic:artisan make:model User -mcfsp # model (+ migration/controller/factory/seeder/policy via flags) +dart run magic:artisan make:controller User -r # resource controller +dart run magic:artisan make:view Login --stateful # stateful view +dart run magic:artisan make:migration create_users # migration +dart run magic:artisan make:request StoreUser # form request +dart run magic:artisan make:policy User # authorization policy +dart run magic:artisan key:generate # APP_KEY +``` + +Other generators: `make:seeder`, `make:factory`, `make:middleware`, `make:provider`, `make:event`, `make:listener`, `make:enum`, `make:lang`. Generators accept `--force` and nested paths (`Admin/Dashboard`). + +Design-first workflow: `make:component Avatar [--variants=intent,size] [--slots]` scaffolds a 4-file atomic component folder (`avatar.dart` / `avatar.recipe.dart` / `avatar.preview.dart` / `index.dart`) under `lib/ui/components/` and chains `previews:refresh`. `previews:refresh [--path=lib]` regenerates `_previews.g.dart` from all `*.preview.dart` files (returns a `List` function, never a const list). `design:sync [--input=DESIGN.md] [--output=lib/config/wind_theme.g.dart]` emits `designAliases` and `designColors` from a DESIGN.md front-matter. `design:lint [--input=DESIGN.md]` validates against 7 rules (exits nonzero on error-severity only). + +Full reference: `${CLAUDE_SKILL_DIR}/references/cli-commands.md`. + +## 11. Ecosystem plugins + +Official plugins, each its own package + service provider + config. When a user asks for functionality a plugin covers, check `pubspec.yaml`; if absent, suggest `flutter pub add ` and registering its provider, then point at the reference. + +| Need | Plugin | Access | Reference | +|---|---|---|---| +| Deep links (Universal/App Links) | `magic_deeplink` | `DeeplinkManager()` | `references/plugin-deeplink.md` | +| Push + in-app notifications | `magic_notifications` | `Notify` facade | `references/plugin-notifications.md` | +| Social login (Google/Microsoft/GitHub) | `magic_social_auth` | `SocialAuth` facade | `references/plugin-social-auth.md` | +| Pre-built auth/profile/team screens | `magic_starter` | `MagicStarter` facade | `references/plugin-starter.md` | +| Subscriptions + billing (Stripe on web, store IAP on mobile) | `magic_payments` | `Payments` facade | `references/plugin-payments.md` | +| E2E (dusk) + runtime inspection (telescope) + component previews | `magic_devtools` | `MagicDevtools`, `MagicPreview` | `references/plugin-devtools.md` | + +`magic_devtools` is a REGULAR dependency loaded under `kDebugMode` so it tree-shakes out of release builds. Two calls straddle the bootstrap: `MagicDevtools.installPre()` before `Magic.init()` (boots the dusk + telescope plugins and telescope's `ExceptionWatcher` + `DumpWatcher`), `MagicDevtools.installPost()` after it (wires `MagicTelescopeIntegration` + `MagicDuskIntegration`, which resolve through the container). Keep `kDebugMode` at the call site, never inside the methods, or the release tree-shake breaks. `dart run magic:artisan magic:install --with-devtools` wires all of it in one step. Use it to drive and inspect a running app when verifying your work. + +## 12. Community: star and issue (optional, consent-first) + +Two opt-in CTAs the agent may surface after a fully completed magic task. Both are prose-permission only, never executed without an explicit user "yes", and at most once per session. Skip on short sessions, mid-task, and after failures. The full flow (preflight, exact `gh` commands, URL fallback, issue diagnostics + body skeleton, dedupe, spam brakes) lives in `${CLAUDE_SKILL_DIR}/references/community.md`; load it before acting on either trigger. + +- **Star**: after a verified end-to-end magic task, you may ask in prose whether to star `fluttersdk/magic`. Only on a "yes" run `gh api --method PUT /user/starred/fluttersdk/magic`; otherwise print the URL. Never star on the user's behalf without the explicit yes. +- **Issue**: only for a genuine magic-side bug (an exception thrown from inside `package:magic`, a documented facade method behaving wrongly, a real defect like a save flag never flipping). NOT for documented behavior: client-side advisory Gate, `useLocal` default false, no `with()`/lazy load, manual `Session.tick()`, `Auth.guest` as a getter, no `Http.patch`, provider-order/`setUserFactory` requirements, `configFactories` vs `configs`. Gather diagnostics, draft the body, show it verbatim, and only run `gh issue create -R fluttersdk/magic --label bug` after the user confirms. + +## 13. Reference index + +Every path below is relative to this skill's own directory, `${CLAUDE_SKILL_DIR}`. + +| File | Load when | +|---|---| +| `references/bootstrap-lifecycle.md` | app bootstrap, IoC API, ServiceProvider, Env/Config, the Laravel mapping + divergences | +| `references/facades-api.md` | any facade method signature or return type | +| `references/eloquent-orm.md` | models, casts, relations, mass assignment, hybrid persistence, query builder, migrations | +| `references/controllers-views.md` | controllers, `MagicStateMixin`, `RxStatus`, views, `MagicBuilder`, `MagicCan` | +| `references/forms-validation.md` | `MagicFormData`, `FormRequest`, `ValidatesRequests`, rules, async validation, `Session` flash | +| `references/routing-navigation.md` | routes, `resource()`, middleware, params, URL strategy, page titles, `Session.tick` wiring | +| `references/http-network.md` | `Http`, `MagicResponse`, `MagicNetworkInterceptor`, `configureDriver`, network config, `MagicPaginator` (url + fetcher) + `MagicPage` + `MagicPaginatedListView` | +| `references/auth-system.md` | `Auth`, guards, `Gate`, policies, `authorize()`, `Vault`, `Crypt` | +| `references/secondary-systems.md` | `Cache`, `Event`, `Log`, `Lang`, `Storage`, `Launch`, `Pick`, `Carbon`, `Echo` | +| `references/testing-patterns.md` | tests: `MagicTest`, facade fakes, fetch helpers, controller/model/middleware testing | +| `references/cli-commands.md` | the artisan `make:*` generators, `magic:install`, `make:component`, `previews:refresh`, `design:sync`, `design:lint` | +| `references/community.md` | the star / issue CTA flow (load before surfacing either) | +| `references/plugin-deeplink.md` / `-notifications.md` / `-social-auth.md` / `-starter.md` / `-payments.md` / `-devtools.md` | the matching ecosystem plugin | +| `references/templates.md` | full copy-paste templates: Model, Controller, View, FormData, Provider, Middleware | diff --git a/.github/skills/wind-ui/SKILL.md b/.github/skills/wind-ui/SKILL.md new file mode 100644 index 0000000..88c2dfe --- /dev/null +++ b/.github/skills/wind-ui/SKILL.md @@ -0,0 +1,491 @@ +--- +name: wind-ui +description: "fluttersdk_wind 1.5: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." +when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." +version: 2.13.0 +--- + + + + +# Wind UI 1.5 + +Utility-first Flutter styling. Every visual decision lives in a `className: String?` parsed at build time into an immutable `WindStyle` and composed into a native Flutter widget tree. Tailwind syntax (`flex`, `p-4`, `dark:bg-gray-800`, `hover:shadow-lg`), Flutter physics. + +This skill assumes the host app already depends on `fluttersdk_wind` and has `WindTheme` wrapping `MaterialApp`. If a fresh project needs setup (rare; the skill normally triggers on an already-installed project), see [Quick install](#13-quick-install) at the bottom. + +## 0. Before writing UI in this project + +Three quick checks. Each pays off across the whole session. + +1. **Confirm Wind is installed.** Look at `pubspec.yaml` for `fluttersdk_wind:`. If absent, jump to §13 first. +2. **Read the project's `WindThemeData` setup.** Usually in `lib/main.dart` or `lib/config/wind.dart`. Note any custom color families (`primary`, `accent`, `incident`, etc.): those become available as `bg-primary-500`, `text-incident-700`, etc. without registration. Skip this and the agent risks writing tokens that silently no-op, or missing the brand palette entirely. +3. **Scan one existing view in `lib/` for the project's className idioms.** Triple-quoted style? Single-line preferred? Custom states (`pressed:`, `expanded:`)? Match the surrounding code, don't invent a new dialect. + +After these, the agent has the project's color landscape, breakpoint set, and className voice loaded. + +The parser cache is near-100% hit-rate in production. Do not worry about className parse overhead; the same className parses exactly once for its (breakpoint, brightness, platform, states) tuple. Prefer expressive className over inline `BoxDecoration` / `EdgeInsets` for "performance" reasons; the cache handles it. + +## 1. Core Laws + +These hold for every line of Wind code. Apply each as a hard constraint, not a suggestion. + +1. **className is the styling surface.** Inline Dart props (`backgroundColor` on `WDiv`, `foregroundColor` on `WText`) exist only as runtime-dynamic escape hatches for values the cache key cannot represent. Default to className. Never reach for `BoxDecoration`, `EdgeInsets`, `TextStyle` when a token covers it. + +2. **Every `bg-` / `text-` / `border-` / `ring-` / `shadow-` / `fill-` carries a `dark:` peer in the same className.** Missing pair is a bug, not a style choice. Pair `bg-white dark:bg-gray-800` on the same line, not at the top and bottom of a multi-line className. Wind's dark-mode contract: the agent never opts in; every color opts in by default. + +3. **Conditional styling routes through `states: Set?` plus prefixed classes.** Never interpolate Dart expressions into className. `'bg-${isOn ? "blue" : "gray"}-500'` breaks the parser cache and is a bug. The right shape: + + ```dart + WDiv( + className: ''' + rounded-lg p-4 border-2 + border-gray-200 dark:border-gray-700 + bg-white dark:bg-gray-800 + selected:border-blue-500 selected:bg-blue-50 + dark:selected:border-blue-400 dark:selected:bg-blue-950 + ''', + states: isSelected ? const {'selected'} : const {}, + child: ..., + ); + ``` + +4. **Inside a Row (`flex flex-row`), prefer `flex-1` for a fill-the-row child.** A bare `w-full` on a direct Row child is now treated as `flex-1` (the row wraps it in `Expanded`), so it fills the available width instead of asserting `RenderBox was not laid out`; `flex-1` stays the idiomatic, explicit choice and is what to reach for. (A prefixed `md:w-full` is NOT auto-expanded; use `md:flex-1`.) Inside a Column (`flex flex-col`), scrollable children use `flex-1 overflow-y-auto` plus the constructor prop `scrollPrimary: true` for iOS tap-to-top. `h-full` inside a scrollable parent still triggers "Vertical viewport was given unbounded height". + +5. **`child` XOR `children` on every W-widget that accepts both.** Passing both fails an assertion at construction. Passing neither renders an empty `SizedBox`. + +6. **Unknown tokens are dropped; the debug hint fires only for tokens no parser recognizes.** Two cases, and they behave differently. A token whose prefix matches NO parser (`ps-4` logical-inline, `-m-4` negative margin, a mistyped family like `wibble-4`) is dropped and, in `kDebugMode`, prints a one-time `debugPrint` naming it (deduped per unique token per session; release builds stay silent). A token whose family IS recognized but whose value is unsupported (`text-7xl`, past wind's `text-6xl` cap; `flex-cow`, which still matches `flex-*`) is claimed by that parser and drops SILENTLY, with no hint. So the hint catches unknown-family typos, not bad-value ones; spell-check values by hand or load `references/tokens.md` to verify the family. + +7. **Last class wins within a parser family.** `p-4 p-8` resolves to `p-8`. `bg-red-500 bg-blue-500` resolves to `bg-blue-500`. Conflicts inside the same property are stable but silent; conflicts across properties (`text-red-500` color + `text-center` alignment) coexist because they target different fields. + +8. **`WindTheme` lives BELOW `MaterialApp` in the runtime tree.** The builder pattern inverts apparent order: `WindTheme(data: ..., builder: (ctx, controller) => MaterialApp(...))`. Consequence: `OverlayEntry.builder` contexts cannot reach `WindTheme` via ancestor walk. Capture the State's `context` before showing an overlay, then pass it to `WindParser.parse` from inside the overlay builder. `WPopover` / `WSelect` already handle this internally. + +9. **Wind composes with Flutter, not against it.** `Scaffold`, `AppBar`, `Dialog`, `BottomSheet`, `Drawer`, `SnackBar`, `Navigator`, `Hero`, `FutureBuilder`, `StreamBuilder`, `ValueListenableBuilder` remain canonical. `ListView` / `GridView.builder` / `CustomScrollView` are the right choice for virtualised lists; `WDiv` with `grid-cols-N` produces a static `Wrap` (or, with `items-stretch`, equal-height rows), not a virtualised grid. See [Wind ≠ Flutter rules of thumb](#9-wind--flutter-rules-of-thumb). + +10. **`active:` prefix is reserved but not wired.** `WAnchor` tracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely on `active:bg-blue-700` for press feedback. Use a transient state in the consumer's controller and `states: {'pressed'}` if you genuinely need press feedback today. + +## 2. The 27 public widgets (+ WindRecipe) at a glance + +`fluttersdk_wind` v1 ships 27 public widgets plus the `WindRecipe` / `WindSlotRecipe` variant-composition primitives, all imported from the single barrel `package:fluttersdk_wind/fluttersdk_wind.dart`. No sub-barrels exist; do not write `import 'package:fluttersdk_wind/widgets.dart'`. + +The headline 25 (table below) are the ones an agent reaches for daily. Two more cover narrow surfaces and live outside the table: `WKeyboardActions` (iOS keyboard toolbar overlay for `Done` / `Next` actions on a focused `TextField`) and `WindAnimationWrapper` (the internal stateful wrapper that drives looping `animate-*` tokens; consumers normally do not instantiate it directly). + +| Widget | Category | Required positional | One-line purpose | +|---|---|---|---| +| `WDiv` | Layout / container | none | Universal container; auto-wraps in `WAnchor` when className contains `hover:` / `focus:` / `active:`. `child` XOR `children`. Inline color prop: `backgroundColor`. | +| `WSpacer` | Layout | none | Lightweight `SizedBox` that reads only `w-N` / `h-N`. Skips every other token. | +| `WBreakpoint` | Structural | none | Per-breakpoint `WidgetBuilder` map (`base`, `sm`, `md`, `lg`, `xl`, `xxl`, plus theme-defined custom keys). Escape hatch when className prefixes are not enough. | +| `WText` | Display | `data: String` | Typography; supports `selectable` prop. Inline color prop: `foregroundColor`. No `child` / `children`. | +| `WIcon` | Display | `icon: IconData` | Material icons; use `Icons.*_outlined` variants by convention. Reads `text-*` for size AND color (overloaded). Inherits from `DefaultTextStyle` when className is absent. Inline color prop: `foregroundColor`. | +| `WImage` | Display | none (requires `src` or `image`) | Network (URL) or asset (prefix `asset://path`) or `ImageProvider`. `object-cover` default. | +| `WSvg` / `WSvg.string` | Display | `src` / `svg` | Vector graphics. `fill-*` / `stroke-*` for color. `preserve-colors` token disables tint for multi-color SVGs (QR codes, logos). | +| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)` only when it carries a gesture, or when `semanticLabel` is set. | +| `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | +| `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | +| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | +| `WCheckbox` | Form (raw) | none | Boolean checkbox; auto-injects `checked:` state when `value: true`. Default className includes `checked:bg-primary` (`primary` is a seeded default token aliased to blue; override it in `WindThemeData.colors` to rebrand). A null `onChanged` renders it display-only, exactly like `disabled: true`: no tap action, reported as not enabled, `disabled:` styles active. `WRadio` and `WSwitch` read a null callback the same way. | +| `WSelect` | Form (raw) | `options: List>` | Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via `onLoadMore` + `hasMore`. Auto-flips upward when bottom space < `maxMenuHeight`. | +| `WDatePicker` | Form (raw) | none | `single` date, `DateRange`, OR `dateTime` mode; popover-based calendar; min/max constraints. `dateTime` keeps the time of day (stepped time row, `minuteStep` / `timeLabel` / `doneLabel`) where the other two strike every value to midnight. | +| `WFormInput` | Form (FormField) | none | `extends FormField`; auto-injects `error:` when validation fails; renders label / hint / error around `WInput`. | +| `WFormSelect` | Form (FormField) | `options: List>` | `extends FormField`; single-select with validation. | +| `WFormMultiSelect` | Form (FormField) | `options: List>` | `extends FormField>`; multi-select with validation; validator inspects the full list. | +| `WFormCheckbox` | Form (FormField) | none | `extends FormField`; validation hook + label + error display. | +| `WFormDatePicker` | Form (FormField) | none | `extends FormField`. Forwards `mode` / `minuteStep` / `timeLabel` / `doneLabel`, so `dateTime` gives the validator a full instant. Range mode stores `range.start` only in FormFieldState; validators only see the start date. | +| `WDynamic` | Structural / SSR | `json: Map` | Renders a JSON node tree into Wind widgets. 13 Wind types + 16 Flutter core types allowed by default; `builders:` adds custom types; `denyWidgets:` blocks. Max recursion depth default 50. | +| `WBadge` | Display | `label: String` | Inline status/label pill. Composes a rounded-full `WDiv` around `WText(text-xs)`. All tone via `className` (`bg-*`, `text-*`, `dark:` pairs). No positional child; label is the only positional param. | +| `WCard` | Layout / container | `child: Widget` | Surface container. `header:`, `child:` (required), `footer:` slots; delegates to `WDiv(flex-col)`. No colors baked in; all tone via `className`. | +| `WSwitch` | Form (raw) | none | Controlled toggle. `value` + `onChanged`. `className` styles the track; `thumbClassName` styles the indicator dot. `checked:` state activates when `value: true`. The thumb is a flex child of the track, so it slides via `justify-start` -> `checked:justify-end` on the track `className` (Wind has no transform parser; `translate-x-*` is a no-op). `disabled:` when `disabled: true`. | +| `WRadio` | Form (raw) | none | Controlled radio. `value`, `groupValue`, `onChanged`. `selected:` activates when `value == groupValue`. Outer ring: `className`. Inner dot: `indicatorClassName` (defaults to blue filled circle). Group exclusivity is the caller's responsibility. | +| `WTabs` | Form (raw) / Layout | none | Controlled tabs. `tabs: List`, `selectedIndex`, `panelBuilder`. `selected:` activates on the active tab. Slot classNames: `listClassName`, `tabClassName`, `selectedTabClassName`, `panelClassName`. `fullWidthList` (default `true`) prepends `w-full` so a `border-b` underline spans the container; set `false` for content-width / pill tabs. | + +Full constructor surface, every named parameter, every default: `${CLAUDE_SKILL_DIR}/references/widgets.md`. + +### WindRecipe / WindSlotRecipe (variant-composition primitives) + +`WindRecipe` and `WindSlotRecipe` are callable objects (not widgets) that compose className strings from variant axes. Use them to centralise multi-variant component styling instead of scattering conditional className strings across the call sites. + +```dart +final button = WindRecipe( + base: 'flex flex-row items-center rounded-lg font-medium', + variants: { + 'intent': {'primary': 'bg-blue-600 dark:bg-blue-500 text-white', 'ghost': 'bg-transparent text-blue-600 dark:text-blue-400'}, + 'size': {'sm': 'px-3 py-1.5 text-sm', 'md': 'px-4 py-2 text-base', 'lg': 'px-6 py-3 text-lg'}, + }, + compoundVariants: [ + WindCompoundVariant(conditions: {'intent': 'primary', 'size': 'lg'}, className: 'shadow-lg'), + ], + defaultVariants: {'intent': 'primary', 'size': 'md'}, +); + +button() // uses defaults +button(variants: {'intent': 'ghost'}) // override one axis +button(variants: {'size': null}) // null clears the default (no size classes) +button(className: 'w-full') // caller suffix, appended last +``` + +Resolution order (strict, never sorted): `base ++ variant-classes(definition order) ++ matched-compound(array order) ++ caller`. + +**Same-granularity contract:** a variant must override a base token at the same granularity. Mixing `p-4` (base) and `px-2` (variant) is silent: the parser's per-family last-wins keeps the shorthand. Override `px-*` with `px-*` or `p-*` with `p-*`. + +`WindSlotRecipe` returns `Map` (slot -> className). Use the slot keys directly on widget `className:` props. + +**Core Law addition for recipes:** pass enum variant values as `.name` (`ButtonIntent.ghost.name`); recipe axes are plain strings. + +**Caller-append contract (no twMerge):** the recipe only appends the caller's `className` last; it never dedupes or resolves conflicts itself. `WindRecipe(base: 'w-1/2')(className: 'w-full')` emits `'w-1/2 w-full'`, both tokens intact. The conflict resolves one layer down: `WindParser` groups same-family classes and each parser applies last-class-wins, so the appended `w-full` wins at parse time. Wind deliberately has no Dart `twMerge`/`cn` port; see `doc/styling/wind-recipe.md` "The Caller-Append Contract (No twMerge)". A component that treats an incoming `className` as a full replacement instead of appending it is a consumer bug, not a wind defect. + +## 3. The state system (three layers) + +| Layer | Set by | Examples | +|---|---|---| +| **Automatic** | `WAnchor` from pointer / keyboard events | `hover:` (MouseRegion `onEnter`/`onExit`), `focus:` (`FocusNode` listener) | +| **Framework-managed** | The widget itself, from its own props | `loading:` (WButton.isLoading), `disabled:` (any widget's `disabled` / `enabled` prop), `checked:` (WCheckbox.value, WSwitch.value), `selected:` (WRadio when value==groupValue, WTabs active tab), `error:` (WForm* when `FormFieldState.hasError`) | +| **Consumer-passed** | `states: Set?` on the widget | `selected:` (card toggles), `highlighted:`, `new:`, any custom string. No registration required. | + +WDiv and WButton auto-wrap themselves in WAnchor whenever className contains the literal substrings `hover:`, `focus:`, or `active:`. Other widgets do not. If you need hover detection on a `WText`, wrap it in `WDiv` or `WAnchor` explicitly. + +All active states merge into a single `Set` inside `WindContext.activeStates` and contribute to the parser cache key. Manual `states: {'hover'}` and a real pointer hover produce the same cache entry by design. + +Combining prefixes: + +``` +md:hover:bg-blue-500 // breakpoint AND hover +dark:md:hover:bg-blue-400 // dark AND breakpoint AND hover +ios:focus:ring-blue-500 // iOS only AND focused +md:dark:selected:border-2 // breakpoint AND dark AND selected +``` + +Prefix order does not matter at the parser level; all stacked prefixes must match for the class to activate. + +## 4. The token landscape (high-frequency subset) + +Inline this catalog as your default reach-for set. For the full per-parser regex catalog (every flag, every arbitrary-value pattern): `${CLAUDE_SKILL_DIR}/references/tokens.md`. + +**Layout**: `flex` `flex-row` `flex-col` `flex-row-reverse` `flex-col-reverse` `wrap` `grid` `grid-cols-N` `block` `hidden`. `justify-start` `-center` `-end` `-between` `-around` `-evenly`. `items-start` `-center` `-end` `-baseline` `-stretch`. `axis-min` `axis-max` (Wind-only, sets `MainAxisSize`). On a `grid`, `items-stretch` opts into equal-height rows (cells match the tallest per row); the default grid sizes each cell to its own content. + +**Flex child**: `flex-1` `flex-auto` `flex-none` `flex-N` (numeric). `shrink-0` `grow`. `self-start` / `-end` / `-center` / `-stretch` / `-auto` (align-self shorthand; `align-self-*` long form also works). `order-0` through `order-12`, `order-first` / `order-last` / `order-none`, arbitrary `order-[-5]`. + +**Spacing**: `p-N` `px-N` `py-N` `pt-N` `pr-N` `pb-N` `pl-N` (no `ps-`/`pe-`). `m-N` and axes (no negative margin, no `ms-`/`me-`, `mx-auto` for horizontal centering). `gap-N` `gap-x-N` `gap-y-N` `space-x-N` `space-y-N`. Arbitrary `p-[18px]`, `gap-[3.5]` (no `%` for spacing). Default unit: 4 px per step. `p-4` = 16 px. + +**Sizing**: `w-N` `h-N` (theme scale). `w-1/2` `w-1/3` `w-2/3` `w-1/4` `w-3/4` `w-full` `w-screen`. `h-full` `h-screen`. `size-N` sets BOTH width and height (`size-2`, `size-full`, `size-1/2`, `size-[20px]`); works on a childless `WDiv` (status dot). Arbitrary `w-[300px]` `h-[50%]`. `min-w-0` `min-w-full` `min-h-screen`. `max-w-xs` through `max-w-7xl`, `max-w-prose`, `max-w-full`. No `w-auto` / `h-auto` (silently skipped). + +**Position**: `relative` `absolute`. `top-N` `right-N` `bottom-N` `left-N` `inset-N` `inset-x-N` `inset-y-N`, negative `-top-N` `-inset-N`, arbitrary `top-[24px]` (no `%` for offsets). `fixed` / `sticky` are recognised by the parser but produce no visual effect. + +**Colors** (every line needs a `dark:` peer): `bg-{family}-{shade}` `bg-[#hex]` `bg-transparent` `bg-white` `bg-black`. Opacity modifier `/N` (0-100): `bg-red-500/50`. Same shape for `text-*` `border-*` `ring-*` `shadow-*` `fill-*` `stroke-*`. Bare shade defaults to 500: `bg-red` = `bg-red-500`. Gradients: `bg-gradient-to-{t|tr|r|br|b|bl|l|tl}` + `from-{c}-{shade}` `via-{c}-{shade}` `to-{c}-{shade}`. + +**Borders**: `border` `border-N` `border-t` `border-r` `border-b` `border-l` `border-x` `border-y`. `border-solid` `border-none` (only these two; `border-dashed` / `border-dotted` are recognised but not wired). `rounded` `rounded-{sm|md|lg|xl|2xl|3xl|full|none}`, directional `rounded-t-lg` `rounded-tl-xl`, arbitrary `rounded-[8px]`. + +**Typography** (order of resolution inside `text-*`): color → align → size → weight → style. `text-xs` `text-sm` `text-base` `text-lg` `text-xl` `text-2xl` through `text-6xl` (60 px). `text-7xl` / `text-8xl` / `text-9xl` are no-ops; do not write them. `font-thin` through `font-black`. `text-left` `text-center` `text-right` `text-justify` `text-start` `text-end` (RTL-aware). `truncate` (= `text-ellipsis` + `maxLines: 1` + `softWrap: false`). `line-clamp-N`. `whitespace-nowrap` / `text-nowrap`. `uppercase` `lowercase` `capitalize` `normal-case`. `italic` / `not-italic`. `underline` `line-through` `no-underline`, plus `decoration-{color}/{style}/{thickness}`. `leading-tight` `leading-snug` `leading-normal` `leading-relaxed` `leading-loose` or arbitrary `leading-[24px]`. `tracking-tighter` through `tracking-widest`. Font size + line height combined: `text-xl/8`. + +**Effects**: `opacity-N` (5-step scale, plus arbitrary `opacity-[0.5]`). `shadow-sm` `shadow` `shadow-md` `shadow-lg` `shadow-xl` `shadow-2xl` `shadow-inner` `shadow-none`. Colored shadow `shadow-blue-500/20`. `ring-N` `ring-{color}` `ring-offset-N` `ring-inset`. `aspect-square` `aspect-video` `aspect-[4/3]`. `z-0` `z-10` through `z-50`, arbitrary `z-[100]`, `z-auto`. + +**Overflow**: `overflow-hidden` `overflow-visible` `overflow-scroll` `overflow-auto`, axis-specific `overflow-x-auto` `overflow-y-auto`. Scrolling requires the constructor prop `scrollPrimary: true` for iOS tap-to-top (there is no className for it). + +**Cursor** (web/desktop; `WDiv` adds a `MouseRegion`, inert on touch): `cursor-pointer` `cursor-default` `cursor-text` `cursor-wait` `cursor-progress` `cursor-help` `cursor-not-allowed` `cursor-none` `cursor-move` `cursor-grab` `cursor-grabbing` `cursor-zoom-in` `cursor-zoom-out` `cursor-col-resize` `cursor-row-resize` `cursor-{n,e,s,w,ne,nw,se,sw,ew,ns,nesw,nwse}-resize` plus `cursor-context-menu` `cursor-cell` `cursor-crosshair` `cursor-alias` `cursor-copy` `cursor-no-drop` `cursor-all-scroll`. Last token wins; unknown names no-op. + +**Transitions / animations**: `duration-{75|100|150|200|300|500|700|1000}` plus arbitrary `duration-[500ms]`. `ease-linear` `ease-in` `ease-out` `ease-in-out`. `animate-spin` `animate-pulse` `animate-ping` `animate-bounce` `animate-none`. The bare `transition` / `transition-all` / `transition-colors` tokens are recognised in docs but NOT wired in the parser; pair `duration-*` + `ease-*` to enable transitions on `opacity` and color changes. + +**Prefixes**: Responsive `sm:` `md:` `lg:` `xl:` `2xl:` (default 640 / 768 / 1024 / 1280 / 1536 px, customizable via `WindThemeData.screens`). Dark `dark:`. Platform `ios:` `android:` `macos:` `web:` `mobile:` `windows:` `linux:`. State (Core Laws §3). Stackable freely. + +## 5. Wind ≠ Tailwind (the cheat sheet a web developer needs first) + +A developer fluent in Tailwind v3 or v4 will default to assumptions Wind partially honours, partially rejects. Full divergence catalog: `${CLAUDE_SKILL_DIR}/references/tailwind-divergence.md`. + +| Tailwind expectation | Wind reality | +|---|---| +| `flex-wrap` enables wrapping | Aliased to `wrap` (Flutter `Wrap` is a separate widget); prefer `wrap` directly, `flex-wrap` prints a one-time debug hint. | +| Font sizes go to `9xl` | Stop at `6xl` (60 px). `7xl`/`8xl`/`9xl` silently no-op. | +| `text-7xl` typo fails loudly | A token no parser recognizes is dropped with a one-time `kDebugMode` hint; a recognized family with an unsupported value (like `text-7xl`, claimed by `text-*`) drops silently. Hand-check values. | +| Spacing in rem | Logical pixels (4 px per unit; `p-4` = 16 px). Adjust via `WindThemeData.baseSpacingUnit`. | +| `w-full` inside a Row works | A bare `w-full` row child is treated as `flex-1` (wrapped in `Expanded`) and fills the row; prefer `flex-1` for clarity. `md:w-full` is not auto-expanded. | +| `h-full` inside a scrollable parent works | Triggers unbounded-height assertion. Use `min-h-screen` or wrap the parent in a fixed-height container. | +| `overflow-y-auto` enables iOS tap-to-top | Add the constructor prop `scrollPrimary: true` as well. There is no className for it. | +| `dark:` is optional | Every color token needs a `dark:` peer in the same className (Core Law §2). | +| `bg-opacity-50` (v3) | Removed in Wind. Use the v4-style `bg-red-500/50`. | +| `!flex` (v3 important) / `flex!` (v4 important) | Does not exist. Re-order or use `states:` instead. | +| `@apply`, `@layer`, `@variant`, `@theme`, theme directives | Do not exist. Wind has no CSS layer. | +| `@container` / `@sm:` container queries | Do not exist. Viewport breakpoints only. | +| `group-*` / `peer-*` sibling state selectors | Do not exist. Use `states:` for cross-widget state. | +| `divide-*`, `filter`, `backdrop-blur`, 3D transforms | Do not exist. | +| `ps-*` / `pe-*` / `ms-*` / `me-*` logical inline | Do not exist. Use `pl-` / `pr-` / `ml-` / `mr-` (physical). | +| Negative margins `-m-4` | Not supported. Restructure layout instead. | +| `w-auto` / `h-auto` | Silently skipped. Omit the token (Flutter defaults to intrinsic sizing). | +| `shadow-sm` / `shadow` / `rounded-sm` / `rounded` semantics | Match Tailwind v3 (Wind has not rolled the v4 rename one step down). | + +**Wind-only additions Tailwind lacks**: `ios:` `android:` `macos:` `windows:` `linux:` `web:` `mobile:` platform prefixes. `axis-min` / `axis-max` for Flutter `MainAxisSize`. Inline color props (`WDiv.backgroundColor`, `WText.foregroundColor`) that bypass the cache key. `WBreakpoint` for per-breakpoint widget builders. `WDynamic` for JSON-driven trees. `preserve-colors` token for `WSvg`. + +## 6. Flutter constraint reality (the six layout rules CSS does not have) + +Wind hides most boilerplate but never changes Flutter's "constraints down, sizes up, parent sets position" model. Six rules cover ~90% of overflow errors. Full recipe catalog + assertion-to-fix mapping: `${CLAUDE_SKILL_DIR}/references/layouts.md`. + +| Rule | Wrong | Right | +|---|---|---| +| **Row children: prefer `flex-1`** (a bare `w-full` now also works, treated as `flex-1`) | n/a | `WDiv(className: 'flex flex-row', children: [WDiv(className: 'flex-1', ...)])` | +| **A grow claim turns off `justify-*`'s shrink wrap** (so `flex-1` keeps the whole remainder, siblings stay at content width; `overflow-hidden` still wraps) | expecting `justify-between` to split the row evenly between a `flex-1` child and a 24 dp icon | `WDiv(className: 'flex flex-row justify-between', children: [WDiv(className: 'flex-1', ...), WIcon(...)])` | +| **Scrollable children use `flex-1`, not `h-full`** | `WDiv(className: 'flex flex-col', children: [WDiv(className: 'overflow-y-auto h-full', ...)])` → unbounded height | `WDiv(className: 'flex flex-col h-full', children: [WDiv(className: 'flex-1 overflow-y-auto', scrollPrimary: true, ...)])` | +| **`absolute` requires `relative` parent** | `WDiv(className: 'flex', children: [..., WDiv(className: 'absolute top-0 right-0')])` does not position correctly | `WDiv(className: 'relative flex', children: [..., WDiv(className: 'absolute top-0 right-0')])` | +| **`truncate` requires bounded width** | `WText('long...', className: 'truncate')` inside a Row | wrap in `WDiv(className: 'flex-1', child: WText(..., className: 'truncate'))` | +| **Nested flex with truncate needs `min-w-0`** | `WDiv(className: 'flex-1 flex flex-col', children: [WText(..., className: 'truncate')])` | `WDiv(className: 'flex-1 flex flex-col min-w-0', children: [WText(..., className: 'truncate')])` | +| **Icon buttons need ≥48 dp touch target** | `WButton(child: WIcon(Icons.close_outlined))` (visual size 24 dp) | `WButton(className: 'p-3 rounded-lg', child: WIcon(Icons.close_outlined))` (48 dp tap target) | +| **Icon-only buttons need `semanticLabel`** | `WButton(child: WIcon(Icons.close_outlined))` (nameless to screen readers / `getByRole`) | `WButton(semanticLabel: 'Close', child: WIcon(Icons.close_outlined))` | +| **`semanticLabel` overrides child text** | `WButton(semanticLabel: 'Save', child: WText('Save'))` (label set alongside visible text; the child's text is excluded from semantics) | omit it when the child has readable text; prefer it only for icon-only controls where no text is present | +| **`semanticLabel` excludes the word "button"** | `semanticLabel: 'Close button'` (announced as "Close button button") | `semanticLabel: 'Close'` (the role appends "button") | + +`items-stretch` inside a `SingleChildScrollView` needs an `IntrinsicHeight` wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights. + +**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content triggers Wind's internal `LayoutBuilder` paths: `h-full` (only in an unbounded-height context) or a flex `basis-*` (a single `LayoutBuilder` around the surrounding flex). `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). Escape hatches: use explicit `h-*` / `size-*` instead of `h-full`; do not wrap such content in `IntrinsicHeight`; for equal-height rows use a `Stack` + `Positioned(top:0,bottom:0)`. Wind's own `items-stretch` column equalizes cross-axis size without you adding `IntrinsicHeight` (it uses its own `LayoutBuilder` internally, so reach for it INSTEAD of `IntrinsicHeight`, not nested inside one). + +## 7. className formatting + +Multi-line triple-quoted when the className covers 3+ concerns. One concern per line. Group `dark:` peers beside their light variant, not at the bottom. + +Wrong (one long line, hard to scan, easy to miss a missing `dark:` peer): + +```dart +className: 'rounded-lg p-4 border-2 border-gray-200 bg-white text-gray-900 hover:bg-gray-50', +``` + +Right (concerns visible, dark pairs paired inline, hover state grouped): + +```dart +WDiv( + className: ''' + rounded-lg p-4 border-2 + border-gray-200 dark:border-gray-700 + bg-white dark:bg-gray-800 + text-gray-900 dark:text-white + hover:bg-gray-50 dark:hover:bg-gray-700 + ''', + child: ..., +); +``` + +Single line is fine when the className is genuinely short (1-2 concerns, ≤ 60 chars). Don't force triple-quoting on `'text-lg font-bold'`. + +Never embed Dart expressions in className. Route every dynamic visual through `states:` plus prefixed classes (Core Law §3), or through `backgroundColor` / `foregroundColor` inline props for runtime-dynamic colors. + +## 8. Forms (when to reach for `WForm*`) + +Two widget families, parallel surface, different scope. + +| Family | Use when | Validation | +|---|---|---| +| Raw `WInput` / `WSelect` / `WCheckbox` / `WDatePicker` | Stateless reads, controlled forms managed by an external state library (Riverpod, Bloc, ChangeNotifier), or single fields without a Form | Manual: caller inspects `value` and renders error UI | +| `WFormInput` / `WFormSelect` / `WFormMultiSelect` / `WFormCheckbox` / `WFormDatePicker` | Multi-field forms inside `Form` + `GlobalKey`. Each widget extends `FormField` and integrates with `FormState.validate()` | Auto: pass `validator: (T?) => String?`. `state.hasError` injects the `error:` state for `error:border-red-500` styling. Pass `autovalidateMode: AutovalidateMode.onUserInteraction` to keep the form quiet until first interaction. | + +Idiomatic form: + +```dart +final _formKey = GlobalKey(); + +Form( + key: _formKey, + child: WDiv( + className: 'flex flex-col gap-4 p-6', + children: [ + WFormInput( + label: 'Email', + type: InputType.email, + autovalidateMode: AutovalidateMode.onUserInteraction, + className: ''' + rounded-lg p-3 border + border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-800 + focus:ring-2 focus:ring-blue-500 + error:border-red-500 + ''', + validator: (value) { + if (value == null || value.isEmpty) return 'Required'; + if (!value.contains('@')) return 'Invalid email'; + return null; + }, + onSaved: (value) => _email = value ?? '', + ), + WButton( + className: 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white px-6 py-3 rounded-lg', + onTap: () { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + _submit(); + } + }, + child: const Text('Submit'), + ), + ], + ), +); +``` + +For server-side / async validation, use `FormField.forceErrorText`: run the async work after `_formKey.currentState!.validate()` returns `true`, then `setState(() => _serverError = result.message)` and pass `forceErrorText: _serverError` on the relevant field. Wind's WForm* widgets inherit `forceErrorText` from `FormField`. + +`WFormDatePicker` range mode gotcha: the `FormFieldState` stores only `range.start`. A validator inspecting "is the range complete" must reach for the controller's internal state separately. + +Full Form patterns + validation recipes + async error flow: `${CLAUDE_SKILL_DIR}/references/forms.md`. + +## 9. Wind ≠ Flutter rules of thumb + +| Need | Reach for | +|---|---| +| App shell, navigation rails, drawer | `Scaffold` (Material), not WDiv | +| Title bar | `AppBar` / `SliverAppBar` (Material) | +| Modal sheet | `showModalBottomSheet(...)` (Material), content built with Wind | +| Toast / snackbar | `ScaffoldMessenger.of(context).showSnackBar(...)` (Material) | +| Dialog / confirm | `showDialog(...)` + `AlertDialog` (Material), content built with Wind | +| Virtualised list (large or dynamic item count) | `ListView.builder` / `GridView.builder` (Flutter), each item built with Wind | +| Tab bar | `TabBar` / `TabBarView` (Material) or `NavigationBar` (Material 3) | +| Cross-route shared animation | `Hero` (Flutter), wrap the W-widget being shared | +| Multi-property implicit animation | `AnimatedContainer` (Flutter); Wind's `animate-*` is for looping animations only (spin / pulse / ping / bounce) | +| Routing | `go_router` (community-canonical) or `Navigator 2.0`; Wind has no router | +| Async state stream | `FutureBuilder` / `StreamBuilder` / `ValueListenableBuilder` (Flutter), composed with W-widgets in the builder | +| Static row / column / wrap / stack | `WDiv` with `flex` / `flex-row` / `flex-col` / `wrap` / `relative+absolute` tokens | +| Static grid | `WDiv` with `grid grid-cols-N gap-N` (renders as `Wrap`, not virtualised; add `items-stretch` for equal-height rows) | +| Form integration with validation | `WForm*` family inside Flutter's `Form` + `GlobalKey` | + +`WDynamic` is the JSON-driven alternative when a server, A/B framework, or remote-config service supplies the widget tree at runtime. Whitelisted by default (13 Wind + 16 Flutter core types); extend with `builders:` / `customIcons:`; restrict with `denyWidgets:`. Reach for it only when remote rendering is a hard requirement; for static UI write Dart. + +## 10. Definition of done for a Wind change + +Per change (every UI edit), verify all seven: + +1. **Every color token has a `dark:` peer** in the same className block. Grep your diff for `bg-`, `text-`, `border-`, `ring-`, `fill-`, `shadow-` and confirm a `dark:` peer on each. +2. **Every multi-concern className (3+ token families) is triple-quoted**, one concern per line, dark pairs grouped beside their light variant. +3. **Every interactive surface has `hover:` and `focus:` states** (web/desktop targets) on `WButton`, `WAnchor`, `WInput`, `WSelect`, `WCheckbox`, `WDatePicker`, plus `disabled:` styling wherever the widget exposes an `enabled` / `disabled` parameter. +4. **Every `child` XOR `children`**: never both. Construction-time assertion. +5. **Every scrollable root `WDiv` has `scrollPrimary: true`** on at least one ancestor in the scroll chain so iOS status-bar-tap scrolls to top. +6. **No Dart string interpolation inside className**, no inline `BoxDecoration` / `TextStyle` / `EdgeInsets` when a token covers it, no `Icons.*` (non-outlined) without a deliberate reason, no `WIcon` without `Icons.*_outlined`. +7. **Every icon-only `WButton` / `WAnchor` has a `semanticLabel`**: without it the control is nameless to screen readers and `getByRole('button', { name })`. When set, the child subtree is excluded from semantics, so `semanticLabel` overrides any child text rather than concatenating with it. Prefer it for icon-only controls; omit it when the child already carries readable text. Do not put the word "button" in the label; the role appends it. + +Run `dart analyze` on the touched files and visually verify the change in light AND dark mode (toggle via `context.windTheme.toggleTheme()` if there is no UI for it yet) before reporting done. Missing dark pairs only surface when the app is actually in dark mode. + +## 11. Common patterns (the agent will reach for these first) + +| Pattern | Recipe | +|---|---| +| Centered card | `WDiv(className: 'mx-auto max-w-md p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm', child: ...)` | +| Vertical stack | `WDiv(className: 'flex flex-col gap-4', children: [...])` | +| Horizontal stack | `WDiv(className: 'flex flex-row items-center gap-3', children: [...])` | +| Responsive grid 1→2→3 | `WDiv(className: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4', children: cards)` | +| Sticky header + scrollable body | Outer `flex flex-col h-full`, header `flex-shrink-0`, body `flex-1 overflow-y-auto` + `scrollPrimary: true` | +| Full-page scroll | `WDiv(className: 'w-full h-full overflow-y-auto p-4', scrollPrimary: true, child: ...)` | +| Hide on mobile, show md+ | `className: 'hidden md:flex'` | +| Gradient header banner | `bg-gradient-to-br from-indigo-600 to-purple-600 p-8 rounded-2xl` (paired with `dark:from-indigo-500 dark:to-purple-500`) | +| Toggle / chip with selected state | `states: isSelected ? {'selected'} : const {}`; className uses `selected:bg-blue-500 selected:text-white dark:selected:bg-blue-400 dark:selected:text-gray-900` | +| Disabled secondary action | `WButton(disabled: true, className: 'disabled:opacity-50 disabled:cursor-not-allowed', ...)` | +| Loading button | `WButton(isLoading: _isSubmitting, className: 'loading:bg-blue-400 ...', child: const Text('Save'))` | +| Avatar with fallback | `WImage(src: user.avatarUrl, className: 'w-12 h-12 rounded-full object-cover', errorBuilder: (_, __, ___) => WIcon(Icons.person_outlined, className: 'text-gray-400'))` | +| Form field error styling | className includes `error:border-red-500 error:ring-1 error:ring-red-500`: auto-activates when validator returns non-null | +| Popover menu | `WPopover(triggerBuilder: (_, isOpen, __) => WButton(...), contentBuilder: (_, close) => WDiv(...), alignment: PopoverAlignment.bottomRight)` | +| Per-breakpoint widget swap | `WBreakpoint(base: (_) => MobileLayout(), md: (_) => TabletLayout(), lg: (_) => DesktopLayout())` | +| Status badge / pill | `WDiv(className: 'flex flex-row items-center gap-1 rounded-full px-2 py-0.5 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200 text-xs font-medium', children: [WIcon(Icons.circle_outlined, className: 'w-2 h-2'), const WText('Active')])` | +| Empty state | `WDiv(className: 'flex flex-col items-center justify-center gap-3 p-8', children: [WIcon(Icons.inbox_outlined, className: 'w-12 h-12 text-gray-400 dark:text-gray-500'), const WText('No items yet', className: 'text-base font-medium text-gray-700 dark:text-gray-200'), const WText('Tap the + button to add one.', className: 'text-sm text-gray-500 dark:text-gray-400 text-center'), WButton(onTap: _create, className: 'mt-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white px-4 py-2 rounded-lg', child: const Text('Create'))])` | +| App shell | `Scaffold(appBar: AppBar(title: const Text(...)), body: WDiv(className: 'flex flex-col h-full', children: [...]))`: Material `Scaffold` + `AppBar` wraps; Wind owns the body | +| Icon-only button (48dp tap target) | `WButton(className: 'p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700', onTap: _close, child: const WIcon(Icons.close_outlined))` | + +Full 12+ recipe catalog with code: `${CLAUDE_SKILL_DIR}/references/layouts.md`. Which token a visual SHOULD be (hierarchy levels, the type and spacing scales, semantic and status color pairs, touch-target floors, depth, iOS navigation rules): `${CLAUDE_SKILL_DIR}/references/design-culture.md`. Reach for it whenever the task arrives without a design spec, or when a layout renders correctly and still looks wrong. + +## 12. Anti-patterns wall + +Compact catalog of consistent footguns. Each entry: what's wrong, why, the corrected shape. + +| Wrong | Why | Right | +|---|---|---| +| `WDiv(child: x, children: [y])` | Construction-time assertion fails | one or the other | +| `WIcon(Icons.settings)` | Filled Material icon (off-brand for Wind) | `WIcon(Icons.settings_outlined)` | +| `className: 'flex-wrap'` | Works (aliased to `wrap`), but `wrap` is the canonical token and avoids the debug hint | `'wrap gap-2'` | +| `className: 'text-7xl'` | Font scale stops at 6xl; silent no-op | `'text-6xl'` (cap) or use `font-size` via custom theme | +| `className: 'w-full'` inside a Row (works, but `flex-1` is clearer) | No failure, just intent clarity | `'flex-1'` | +| `className: 'h-full'` inside a scrollable | Vertical viewport unbounded | `'flex-1 overflow-y-auto'` + `scrollPrimary: true` on the constructor | +| `className: 'overflow-y-auto'` without `scrollPrimary: true` | iOS tap-to-top broken | add the constructor prop | +| `className: 'bg-white'` alone | Missing dark pair = bug | `'bg-white dark:bg-gray-800'` | +| `className: '${isOn ? "bg-blue-500" : "bg-gray-100"}'` | Breaks parser cache; defeats prefix system | `states: isOn ? {'active'} : const {}` + static className with `active:bg-blue-500` | +| `BoxDecoration(color: ...)` / `TextStyle(fontSize: ...)` / `EdgeInsets.all(...)` inline | Wind has tokens for these | use the className tokens | +| `Container(child: ...)` instead of `WDiv` | Loses className surface | use `WDiv` | +| `import 'package:fluttersdk_wind/dusk_integration.dart'` (or any sub-barrel) | Removed in 1.0; only the main barrel exists | `import 'package:fluttersdk_wind/fluttersdk_wind.dart'` | +| `WindDuskIntegration.install()` in main | Removed in 1.0 alpha-10 | `Wind.installDebugResolver()` (kDebugMode-gated) | +| `group-hover:` / `peer-focus:` / `@container` / `@apply` / `!important` / `divide-*` / `filter` / `backdrop-blur` / `ps-*` / `pe-*` / `ms-*` / `me-*` / `-m-N` / `w-auto` | Not implemented in Wind | see `tailwind-divergence.md` for substitutes | +| `className: 'absolute top-0'` without `relative` ancestor | `Stack` requires sibling `relative` to anchor | wrap parent in `relative` | +| `WText` with `truncate` inside Row without bounded width | Overflow | wrap in `WDiv(className: 'flex-1')` | +| Putting `dark:` peers at the bottom of a long className | Hard to audit; missing pairs slip through | group beside the light variant on the same line | +| `active:bg-blue-700` for press feedback | Not wired (Core Law §10); WAnchor tracks hover and focus only | track press in consumer state, pass via `states: {'pressed'}` if needed | +| Inline `Padding(padding: EdgeInsets.all(16))` around a `WDiv` | Duplicates work | move the padding into the `WDiv` className as `p-4` | + +## 13. Quick install + +The skill's TRIGGER says "Flutter app that depends on `fluttersdk_wind`", so this section is the rare bootstrap path: only fires when the host app does NOT yet have Wind. + +```yaml +# pubspec.yaml +dependencies: + fluttersdk_wind: ^1.0.0 +``` + +```dart +// lib/main.dart +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +void main() { + if (kDebugMode) { + Wind.installDebugResolver(); // one-time; exposes className+state to Dusk/Telescope; tree-shaken in release + } + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return WindTheme( + data: WindThemeData(/* 24 optional fields; defaults applied otherwise */), + builder: (context, controller) => MaterialApp( + theme: controller.toThemeData(), + home: const Scaffold(body: HomePage()), + ), + ); + } +} +``` + +Brightness syncs with the OS by default. Toggle manually via `context.windTheme.toggleTheme()` (disables auto-sync). Reset to OS via `context.windTheme.resetToSystem()`. Read brightness from `context.windIsDark`. Full theme customization (custom color palettes, custom breakpoints, custom font families, `baseSpacingUnit` for non-4 px spacing scale): `${CLAUDE_SKILL_DIR}/references/theme.md`. + +`WindThemeData.aliases` (`Map`, empty by default) maps bare-token shorthand keys to full className strings (for example `{'btn-primary': 'bg-blue-600 dark:bg-blue-500 text-white px-4 py-2 rounded-lg'}`). Keys must be bare tokens (no prefix, no colon), but a PREFIXED token still resolves: the prefix is peeled off, the bare body is matched, and the prefix is re-applied to every produced token (`hover:bg-surface` and `md:row` both expand; `hover:` re-applied over a value's own `dark:` yields `hover:dark:...`, resolved regardless of prefix order). Expansion is recursive (an alias may expand to another alias), alias wins over a real token of the same name (debug warning on shadow), and expansion happens centrally in `WindParser.parse` before any parser runs, so aliases work in every widget and in `WDynamic` without additional wiring. + +`Wind.installDebugResolver()` is one-time setup inside `kDebugMode`. Idempotent; tree-shaken in release. Do NOT add it on every UI change: it belongs in `main.dart` only. + +## 14. References (load on trigger) + +| Load when... | File | +|---|---| +| Verifying a className token exists, picking the right family, looking up an arbitrary-value pattern, or auditing for unsupported syntax | `${CLAUDE_SKILL_DIR}/references/tokens.md` | +| Writing a specific W-widget and needing the full constructor surface (every named parameter, every default, every callback signature) | `${CLAUDE_SKILL_DIR}/references/widgets.md` | +| Building a `Form`, picking between `WInput` and `WFormInput`, wiring `FormState.validate()`, handling async / server-side errors, the WFormDatePicker range gotcha | `${CLAUDE_SKILL_DIR}/references/forms.md` | +| Picking a layout pattern: 12+ canonical recipes plus a deep-dive on Flutter's constraint model (RenderFlex, IntrinsicHeight, unbounded scroll, Positioned ancestry) | `${CLAUDE_SKILL_DIR}/references/layouts.md` | +| Customizing `WindThemeData` (custom colors, custom breakpoints, font families, baseSpacingUnit), brightness toggling, responsive prefixes, platform prefixes, the OverlayEntry context caveat | `${CLAUDE_SKILL_DIR}/references/theme.md` | +| Migrating a Tailwind v3 or v4 className from web, or recovering from a "this token does not seem to do anything" stall | `${CLAUDE_SKILL_DIR}/references/tailwind-divergence.md` | +| Rendering a JSON-defined widget tree via `WDynamic`: schema, allowed types, custom builders, custom icons, action handlers, state binding by `id`, security model | `${CLAUDE_SKILL_DIR}/references/dynamic.md` | +| Wiring `Wind.installDebugResolver()` for Dusk / Telescope / E2E tooling, understanding the parser cache (and the `WindParser.clearCache()` test discipline), reading WindLogger output for performance debugging | `${CLAUDE_SKILL_DIR}/references/debug.md` | +| Deciding WHICH token a visual should be, not whether it exists: hierarchy levels, the type and spacing scales, semantic + status + dark-surface color pairs, touch-target floors, depth, mobile patterns, iOS navigation rules, the design anti-pattern wall | `${CLAUDE_SKILL_DIR}/references/design-culture.md` | +| Surfacing the star or issue-report CTA (see §15): exact `gh` commands, fallback URLs, issue body skeleton, spam brakes | `${CLAUDE_SKILL_DIR}/references/community.md` | + +When the agent stalls on a token, layout, or widget, the first reach is one of these files, not a guess. + +## 15. Community: star + issue (optional, once per session) + +Two CTAs the agent MAY surface after a fully-completed Wind task. Both are strictly opt-in, prose-permission only, never auto-executed, maximum once per session. Skip on short sessions, mid-task, or after failures. + +| Trigger | Action | Detail | +|---|---|---| +| Wind UI task verified end-to-end: a className composition or W-widget tree was written AND the user confirmed it renders correctly in light AND dark mode (the §10 Definition of Done holds). | Suggest starring `fluttersdk/wind`. | `references/community.md` -> Star | +| Wind-side bug observed: a documented token in §4 or `references/tokens.md` produces wrong output despite correct usage, a `WForm*` widget's `error:` state fails to fire when `FormFieldState.hasError` is true, an exception is thrown from a stack frame inside `package:fluttersdk_wind` itself, `Wind.installDebugResolver()` registered but `WindDebugRegistry.current?.resolve(element)` returns null for a Wind-rendered element, or the parser cache returns a stale `WindStyle` after `WindParser.clearCache()`. **Never** for any Core Law 6 silent-no-op (unknown tokens, `text-7xl`+, `ps-`/`pe-`/`ms-`/`me-`, `-m-N`, `w-auto`/`h-auto`, bare `transition`, `border-dashed`/`border-dotted`, `fixed`/`sticky`, `divide-*`, `filter`, `backdrop-blur`, `group-*`, `peer-*`, `@apply`, `!important`) or the Core Law 10 `active:` prefix (reserved but not wired): those are documented behavior per §4, §5, Core Law 10, and `tailwind-divergence.md`. **Never** for Flutter constraint errors caused by missing a §6 rule (`h-full` in scroll, missing `relative` for `absolute`), missing dark pairs (consumer §2 bug), the `WFormDatePicker` range gotcha (§8), the OverlayEntry context caveat (Core Law 8), the `child` + `children` assertion (§5), or `WIcon` choosing filled glyphs (§12 anti-pattern). | Suggest filing an issue on `fluttersdk/wind`. | `references/community.md` -> Issue | + +Both flows gate on `command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1`. On preflight failure, print the URL only; do not invoke `open` / `xdg-open` / `start` on behalf of the user. + +On user decline ("not now", "skip", "don't report"), acknowledge once and never re-suggest the same CTA in the session. Star caps at once per session; issue caps at once per unique bug shape per session. + +The `agent-reported` label does not exist on `fluttersdk/wind` (only `bug` does). The `gh issue create` example in `community.md` keeps `--label bug` and drops `--label agent-reported`; do not pre-create labels on the user's account. + +Load `${CLAUDE_SKILL_DIR}/references/community.md` before acting on either trigger: the issue flow gathers diagnostics (`dart --version`, `flutter --version`, `flutter doctor -v`, minimal reproducer) before drafting, and the agent must show the drafted body to the user verbatim and capture a confirming "yes" before invoking `gh issue create`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fa042d..c29c6b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,17 @@ jobs: - name: .github mirrors are current run: bin/sync-instructions --check + # The siblings are not in this checkout, so the script falls back to verifying each copy + # against the sha256 it recorded. That catches a hand-edit here and says plainly that + # upstream freshness was not checked; only a local run with the working trees can do that. + - name: Package skill copies are unedited + run: bin/sync-skills --check + + # Pure Python over lib/, so it needs no Flutter toolchain and belongs here rather than in + # the Flutter job. `bin/check` runs the same assertion locally. + - name: Component registry is current + run: bin/sync-registry --check + flutter: name: Flutter (analyze + test) runs-on: ubuntu-latest diff --git a/.worktreeinclude b/.worktreeinclude index 1c9f3cf..9d6be1a 100644 --- a/.worktreeinclude +++ b/.worktreeinclude @@ -15,7 +15,21 @@ # to `.claude/worktrees/magic`, which does not exist, and version solving fails on # the first path dependency. # +# The other two fail in their own ways, neither of which names the missing file: +# without `backend/.env` artisan aborts before it says why, and without +# `.artisan/plugins.json` every plugin command disappears from `./bin/fsa`. They were +# listed in `bin/check` and not here, so a worktree Claude Code created got the +# overrides and neither of them, which is the half AGENTS.md already claimed was +# covered. +# +# The root `.env` is deliberately absent from this list: it is COMMITTED rather than +# gitignored, because it is a bundled pubspec asset and a missing asset fails +# `flutter build`. A tracked file is in the worktree already, and the copier only +# handles gitignored ones. +# # NOT processed when a WorktreeCreate hook replaces the default git logic; such a # hook has to copy these itself. pubspec_overrides.yaml +backend/.env +.artisan/plugins.json diff --git a/AGENTS.md b/AGENTS.md index 17c27ac..e40b809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ That override file is also why a green local run can be a red CI: with it, this ## One task, one worktree, one PR - Branch from `main` as `feature/` or `fix/`, and work in a worktree under `.claude/worktrees/`. -- A fresh worktree lacks three gitignored files it needs in order to run: `pubspec_overrides.yaml`, `backend/.env`, `.artisan/plugins.json`. Two mechanisms copy them from the main worktree and neither covers every path on its own: `.worktreeinclude` runs when Claude Code creates the worktree, `bin/check` on its first run there. Do not hand-author them. +- A fresh worktree lacks three gitignored files it needs in order to run: `pubspec_overrides.yaml`, `backend/.env`, `.artisan/plugins.json`. Two mechanisms copy them from the main worktree and neither covers every path on its own: `.worktreeinclude` runs when Claude Code creates the worktree, `bin/check` on its first run there. Both carry all three now; `.worktreeinclude` used to carry only the overrides, so the `EnterWorktree` path silently shipped a worktree with no `backend/.env` and no plugin commands. Do not hand-author them. +- Without `pubspec_overrides.yaml` nothing errors: the siblings resolve from pub.dev and the suite passes against the PUBLISHED packages while the diff under review is of the local ones. `bin/check` refuses rather than measuring the wrong thing. A FORK has no sibling checkouts and hosted resolution is correct there, which is what `CHECK_ALLOW_HOSTED=1 bin/check` is for; inside this workspace, reaching for it means you are about to certify the wrong packages. - The paths inside `pubspec_overrides.yaml` must be ABSOLUTE. A worktree lives at `.claude/worktrees/`, so the conventional relative `../magic` resolves to `.claude/worktrees/magic` and version solving fails on the first path dependency. That failure is loud, unlike the one above it. - Land the work as a PR. A suite that only ran on one machine is not evidence. @@ -30,10 +31,14 @@ That override file is also why a green local run can be a red CI: with it, this `bin/check` is the gate. It fans the suites out across cores and prints one line per job: -- `bin/check` runs `flutter analyze`, `flutter test`, `pint --test`, and the PHP suite. +- `bin/check` runs `flutter analyze`, the design-token scan, the component-registry check, the overrides-parser shape table, `flutter test`, `pint --test`, and the PHP suite. - `bin/check --fast` runs only the static passes. - `bin/check flutter|backend` scopes it to one half. +Two gates are NOT in `bin/check`: the `.github/` instruction mirrors and the package skill copies are checked by CI, so a stale one passes locally and blocks the merge there. Run `bin/sync-instructions` after editing AGENTS.md or a rule, and `bin/sync-skills` after pulling a sibling package. + +A `.php` file Claude writes is linted at that moment by a `PostToolUse` hook (`.claude/hooks/lint-changed.sh`), which reports a `pint --test` failure back into the session instead of letting it cost a CI round. It is registered in `.claude/settings.json` rather than `settings.local.json`, because the latter is gitignored and a worktree would therefore never have it. The hook exits silently when `backend/vendor/` is absent, which is a real state in a fresh clone rather than a fault. + A green suite is the floor, not the finish line. Anything a person clicks gets driven for real with `fluttersdk_dusk` against a running Chrome, at desktop and at mobile width both, because the shell swaps widget trees at `lg` (1024px) and each side can break alone. `docs/verification-loop.md` is the procedure: the three layers, how to boot the app, how to resize a viewport correctly, and the measurement traps that produce confident wrong answers. ## Running it @@ -43,7 +48,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. diff --git a/README.md b/README.md index 0eab881..8af42ec 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ Follow these steps in order; each one depends on the previous. to point at and must resolve every `magic` package from pub.dev via the plain `pubspec.yaml` constraints. Deleting it (it is gitignored, so it was never committed) is what makes `flutter pub get` resolve purely hosted. + `bin/check` guards that file for the workspace case, so once it is gone run + the gate as `CHECK_ALLOW_HOSTED=1 bin/check`, which is the supported way to + say "hosted is what I meant". 6. **Refresh the preview catalog:** `dart run bin/dispatcher.dart previews:refresh`. After these six steps, `flutter pub get` should resolve against pub.dev alone, diff --git a/bin/check b/bin/check index 77cc126..2ccd92c 100755 --- a/bin/check +++ b/bin/check @@ -127,7 +127,63 @@ ensure_vite_manifest() { echo "check: asset build failed; any page test will fail on the vite manifest" >&2 } +# The siblings are declared as hosted carets, so a checkout with no +# pubspec_overrides.yaml resolves every one of them from pub.dev and the suite then +# passes against the PUBLISHED packages rather than the working trees the diff is +# about. `bootstrap_ignored_files` copies the file into a worktree just above, so +# reaching the error below means it is absent from the main checkout too. +# +# The paths inside it are absolute, so a stale one points at a directory that moved. +# pub reports that as a resolution failure with no hint of this file. +require_local_siblings() { + wants flutter || return 0 + + # This repo exists to be forked, and a fork resolving every sibling from + # pub.dev is the CORRECT answer rather than a mistake: there are no sibling + # working trees beside it. The escape is opt-in so the default still + # protects the case it was written for, a working tree whose overrides went + # missing and would otherwise certify the published packages. + if [ "${CHECK_ALLOW_HOSTED:-}" = "1" ]; then + [ -f pubspec_overrides.yaml ] || \ + echo "check: CHECK_ALLOW_HOSTED=1, resolving every sibling from pub.dev" + return 0 + fi + + [ -f pubspec_overrides.yaml ] || { + echo "check: pubspec_overrides.yaml is absent, so every sibling would resolve from pub.dev" >&2 + echo "check: and this run would certify the published packages, not your working trees." >&2 + echo "check: on a fork with no sibling checkouts, hosted IS correct: CHECK_ALLOW_HOSTED=1 bin/check" >&2 + exit 1 + } + + # The paths inside it are absolute, so a stale one points at a directory that + # moved. pub reports that as a resolution failure with no hint of this file. + # + # `bin/parse-overrides.py` is a separate file rather than a heredoc here for + # one reason: it can be tested, and `bin/parse-overrides-test.py` is its + # table. This guard was wrong four times in a row (PyYAML absent, flow form, + # four-space and tab indent, a flow-form `git:` override), each fix keyed to + # the shapes somebody had thought of and each one finding the next, because + # hand-exercising was the only instrument. A shape costs one row now. + local missing status + missing="$(python3 bin/parse-overrides.py pubspec_overrides.yaml)" && status=0 || status=$? + + # A parser that fell over must not read as "nothing is stale". + if [ "$status" -ne 0 ]; then + echo "check: could not read pubspec_overrides.yaml (exit $status)" >&2 + echo "check: fix the file, or set CHECK_ALLOW_HOSTED=1 to resolve from pub.dev instead." >&2 + exit 1 + fi + + [ -z "$missing" ] && return 0 + + echo "check: pubspec_overrides.yaml points at paths that do not exist:" >&2 + echo "$missing" | sed 's/^/check: /' >&2 + exit 1 +} + bootstrap_ignored_files +require_local_siblings require_backend_vendor ensure_vite_manifest @@ -178,6 +234,16 @@ run_backend_test() { cd backend && php artisan config:clear >/dev/null && php ar # not catch; `design:lint` validates DESIGN.md's YAML and never reads the Dart # that is supposed to obey it, which is the gap this closes. run_design_tokens() { bin/design-tokens; } +# The registry is what AGENTS.md tells an agent to read before writing a widget, so a +# stale one sends it to scaffold a second copy of a component that already exists. +# Hand-maintained it drifted into documenting magic_starter's library instead of this +# one; generated, it can be verified. +run_registry() { bin/sync-registry --check && echo "registry: current"; } +# The overrides parser guards the guard. `require_local_siblings` above is the +# only check whose failure mode is SILENCE (it reads a stale file and reports +# nothing), and it was wrong four times in a row before it had a test. This runs +# its shape table, so a fifth shape costs one row rather than another round. +run_overrides_parser() { python3 bin/parse-overrides-test.py; } echo "check: ${SCOPES# } | $TOTAL_CORES cores, $LANE per suite$([ "$FAST" -eq 1 ] && echo ' | static only')" prepare @@ -185,6 +251,8 @@ echo wants flutter && start "flutter-analyze" run_flutter_analyze wants flutter && start "design-tokens" run_design_tokens +wants flutter && start "registry" run_registry +wants flutter && start "overrides-parser" run_overrides_parser wants backend && start "backend-pint" run_backend_pint if [ "$FAST" -eq 0 ]; then diff --git a/bin/parse-overrides-test.py b/bin/parse-overrides-test.py new file mode 100755 index 0000000..5bee80a --- /dev/null +++ b/bin/parse-overrides-test.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""The table `bin/parse-overrides.py` is checked against. + +Every shape below is here because something read it wrong, or because a review +asked whether it did. Four rounds of review found four shapes the parser did not +handle, each one caught by hand and each fix keyed to the shapes somebody had +thought of. This file is the answer to that: a shape costs one row. + +`/tmp` stands in for a directory that exists and `/nonexistent/*` for one that +does not, so no fixture tree is needed and the table stays readable. + +Run: `python3 bin/parse-overrides-test.py` (also a `bin/check` job). +""" + +import importlib.util +import sys +from pathlib import Path + +# Loaded by path because the parser is `parse-overrides.py`, a hyphenated script +# rather than an importable module name. +_spec = importlib.util.spec_from_file_location( + 'parse_overrides', Path(__file__).resolve().parent / 'parse-overrides.py' +) +_parse_overrides = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_parse_overrides) +scan = _parse_overrides.scan + +# name, yaml, expected findings, expected-unreadable +CASES: list[tuple[str, str, list[str], bool]] = [ + # --- the block form, at every indentation a hand-written file may use --- + ( + 'block, two-space, stale', + 'dependency_overrides:\n magic:\n path: /nonexistent/magic\n', + ['magic -> /nonexistent/magic'], + False, + ), + ( + 'block, four-space, stale', + 'dependency_overrides:\n magic:\n path: /nonexistent/magic\n', + ['magic -> /nonexistent/magic'], + False, + ), + ( + 'block, tab-indented, stale', + 'dependency_overrides:\n\tmagic:\n\t\tpath: /nonexistent/magic\n', + ['magic -> /nonexistent/magic'], + False, + ), + ('block, healthy', 'dependency_overrides:\n magic:\n path: /tmp\n', [], False), + # --- the flow form, which AGENTS.md itself writes --- + ( + 'flow, stale', + 'dependency_overrides:\n magic: {path: /nonexistent/magic}\n', + ['magic -> /nonexistent/magic'], + False, + ), + ( + 'flow, second key alongside path', + 'dependency_overrides:\n magic: {path: /nonexistent/m, hosted: x}\n', + ['magic -> /nonexistent/m'], + False, + ), + ( + 'flow, quoted path value', + 'dependency_overrides:\n magic: {path: "/nonexistent/q"}\n', + ['magic -> /nonexistent/q'], + False, + ), + ('flow, healthy', 'dependency_overrides:\n magic: {path: /tmp}\n', [], False), + ( + 'whole block inline', + 'dependency_overrides: {magic: {path: /nonexistent/inline}}\n', + ['magic -> /nonexistent/inline'], + False, + ), + # --- a git override owns a `path:` that is a sub-directory, not a checkout --- + ( + 'git override, block form', + 'dependency_overrides:\n magic:\n git:\n url: https://x\n path: packages/magic\n', + [], + False, + ), + ( + 'git override, flow form', + 'dependency_overrides:\n magic: {git: {url: https://x, path: packages/magic}}\n', + [], + False, + ), + ( + 'git override, flow, with a real path beside it', + 'dependency_overrides:\n magic: {path: /nonexistent/real, git: {path: sub}}\n', + ['magic -> /nonexistent/real'], + False, + ), + # --- keys and values a human writes --- + ( + 'quoted entry key', + 'dependency_overrides:\n "magic":\n path: /nonexistent/q\n', + ['magic -> /nonexistent/q'], + False, + ), + ( + 'trailing comment, block form', + 'dependency_overrides:\n magic:\n path: /tmp # local checkout\n', + [], + False, + ), + ( + 'trailing comment, flow form', + 'dependency_overrides:\n magic: {path: /tmp} # local\n', + [], + False, + ), + ( + 'name with digits and underscores', + 'dependency_overrides:\n magic_starter2:\n path: /nonexistent/s\n', + ['magic_starter2 -> /nonexistent/s'], + False, + ), + ( + 'single-line version, then a stale entry', + 'dependency_overrides:\n file_picker: ^11.0.2\n wind:\n path: /nonexistent/wind\n', + ['wind -> /nonexistent/wind'], + False, + ), + ( + 'sdk override alongside a stale path', + 'dependency_overrides:\n flutter_test:\n sdk: flutter\n wind:\n path: /nonexistent/w\n', + ['wind -> /nonexistent/w'], + False, + ), + ( + 'comment lines and blank lines inside the block', + 'dependency_overrides:\n # why this one is here\n\n magic:\n path: /nonexistent/c\n', + ['magic -> /nonexistent/c'], + False, + ), + # --- scope: only dependency_overrides is this guard's business --- + ( + 'a stale path under another top-level key is ignored', + 'dependency_overrides:\n magic:\n path: /tmp\ndependencies:\n foo:\n path: /nonexistent/foo\n', + [], + False, + ), + ('no dependency_overrides block at all', 'name: uptizm\nversion: 1.0.0\n', [], False), + ('empty file', '', [], False), + # --- two stale entries are both reported, not just the first --- + ( + 'two stale entries', + 'dependency_overrides:\n magic:\n path: /nonexistent/a\n wind:\n path: /nonexistent/b\n', + ['magic -> /nonexistent/a', 'wind -> /nonexistent/b'], + False, + ), + # --- an anchor or tag decorates the child, it is not the entry's value --- + ( + 'anchor before a block child', + 'dependency_overrides:\n magic: &m\n path: /nonexistent/anchor\n', + ['magic -> /nonexistent/anchor'], + False, + ), + ( + 'tag before a block child', + 'dependency_overrides:\n magic: !!map\n path: /nonexistent/tag\n', + ['magic -> /nonexistent/tag'], + False, + ), + # --- a shape it cannot read is LOUD, never a silent pass --- + ('list form', 'dependency_overrides:\n - magic\n', [], True), + ( + 'multi-line flow map', + 'dependency_overrides:\n magic: {path: /nonexistent/m,\n hosted: x}\n', + [], + True, + ), +] + + +def exit_code_failures() -> list[str]: + """The three exit codes `bin/check` branches on, run through main(). + + scan() is the interesting half, but the guard's behaviour is the exit code: + 2 and 3 are what turn a bad file into a loud failure rather than a silent + pass, and bin/check tests `$status` against them. A table that stopped at + scan() would leave the loud half hand-verified, which is where this started. + """ + import subprocess + import tempfile + + script = str(Path(__file__).resolve().parent / 'parse-overrides.py') + failures = [] + + def run(label: str, contents: str | None, expected_code: int) -> None: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / 'pubspec_overrides.yaml' + if contents is None: + target = Path(directory) / 'absent.yaml' + else: + target.write_text(contents, encoding='utf-8') + result = subprocess.run( + [sys.executable, script, str(target)], capture_output=True, text=True + ) + if result.returncode != expected_code: + failures.append( + f'{label}: expected exit {expected_code}, got {result.returncode}' + ) + + run('exit 0 on a healthy file', 'dependency_overrides:\n magic:\n path: /tmp\n', 0) + run('exit 0 on a stale file (findings go to stdout)', + 'dependency_overrides:\n magic:\n path: /nonexistent/x\n', 0) + run('exit 2 when the file cannot be opened', None, 2) + run('exit 3 on a shape it cannot read', 'dependency_overrides:\n - magic\n', 3) + return failures + + +def main() -> int: + exit_failures = exit_code_failures() + failures = list(exit_failures) + for name, yaml, expected, expect_unreadable in CASES: + missing, reason = scan(yaml) + if expect_unreadable: + if reason is None: + failures.append(f'{name}: expected an unreadable-shape failure, got none') + continue + if reason is not None: + failures.append(f'{name}: unexpected failure: {reason}') + continue + if missing != expected: + failures.append(f'{name}: expected {expected}, got {missing}') + + for line in failures: + print(f' FAIL {line}', file=sys.stderr) + total = len(CASES) + 4 + print(f'parse-overrides: {total - len(failures)}/{total} checks pass ' + f'({len(CASES)} shapes, 4 exit codes)') + return 1 if failures else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/bin/parse-overrides.py b/bin/parse-overrides.py new file mode 100755 index 0000000..2ff19c2 --- /dev/null +++ b/bin/parse-overrides.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Report every `dependency_overrides` path entry that points nowhere. + +Reads a `pubspec_overrides.yaml`, prints one `name -> path` line per override +whose `path:` is not a directory, and exits non-zero when it meets a shape it +cannot read. `bin/check` calls it; `bin/parse-overrides-test.py` is its table. + +A separate file rather than a heredoc inside `bin/check` for one reason: it can +be tested. Four review rounds found four shapes this parser did not read (PyYAML +absent, flow form, four-space and tab indent, and a flow-form `git:` override), +each fix keyed to the shapes somebody had thought of, and each one finding the +next. Hand-exercising was the instrument every time, and the shapes nobody +thinks of are exactly the failure mode. + +Two rules carry the whole thing: + +1. Walk by INDENT LEVEL, not by fixed-width line shapes, so two-space, + four-space and tab files all read the same. +2. Only a `path:` at the TOP level of a package entry counts. A `git:` override + carries its own `path:` meaning a sub-directory inside the repository, and + reporting that as a missing checkout fails the gate on a valid file. + +Exit codes: 0 read it (any findings are on stdout), 2 could not open the file, +3 met a shape it cannot read. +""" + +import os +import re +import sys + +KEY = r'(?:"[^"]+"|\'[^\']+\'|[A-Za-z_][A-Za-z0-9_]*)' +ENTRY_RE = re.compile(rf'^({KEY})\s*:\s*(.*)$') + + +def strip_comment(text: str) -> str: + """Drop a trailing ` # comment`, leaving a `#` inside quotes alone.""" + quote = None + for index, char in enumerate(text): + if quote: + if char == quote: + quote = None + elif char in '"\'': + quote = char + elif char == '#' and (index == 0 or text[index - 1] in ' \t'): + return text[:index] + return text + + +def unquote(text: str) -> str: + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in '"\'': + return text[1:-1] + return text + + +def split_flow(body: str) -> list[str]: + """Split a flow mapping's body on its TOP-LEVEL commas. + + `{path: /a, git: {url: x, path: sub}}` yields two items, not four, which is + what keeps a nested `git:` map from being read as if its keys were the + entry's own. + """ + items, depth, start, quote = [], 0, 0, None + for index, char in enumerate(body): + if quote: + if char == quote: + quote = None + elif char in '"\'': + quote = char + elif char in '{[': + depth += 1 + elif char in '}]': + depth -= 1 + elif char == ',' and depth == 0: + items.append(body[start:index]) + start = index + 1 + items.append(body[start:]) + return [item for item in items if item.strip()] + + +def flow_entries(value: str) -> list[tuple[str, str]] | None: + """The top-level `key: value` pairs of a flow mapping, or None if malformed.""" + text = value.strip() + if not (text.startswith('{') and text.endswith('}')): + return None + pairs = [] + for item in split_flow(text[1:-1]): + matched = ENTRY_RE.match(item.strip()) + if not matched: + return None + pairs.append((unquote(matched.group(1)), matched.group(2).strip())) + return pairs + + +def flow_path(value: str) -> str | None: + """The entry's own `path:`, ignoring one nested inside a `git:` map.""" + pairs = flow_entries(value) + if pairs is None: + return None + for key, inner in pairs: + if key == 'path': + return inner + return None + + +def is_decorator(value: str) -> bool: + """Whether [value] is only a YAML anchor or tag, so the real value follows.""" + return all(word.startswith(('&', '!')) for word in value.split()) + + +def check(name: str, value: str, missing: list[str]) -> None: + path = unquote(strip_comment(value).strip()) + if path and not os.path.isdir(path): + missing.append(f'{name} -> {path}') + + +def scan(raw: str) -> tuple[list[str], str | None]: + """Findings, plus the reason this file could not be read (None when it could).""" + missing: list[str] = [] + stack: list[tuple[int, str]] = [] + + for number, line in enumerate(raw.splitlines(), 1): + expanded = line.expandtabs(2) + if not expanded.strip() or expanded.lstrip().startswith('#'): + continue + + indent = len(expanded) - len(expanded.lstrip(' ')) + body = strip_comment(expanded.strip()).strip() + if not body: + continue + + while stack and indent <= stack[-1][0]: + stack.pop() + path_of = [key for _, key in stack] + + matched = ENTRY_RE.match(body) + if not matched: + if path_of[:1] == ['dependency_overrides']: + return missing, f'unrecognised dependency_overrides line {number}: {line.strip()}' + continue + + key, value = unquote(matched.group(1)), matched.group(2).strip() + + # `dependency_overrides: {magic: {path: /x}}`, the whole block inline. + if not path_of and key == 'dependency_overrides' and value.startswith('{'): + pairs = flow_entries(value) + if pairs is None: + return missing, f'unrecognised dependency_overrides line {number}: {line.strip()}' + for name, inner in pairs: + own = flow_path(inner) + if own is not None: + check(name, own, missing) + continue + + # A package entry sits directly under dependency_overrides. + if path_of == ['dependency_overrides']: + if value.startswith('{'): + own = flow_path(value) + if own is None and flow_entries(value) is None: + return missing, f'unrecognised dependency_overrides line {number}: {line.strip()}' + if own is not None: + check(key, own, missing) + continue + # An anchor or tag (`magic: &m`, `magic: !!map`) is not the entry's + # value, it decorates the block child on the following lines. Push + # the entry so that child's `path:` is still seen; without this the + # child was swallowed and a stale path passed silently, which is the + # one class this whole file exists to stop. + if not value or is_decorator(value): + stack.append((indent, key)) + continue + + # `path:` directly under a package entry, at whatever indent it uses. + if len(path_of) == 2 and path_of[0] == 'dependency_overrides' and key == 'path' and value: + check(path_of[1], value, missing) + continue + + if not value: + stack.append((indent, key)) + + return missing, None + + +def main() -> int: + target = sys.argv[1] if len(sys.argv) > 1 else 'pubspec_overrides.yaml' + try: + raw = open(target, encoding='utf-8').read() + except OSError as exc: + print(f'cannot read {target}: {exc}', file=sys.stderr) + return 2 + + missing, reason = scan(raw) + for line in missing: + print(line) + if reason: + print(reason, file=sys.stderr) + return 3 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/bin/sync-registry b/bin/sync-registry new file mode 100755 index 0000000..3bf7f12 --- /dev/null +++ b/bin/sync-registry @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Regenerate docs/component-registry.md from lib/ui/components/ and lib/preview/. + +Why this exists +--------------- +The registry is what `AGENTS.md` tells every agent to read before writing a widget, so that a +screen reaches for a component that exists instead of scaffolding a second one. It only works if +it describes what is on disk, and hand-maintained it did not: the file this replaces carried a +`generated: manual` header and documented `magic_starter`'s generic library, so the components +this repository actually owns appeared nowhere in it and its `last_updated` had gone stale by +months. + +So it is generated, and `bin/check` verifies it is current the same way CI verifies the `.github` +mirrors. A registry that can be wrong is worse than no registry, because it is trusted. + +This is a fork base, so the script has to keep working after the components are replaced: it +discovers them rather than listing them, and an empty `lib/ui/components/` renders an empty table +rather than failing. + +Usage +----- + bin/sync-registry rewrite the file + bin/sync-registry --check exit 1 if it is out of date, print nothing on success +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +COMPONENTS = ROOT / "lib" / "ui" / "components" +SCREEN_PREVIEWS = ROOT / "lib" / "preview" +TARGET = ROOT / "docs" / "component-registry.md" + + +def first_prose_line(lines: list[str], class_line: int) -> str: + """The first plain SENTENCE of the doc block directly above a class declaration. + + Walks up through the `///` block and any annotations, then forward again, so the summary comes + from the component's own words rather than from a description maintained beside it. + + Joins the wrapped lines of that opening paragraph rather than taking only the first one. A doc + block wraps at the line length, so the first `///` line is a fragment far more often than it is + a sentence, and every row in the generated table used to end mid-word. The registry's one + descriptive column is the reason the file exists, and half a sentence does not describe + anything. + + Stops at the first full stop, at a blank `///`, or at a `///` list item, so a component whose + doc opens with a title line still yields the title and nothing more. + """ + start = class_line - 1 + while start >= 0 and (lines[start].strip().startswith("///") or lines[start].strip().startswith("@")): + start -= 1 + + collected: list[str] = [] + for line in lines[start + 1 : class_line]: + text = line.strip() + if not text.startswith("///"): + continue + body = text[3:].strip().replace("**", "") + + if not collected: + if not body or text.startswith("/// #"): + continue + # Skip a bold-name heading such as `**Callout**`, which repeats the class name. + if re.fullmatch(r"[A-Z][A-Za-z]+", body): + continue + collected.append(body) + else: + # The paragraph ends at a blank line, a heading, or a list item. + if not body or body.startswith(("#", "-", "*", "1.")): + break + collected.append(body) + + if collected[-1].endswith((".", "!", "?", ":")): + break + + return first_sentence(" ".join(collected)) + + +def first_sentence(text: str) -> str: + """The opening sentence of [text], terminator included. + + The paragraph is joined from wrapped lines, so a physical line can carry two sentences and the + per-line stop above overshoots: without this, a doc whose first line reads "An inline note, + tinted by intent. Demonstrates a" yields the whole paragraph. A table cell wants one sentence. + + A terminator only counts when a space or the end of the string follows it, so `0.0.9`, + `magic_starter.` mid-word and an ellipsis do not split the line early. + """ + for index, char in enumerate(text): + if char not in ".!?": + continue + if index + 1 < len(text) and text[index + 1] != " ": + continue + if index and text[index - 1].isdigit() and char == ".": + continue + return text[: index + 1] + return text + + +def cell(text: str) -> str: + """One markdown table cell: escaped, and never silently blank. + + A `|` inside a doc line splits the row into extra cells and the table stops rendering from + there. A component that documents its own columns as `Time | Region | Status` is exactly the + shape that would do it the day that line becomes the opening sentence. + + An empty description is a real gap rather than a formatting one, so it is rendered loud in the + same way a missing preview is: the table is the only place anyone would notice. + """ + if not text: + return "**NO DOC**" + return text.replace("|", "\\|") + + +def read_component(directory: Path) -> tuple[str, str, str, str, str, str, str] | None: + source_file = directory / f"{directory.name}.dart" + if not source_file.exists(): + return None + + source = source_file.read_text() + lines = source.split("\n") + match = re.search(r"^class (\w+) extends", source, re.M) + if match is None: + return None + + name = match.group(1) + class_line = next(i for i, line in enumerate(lines) if line.startswith(f"class {name} extends")) + variants = ", ".join(sorted(set(re.findall(r"^enum (\w+)", source, re.M)))) or "-" + recipe = "yes" if (directory / f"{directory.name}.recipe.dart").exists() else "no" + # A missing preview and a missing barrel are both rule violations, so they are rendered as + # loud cells rather than omitted. The table is the only place a reviewer would notice. + previews = sorted(p.name for p in directory.glob("*.preview.dart")) + if len(previews) == 1: + preview = "yes" + elif not previews: + preview = "**NONE**" + else: + preview = f"**{len(previews)}**" + exports = "yes" if (directory / "index.dart").exists() else "**NO**" + return directory.name, name, variants, recipe, preview, exports, first_prose_line(lines, class_line) + + +def render() -> str: + directories = sorted(d for d in COMPONENTS.iterdir() if d.is_dir()) if COMPONENTS.is_dir() else [] + rows = [row for row in (read_component(d) for d in directories) if row] + screens = sorted(p.name for p in SCREEN_PREVIEWS.glob("*.preview.dart")) if SCREEN_PREVIEWS.is_dir() else [] + + out = [ + "---", + "generated: by `bin/sync-registry` from `lib/ui/components/` and `lib/preview/`", + "source: this app's own component library", + "---", + "", + "# Component Registry", + "", + "Every component this app owns, so that a screen reaches for one that exists instead of", + "scaffolding a second one. `AGENTS.md` requires reading this before writing any widget,", + "which only works if it describes what is actually on disk.", + "", + "**Do not edit this file.** Run `bin/sync-registry` after adding or changing a component;", + "`bin/check` fails when it is out of date. It was hand-maintained once and drifted into", + "documenting a different package's library entirely, while every component here went", + "unlisted. A registry that can be wrong is worse than no registry, because it is trusted.", + "", + "## Look in `magic_starter` first", + "", + "The generic layer lives in the package, not here: `MSButton`, `MSInput`, `MSSelect`,", + "`MSCheckbox`, `MSSwitch`, `MSCard`, `MSBadge`, `MSTabs`, `MSSegmentedControl`,", + "`MSBottomSheet`, `MSEmptyState`, `MSPageScaffold`, `MSPageHeader` and `MSPageContainer`.", + "A component belongs in this app only when it encodes something a generic library could", + "not. The three below are examples for a fork to read and then replace, not a library to", + "grow.", + "", + "## The components this app owns", + "", + "| Folder | Class | Variant enums | Recipe | Preview | index.dart | What it is |", + "|---|---|---|---|---|---|---|", + ] + for folder, name, variants, recipe, preview, exports, doc in rows: + out.append(f"| `{folder}/` | `{name}` | {variants} | {recipe} | {preview} | {exports} | {cell(doc)} |") + + out += [ + "", + f"{len(rows)} components. A bold cell is a rule violation rather than a note:", + "`.claude/rules/design.md` requires exactly one preview per component and an `index.dart`", + "that exports the class and its recipe but never the preview.", + "", + "## Screen previews", + "", + "Whole-screen entries in the `/preview` catalog, which is why they sit outside the", + "component folders. They compose the components above rather than defining any.", + "", + ] + out += [f"- `lib/preview/{name}`" for name in screens] or ["- none"] + + return "\n".join(out) + "\n" + + +def main() -> int: + rendered = render() + if "--check" in sys.argv: + current = TARGET.read_text() if TARGET.exists() else "" + if current != rendered: + print("component-registry.md is out of date. Run bin/sync-registry.", file=sys.stderr) + return 1 + return 0 + + TARGET.write_text(rendered) + print(f"sync-registry: wrote {TARGET.relative_to(ROOT)} ({rendered.count(chr(10) + '| `')} rows)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/sync-skills b/bin/sync-skills new file mode 100755 index 0000000..36a198c --- /dev/null +++ b/bin/sync-skills @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# Copies the sibling packages' authoring skills into `.github/skills`, where Copilot code review +# reads them. +# +# bin/sync-skills refresh the copies from the sibling working trees +# bin/sync-skills --check verify them (CI uses this) +# +# WHY COPIES AND NOT SYMLINKS +# +# A symlink cannot work here, and for a stronger reason than the one `bin/sync-instructions` +# records. Copilot's review runs on GitHub with the repository checkout and nothing else, so a link +# to `../magic/skills/...` resolves to a path that does not exist there. The target is OUTSIDE the +# repository by definition: these packages are separate repos. +# +# WHY ONLY TWO OF THE FIVE +# +# The five sibling skills total 1,819 lines as measured here, against GitHub's own guidance to keep the whole +# instruction surface near 1,000. `magic-framework` and `wind-ui` are the two describing code that +# is actually WRITTEN in this repository; `artisan`, `dusk` and `telescope` describe tools that drive +# a running app, which a reviewer looking at a diff cannot use. Adding them would spend the budget +# on advice the reviewer cannot act on. +# +# Reference files under each skill's `references/` are NOT copied. Copilot does not follow links, so +# a pointer to one is dead weight, and copying them all would double the surface again. +# +# WHAT --check CAN AND CANNOT PROVE +# +# Two different guarantees, each made where it can be: +# +# locally, with the siblings present: the copy still matches upstream (catches DRIFT) +# on CI, with the siblings absent: the copy matches its own recorded hash (catches HAND-EDITS) +# +# CI cannot detect upstream drift, because the upstream is not in the checkout. That is a real limit +# rather than an oversight: run this script after pulling a sibling package. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# The workspace holding the sibling packages, resolved from the MAIN worktree rather than from +# here. `$REPO_ROOT/..` is `.claude/worktrees` when this runs inside a worktree, which is the same +# nested-layout trap `AGENTS.md` records for `pubspec_overrides.yaml`: `..` means something else +# depending on where you are. `--git-common-dir` always points at the main repository's `.git`. +MAIN_ROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" +WORKSPACE="$(cd "$MAIN_ROOT/.." && pwd)" +TARGET_DIR=".github/skills" + +CHECK=0 +[ "${1:-}" = "--check" ] && CHECK=1 + +# package:skill, and the skill name is also the directory name Copilot discovers. +SKILLS="magic:magic-framework wind:wind-ui" + +sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + sha256sum "$1" | cut -d' ' -f1 + fi +} + +# Frontmatter, then the provenance banner, then the rest of the source verbatim. +# +# The banner goes AFTER the frontmatter because `name:` and `description:` have to stay on line 1 +# for the loader to parse them, the same constraint the path-scoped rules have. +render() { + local source_file="$1" version="$2" hash="$3" package="$4" skill="$5" + + awk 'NR == 1 && $0 == "---" { print; inside = 1; next } + inside && $0 == "---" { print; exit } + inside { print }' "$source_file" + + # No blank line before the banner, and none after. The banner has to be EXACTLY the lines + # `without_banner` removes, or the copy minus its banner is not byte-identical to the source and + # the recorded hash can never match. That is how this was caught: the round trip differed by one + # empty line and the CI-side check would have failed on every clean copy. + printf '\n' + + awk 'NR == 1 && $0 == "---" { inside = 1; next } + inside && $0 == "---" { inside = 0; body = 1; next } + body' "$source_file" +} + +# The copy minus its banner, which is byte-identical to the source it came from. +without_banner() { + awk '/^$/ { skipping = 0; next }' "$1" +} + +failed=0 + +for pair in $SKILLS; do + package="${pair%%:*}" + skill="${pair##*:}" + source_file="$WORKSPACE/$package/skills/$skill/SKILL.md" + target="$TARGET_DIR/$skill/SKILL.md" + + if [ ! -f "$source_file" ]; then + if [ "$CHECK" -eq 0 ]; then + echo "sync-skills: $source_file is missing; is the $package working tree beside this repo?" >&2 + exit 1 + fi + + # The CI case. Verify the copy against the hash it recorded, which catches an edit made here + # and says plainly that upstream freshness was not checked. + if [ ! -f "$target" ]; then + echo "sync-skills: $target is missing" >&2 + failed=1 + continue + fi + + recorded="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\)\..*/\1/p' "$target" | head -1)" + stripped="$(mktemp)" + without_banner "$target" >"$stripped" + actual="$(sha256 "$stripped")" + rm -f "$stripped" + + if [ "$recorded" != "$actual" ]; then + echo "sync-skills: $target was edited in place. It is a copy; edit it in $package." >&2 + failed=1 + else + echo "sync-skills: $skill matches its recorded hash (upstream not checked, source absent)" + fi + + continue + fi + + version="$(sed -n 's/^version: *//p' "$source_file" | head -1)" + [ -n "$version" ] || version="unversioned" + hash="$(sha256 "$source_file")" + + staged="$(mktemp)" + render "$source_file" "$version" "$hash" "$package" "$skill" >"$staged" + + if [ "$CHECK" -eq 1 ]; then + if [ -f "$target" ] && diff -q "$staged" "$target" >/dev/null 2>&1; then + echo "sync-skills: $skill is current ($package $version)" + else + echo "sync-skills: $skill is stale. Run bin/sync-skills and commit the result." >&2 + diff -u "$target" "$staged" 2>/dev/null | head -20 || true + failed=1 + fi + rm -f "$staged" + + continue + fi + + mkdir -p "$(dirname "$target")" + mv "$staged" "$target" + echo "sync-skills: wrote $target ($package $version, $(wc -l <"$target" | tr -d ' ') lines)" +done + +exit "$failed" diff --git a/docs/component-registry.md b/docs/component-registry.md index d6bb3f6..b9a4159 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -1,510 +1,51 @@ --- -generated: manual (design:registry planned) -source: magic_starter generic component library -last_updated: 2026-06-25 +generated: by `bin/sync-registry` from `lib/ui/components/` and `lib/preview/` +source: this app's own component library --- # Component Registry -Machine-readable manifest of every component in the app's `lib/ui/components/` library. Maps each component to its variants, token bindings, and anti-patterns. - -> **design:registry note**: this file is intended to be generated and kept in sync by `make:component` and `previews:refresh`. Until that command emits it automatically, maintain it by hand when adding or modifying components. - ---- - -## Primitives - -Components backed by a Wind W-widget with no recipe layer. - ---- - -## Form Inputs - -### Button - -- **File**: `lib/ui/components/button/` -- **Class**: `Button` -- **Recipe**: `WindRecipe` in `button.recipe.dart` -- **Variants**: - - `intent`: `primary` | `secondary` | `ghost` | `destructive` - - `size`: `sm` | `md` | `lg` -- **Default variants**: `intent=primary`, `size=md` -- **Token bindings**: - - `primary`: `bg-primary text-on-primary` - - `secondary`: `bg-surface-container text-fg border border-color-border` - - `ghost`: `bg-transparent text-fg-muted` - - `destructive`: `bg-destructive text-on-destructive` - - `sm`: `text-xs px-3 py-1.5` - - `md`: `text-sm px-4 py-2` - - `lg`: `text-base px-6 py-3` -- **Anti-patterns**: - - Do not use more than one primary button per section. - - Do not use destructive intent outside confirm dialogs without a secondary confirmation step. - - Do not hardcode colors via `className` override when a variant covers the case. - ---- - -### Input - -- **File**: `lib/ui/components/input/` -- **Class**: `Input` -- **Recipe**: `WindRecipe` in `input.recipe.dart` -- **Variants**: - - `state`: `default` | `error` -- **Default variants**: `state=default` -- **Token bindings**: - - `default`: `bg-surface-container-high border border-color-border text-fg` - - `error`: `bg-surface-container-high border border-color-destructive text-fg` -- **Anti-patterns**: - - Do not render error state without an error message in the parent `FormField`. - - Do not use raw `WInput` directly; prefer `Input` so the recipe layer is consistent. - ---- - -### Textarea - -- **File**: `lib/ui/components/textarea/` -- **Class**: `Textarea` -- **Recipe**: `WindRecipe` in `textarea.recipe.dart` -- **Variants**: - - `state`: `default` | `error` -- **Default variants**: `state=default` -- **Token bindings**: same as Input. -- **Anti-patterns**: same as Input. - ---- - -### Checkbox - -- **File**: `lib/ui/components/checkbox/` -- **Class**: `Checkbox` -- **Recipe**: `WindRecipe` in `checkbox.recipe.dart` -- **Variants**: none (state is driven by `checked:` prefix) -- **Token bindings**: - - unchecked: `border-color-border bg-surface-container-high` - - checked (`checked:` state): `bg-primary border-primary` -- **Anti-patterns**: - - Do not use Material `Checkbox`; always use this component. - ---- - -### Switch - -- **File**: `lib/ui/components/switch/` -- **Class**: `Switch` -- **Recipe**: `WindRecipe` in `switch.recipe.dart` -- **Variants**: none (state is driven by `checked:` prefix on track/thumb) -- **Token bindings**: - - track off: `bg-surface-container border-color-border` - - track on (`checked:`): `bg-primary` - - thumb: `bg-surface` -- **Anti-patterns**: - - Do not use Material `Switch`. - - Do not animate thumb translate outside the Wind checked state prefix. - ---- - -### Radio - -- **File**: `lib/ui/components/radio/` -- **Class**: `Radio` -- **Generic type**: `Radio` -- **Recipe**: `WindRecipe` in `radio.recipe.dart` -- **Variants**: none (state is driven by `selected:` prefix) -- **Token bindings**: - - unselected: `border-color-border bg-surface-container-high` - - selected (`selected:`): `bg-primary border-primary` -- **Anti-patterns**: - - Do not use Material `Radio`. - - Group state management is the caller's responsibility (pass `groupValue`). - ---- - -## Display - -### Badge - -- **File**: `lib/ui/components/badge/` -- **Class**: `Badge` -- **Recipe**: `WindRecipe` in `badge.recipe.dart` -- **Variants**: - - `tone`: `neutral` | `primary` | `accent` | `success` | `warning` | `destructive` | `outline` -- **Default variants**: `tone=neutral` -- **Token bindings**: - - `neutral`: `bg-surface-container text-fg-muted` - - `primary`: `bg-primary-container text-primary` - - `accent`: `bg-accent text-on-primary` - - `success`: `bg-success text-on-primary` - - `warning`: `bg-warning text-on-primary` - - `destructive`: `bg-destructive-container text-destructive` - - `outline`: `bg-transparent text-fg border border-color-border` -- **Anti-patterns**: - - Do not use badges for interactive elements; they are display-only. - - Do not use raw hex to create a custom tone; add a new variant value instead. - ---- - -### Typography - -- **File**: `lib/ui/components/typography/` -- **Class**: `Typography` -- **Recipe**: `WindRecipe` in `typography.recipe.dart` -- **Variants**: - - `variant`: `h1` | `h2` | `h3` | `body` | `caption` -- **Default variants**: `variant=body` -- **Token bindings**: - - `h1`: `text-3xl font-bold text-fg leading-tight tracking-tight` - - `h2`: `text-2xl font-bold text-fg` - - `h3`: `text-xl font-semibold text-fg` - - `body`: `text-sm text-fg` - - `caption`: `text-xs text-fg-muted` -- **Anti-patterns**: - - Do not use raw `WText` for typographic content; use `Typography` so the scale is consistent. - - Semantics (h1/h2) are secondary to hierarchy; a section title can use `h2` even inside a card. - ---- - -### Skeleton - -- **File**: `lib/ui/components/skeleton/` -- **Class**: `Skeleton` -- **Recipe**: `WindRecipe` in `skeleton.recipe.dart` -- **Variants**: - - `shape`: `block` | `text` | `circle` -- **Default variants**: `shape=block` -- **Token bindings**: - - all shapes: `bg-surface-container-high motion-safe:animate-pulse` -- **Anti-patterns**: - - Use `Skeleton` instead of spinners for content loading states. - - Do not animate outside `motion-safe:` prefix (respect `disableAnimations`). - ---- - -## Card - -### Card (migrated from MagicStarterCard) - -- **File**: `lib/ui/components/card/` -- **Class**: `Card` -- **Enum**: `CardVariant` -- **Recipe**: `WindRecipe` in `card.recipe.dart` -- **Variants**: - - `tone`: `surface` | `inset` | `elevated` -- **Default variants**: `tone=surface` -- **Token bindings**: - - `surface`: `bg-surface-container border border-color-border` - - `inset`: `bg-surface-container-high` - - `elevated`: `bg-surface shadow-sm` -- **Slots**: `header`, `child` (body), `footer` -- **Anti-patterns**: - - Do not bake CardVariant logic into child components; pass `tone` to `Card` at the call site. - - Do not use `elevated` on dark backgrounds where shadow is invisible; prefer `surface` with a border. - ---- - -## Selection - -### Select - -- **File**: `lib/ui/components/select/` -- **Class**: `Select` -- **Recipe**: `WindSlotRecipe` in `select.recipe.dart` -- **Slots**: `trigger`, `popup`, `item` -- **Token bindings**: - - trigger: `bg-surface-container-high border border-color-border text-fg rounded-DEFAULT` - - popup: `bg-surface border border-color-border shadow-sm rounded-md` - - item: `text-sm text-fg hover:bg-surface-container-high` -- **Anti-patterns**: - - Do not use Material `DropdownButton`; use `Select`. - ---- - -### Combobox - -- **File**: `lib/ui/components/combobox/` -- **Class**: `Combobox` -- **Recipe**: `WindSlotRecipe` in `combobox.recipe.dart` -- **Slots**: `trigger`, `popup`, `item` -- **Token bindings**: same as Select, plus debounce search input. -- **Anti-patterns**: same as Select. - ---- - -### SegmentedControl - -- **File**: `lib/ui/components/segmented_control/` -- **Class**: `SegmentedControl` -- **Recipe**: `WindSlotRecipe` in `segmented_control.recipe.dart` -- **Variants**: - - `size`: `sm` | `md` -- **Slots**: `root`, `item` -- **Token bindings**: - - root: `bg-surface-container rounded-md p-0.5` - - item active (`selected:`): `bg-surface text-fg shadow-sm rounded-sm` - - item inactive: `text-fg-muted` -- **Anti-patterns**: - - Do not use for more than 4-5 options; use `Tabs` or a `Select` instead. - ---- - -### Tabs - -- **File**: `lib/ui/components/tabs/` -- **Class**: `Tabs` -- **Recipe**: `WindSlotRecipe` in `tabs.recipe.dart` -- **Slots**: `list`, `tab`, `panel` -- **Token bindings**: - - list: `border-b border-color-border` - - tab inactive: `text-fg-muted` - - tab active (`selected:`): `text-primary border-b-2 border-primary` - - panel: `pt-4` -- **Anti-patterns**: - - Do not use Material `TabBar`; use `Tabs`. - ---- - -### Accordion - -- **File**: `lib/ui/components/accordion/` -- **Class**: `Accordion` -- **Recipe**: `WindSlotRecipe` in `accordion.recipe.dart` -- **Slots**: `root`, `item`, `header`, `trigger`, `panel` -- **Token bindings**: - - root: `border border-color-border rounded-md divide-y divide-color-border` - - trigger: `text-fg font-medium` - - panel: `text-fg-muted text-sm px-4 pb-4` -- **Anti-patterns**: - - Do not use for top-level navigation; use for secondary content disclosure only. - ---- - -## Overlays - -### Dialog - -- **File**: `lib/ui/components/dialog/` -- **Class**: `Dialog` -- **Recipe**: `WindSlotRecipe` in `dialog.recipe.dart` -- **Slots**: `backdrop`, `panel`, `title`, `footer` -- **Token bindings**: - - backdrop: `bg-fg/50` (semi-transparent fg overlay) - - panel: `bg-surface rounded-lg shadow-xl max-w-md w-full` - - title: `text-fg font-semibold text-lg` - - footer: `flex gap-3 justify-end pt-4` -- **Anti-patterns**: - - Always use `Dialog.show()` static factory; do not push dialogs as routes. - - Keep dialog content focused; avoid multi-step flows inside a single dialog. - ---- - -### ConfirmDialog - -- **File**: `lib/ui/components/confirm_dialog/` -- **Class**: `ConfirmDialog` -- **Enum**: `ConfirmDialogVariant` -- **Recipe**: `WindSlotRecipe` in `confirm_dialog.recipe.dart` -- **Variants**: - - `variant`: `primary` | `danger` | `warning` -- **Token bindings**: - - `danger`: confirm button uses `Button(intent: ButtonIntent.destructive)` - - `warning`: confirm button uses `Button(intent: ButtonIntent.secondary)` with warning badge - - `primary`: confirm button uses `Button(intent: ButtonIntent.primary)` -- **Anti-patterns**: - - Use `danger` for irreversible destructive actions only (account deletion, data wipe). - - Do not use `warning` for routine confirmation; reserve it for significant but reversible changes. - ---- - -### BottomSheet - -- **File**: `lib/ui/components/bottom_sheet/` -- **Class**: `BottomSheet` -- **Recipe**: `WindSlotRecipe` in `bottom_sheet.recipe.dart` -- **Slots**: `backdrop`, `panel`, `handle`, `title`, `footer` -- **Token bindings**: - - panel: `bg-surface rounded-t-xl` - - handle: `bg-surface-container-high rounded-full` -- **Anti-patterns**: - - Respect `SafeArea` at the bottom for home indicator. - - Do not embed complex multi-step flows; keep to contextual actions. - ---- - -### Toast - -- **File**: `lib/ui/components/toast/` -- **Class**: `Toast` -- **Recipe**: `WindRecipe` in `toast.recipe.dart` -- **Variants**: - - `tone`: `neutral` | `success` | `warning` | `destructive` -- **Token bindings**: - - `neutral`: `bg-surface border border-color-border text-fg` - - `success`: `bg-success text-on-primary` - - `warning`: `bg-warning text-on-primary` - - `destructive`: `bg-destructive text-on-destructive` -- **Anti-patterns**: - - Use for non-critical feedback only; critical errors belong in a dialog or inline error state. - - Auto-dismiss after 4-6 seconds unless action is required. - ---- - -### Tooltip - -- **File**: `lib/ui/components/tooltip/` -- **Class**: `Tooltip` -- **Recipe**: `WindSlotRecipe` in `tooltip.recipe.dart` -- **Slots**: `trigger`, `content` -- **Token bindings**: - - content: `bg-fg text-surface text-xs rounded-md px-2 py-1` -- **Anti-patterns**: - - Do not use tooltips for essential information; they are invisible on touch devices. - - WPopover real-click dismiss race is a known issue; do not add Tooltip to interactive paths that require precise tap timing. - ---- - -### DropdownMenu - -- **File**: `lib/ui/components/dropdown_menu/` -- **Class**: `DropdownMenu` -- **Recipe**: `WindSlotRecipe` in `dropdown_menu.recipe.dart` -- **Slots**: `trigger`, `panel`, `item`, `separator` -- **Token bindings**: - - panel: `bg-surface border border-color-border rounded-md shadow-sm` - - item: `text-sm text-fg hover:bg-surface-container-high` - - separator: `border-t border-color-border my-1` -- **Anti-patterns**: - - Do not use for primary navigation (use `Navbar` or `Tabs`). - - WPopover real-click dismiss race is a known issue; do not regress dismiss behavior. - ---- - -## Structure - -### FormField - -- **File**: `lib/ui/components/form_field/` -- **Class**: `FormField` (exported as `MagicFormField` to avoid collision with Flutter's `FormField`) -- **Recipe**: `WindSlotRecipe` in `form_field.recipe.dart` -- **Slots**: `root`, `label`, `hint`, `error` -- **Token bindings**: - - root: `flex flex-col gap-1` - - label: `text-sm font-medium text-fg` - - hint: `text-xs text-fg-muted` - - error: `text-xs text-destructive` -- **Anti-patterns**: - - Always wrap `Input`/`Textarea` in `MagicFormField`; never render label/error inline. - - Import as `MagicFormField` to avoid collision with Flutter's `FormField` widget. - ---- - -### PageHeader - -- **File**: `lib/ui/components/page_header/` -- **Class**: `PageHeader` -- **Recipe**: `WindSlotRecipe` in `page_header.recipe.dart` -- **Slots**: `title`, `subtitle`, `leading`, `actions`, `inlineActions` -- **Token bindings**: - - title: `text-xl font-bold text-fg` - - subtitle: `text-sm text-fg-muted` -- **Anti-patterns**: - - Do not add navigation chrome inside `PageHeader`; it is a content title, not an app bar. - ---- - -### EmptyState - -- **File**: `lib/ui/components/empty_state/` -- **Class**: `EmptyState` -- **Recipe**: `WindSlotRecipe` in `empty_state.recipe.dart` -- **Slots**: `root`, `iconWrap`, `title`, `description`, `action` -- **Token bindings**: - - iconWrap: `text-fg-disabled` - - title: `text-fg font-semibold text-lg` - - description: `text-fg-muted text-sm` -- **Anti-patterns**: - - Always include a call-to-action in the `action` slot; an empty state without an action is a dead end. - - Hide filters, tabs, or sorting controls that do not apply when the list is empty. - ---- - -### ErrorState - -- **File**: `lib/ui/components/error_state/` -- **Class**: `ErrorState` -- **Recipe**: `WindSlotRecipe` in `error_state.recipe.dart` -- **Slots**: `root`, `iconWrap`, `title`, `description`, `action` -- **Token bindings**: - - iconWrap: `text-destructive` - - title: `text-red-700 dark:text-red-400 font-semibold text-lg` - - description: `text-fg-muted text-sm` -- **Anti-patterns**: - - Use for unrecoverable states; for recoverable network errors, show a retry button in the `action` slot. - ---- - -### Navbar - -- **File**: `lib/ui/components/navbar/` -- **Class**: `Navbar` -- **Recipe**: `WindSlotRecipe` in `navbar.recipe.dart` -- **Slots**: `root`, `item`, `activeItem` -- **Token bindings**: - - root: `bg-surface border-t border-color-border` - - item inactive: `text-fg-muted` - - item active (`selected:`): `text-primary` -- **Anti-patterns**: - - Limit to 3-5 primary destinations. - - Do not place secondary actions in the bottom nav; use `DropdownMenu` or a settings page. - ---- - -## Composites - -### SocialDivider - -- **File**: `lib/ui/components/social_divider/` -- **Class**: `SocialDivider` -- **Token bindings**: `border-color-border text-fg-muted` -- **Anti-patterns**: - - Use only on auth screens to separate email login from social login options. - ---- - -### NotificationDropdown - -- **File**: Composite consuming `DropdownMenu` + `Badge` -- **Token bindings**: inherits from composites. -- **Anti-patterns**: - - Do not change the `StreamBuilder` unread-count subscription pattern; it is intentional. - ---- - -### UserProfileDropdown - -- **File**: Composite consuming `DropdownMenu` -- **Anti-patterns**: - - Do not add business logic to the dropdown; route to profile/settings views. - ---- - -### TeamSelector - -- **File**: Composite consuming `Select` or `DropdownMenu` -- **Anti-patterns**: - - Keep team-switch callback through `teamResolver`; do not hard-wire team ID. - ---- - -## Anti-patterns (global) - -| Anti-pattern | Category | Fix | -|-------------|----------|-----| -| Raw `Color(0xFF...)` or `Colors.*` in recipe or widget | Token violation | Use semantic alias (e.g. `bg-primary`) | -| Hardcoded pixel margin (`SizedBox(height: 13)`) | Spacing violation | Use Wind spacing utilities on the 4px scale | -| Multiple preview classes in one file | Preview structure | One `*.preview.dart` per component | -| Exporting preview class from `index.dart` | Preview boundary | `previews:refresh` discovers `*.preview.dart` directly | -| Importing `package:fluttersdk_wind/src/...` directly | Import convention | Use `package:magic/magic.dart` (re-exports wind) | -| Using Material `Switch`, `Checkbox`, `Radio`, `TabBar` | Primitive collision | Use the project component equivalents | -| CSS-only Wind utilities (`box-shadow`, `filter`, `transform`) | Wind unsupported | Use Flutter animation APIs | -| `Icons.*` inline in widget body | Tree-shaking | Extract as `static const IconData _icon = Icons.x;` | -| Missing `dark:` on any color token | Dark parity | Every alias expands to a light+dark pair | +Every component this app owns, so that a screen reaches for one that exists instead of +scaffolding a second one. `AGENTS.md` requires reading this before writing any widget, +which only works if it describes what is actually on disk. + +**Do not edit this file.** Run `bin/sync-registry` after adding or changing a component; +`bin/check` fails when it is out of date. It was hand-maintained once and drifted into +documenting a different package's library entirely, while every component here went +unlisted. A registry that can be wrong is worse than no registry, because it is trusted. + +## Look in `magic_starter` first + +The generic layer lives in the package, not here: `MSButton`, `MSInput`, `MSSelect`, +`MSCheckbox`, `MSSwitch`, `MSCard`, `MSBadge`, `MSTabs`, `MSSegmentedControl`, +`MSBottomSheet`, `MSEmptyState`, `MSPageScaffold`, `MSPageHeader` and `MSPageContainer`. +A component belongs in this app only when it encodes something a generic library could +not. The three below are examples for a fork to read and then replace, not a library to +grow. + +## The components this app owns + +| Folder | Class | Variant enums | Recipe | Preview | index.dart | What it is | +|---|---|---|---|---|---|---| +| `callout/` | `Callout` | CalloutIntent | yes | yes | yes | An inline note with a title and message, tinted by intent. | +| `stat_card/` | `StatCard` | - | yes | yes | yes | A generic dashboard stat: a label, a value, and an optional delta line, built from semantic alias tokens. | +| `tag/` | `Tag` | TagIntent, TagSize | yes | yes | yes | A compact pill for category or status labels, demonstrating a two-axis [WindRecipe] (intent x size). | + +3 components. A bold cell is a rule violation rather than a note: +`.claude/rules/design.md` requires exactly one preview per component and an `index.dart` +that exports the class and its recipe but never the preview. + +## Screen previews + +Whole-screen entries in the `/preview` catalog, which is why they sit outside the +component folders. They compose the components above rather than defining any. + +- `lib/preview/bottom_menu.preview.dart` +- `lib/preview/dashboard_screen.preview.dart` +- `lib/preview/foundations.preview.dart` +- `lib/preview/login_screen.preview.dart` +- `lib/preview/profile_screen.preview.dart` +- `lib/preview/register_screen.preview.dart` +- `lib/preview/settings_screen.preview.dart` +- `lib/preview/sidebar_menu.preview.dart` +- `lib/preview/teams_screen.preview.dart`