Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .ai/guidelines/development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Local Development

- The starter kit is a **Laravel 13 + Inertia + React + Lattice** application; Lattice provides the server-driven UI.
PHP `^8.4`, Node `>=22`.
- First-time setup: `composer setup` (installs dependencies, copies `.env`, generates the app key, migrates, installs
npm dependencies, builds the frontend). Day to day, run `composer dev` — Laravel's `artisan dev` starts the server,
queue worker, Reverb, Pail, and Vite together.
- The frontend is **not** an Inertia-React `resources/js/Pages` app. Pages, layouts, forms, tables, and actions are PHP
classes (`#[AsPage]`, `#[AsLayout]`, `#[AsForm]`, `#[AsTable]`, `#[AsAction]`) rendered by the Lattice runtime. The
only hand-written React lives in `resources/js/components/`, registered by string key in `resources/js/app.tsx`.
Reach for the `lattice-forms`, `lattice-tables`, `lattice-actions`, and `lattice-closures` skills before inferring a
builder API from a sibling class.
- After changing a custom Lattice wire type (a `#[AsComponent]`/`#[AsField]`/`#[AsColumn]` class), regenerate its
TypeScript with `php artisan lattice:typescript` and commit `resources/js/lattice/generated.d.ts`. CI fails on a
stale file.
- Path-scoped conventions live in `.ai/rules/`; `index.md` maps globs to rule files. Read the rules matching the paths
you are about to touch before planning or editing.
- Regenerate `CLAUDE.md` and `AGENTS.md` after editing `.ai/guidelines/` with `php artisan boost:update`. Both files
are git-ignored and generated by Laravel Boost; `composer update` runs `boost:update` for you.

## Verification

- Git hooks enforce the local gate. `composer install` and `composer update` point `core.hooksPath` at `.githooks`;
if the hooks are not active, run `composer hooks:install` once.
- **pre-commit** auto-fixes staged PHP with Rector and Pint, staged TypeScript with oxfmt and oxlint, staged
Markdown with oxfmt, re-stages the fixes, and blocks on what is left. A file that is both staged and dirty is
only checked, never rewritten.
- **pre-push** runs the gate scoped to what the push changes: the frontend checks in the background, PHPStan in the
foreground, then `composer test` and `npm run build`.
- `composer ci:check` mirrors CI in one command; `composer check:full` adds the library build.
- Static analysis is **PHPStan level 8 over `app/` and `tests/`, with no baseline**. Keep it at zero.
- `composer rector:check` dry-runs the automated refactors; `composer rector:fix` applies them.
- oxfmt formats Markdown as well as TypeScript, so every `.md` in the repo is covered by `npm run format:check`.
- Never push on red. Use `git commit --no-verify` or `git push --no-verify` only in emergencies.

## Running tests

- Tests are **Pest**, in three suites: `tests/Unit`, `tests/Feature`, and `tests/Browser`. `composer test` runs Unit
and Feature in parallel; `composer test:browser` runs the browser suite.
- Browser tests drive a real headless Chromium through `pestphp/pest-plugin-browser`. They need the browsers installed
once (`npx playwright install chromium`) and a current `npm run build` — they serve the built bundle, not the dev
server.
- How to _write_ a test lives in `.ai/rules/testing.md`.

## Comments

- Code must be self-explanatory: reach for clear names, small functions, and types before a comment.
- Do not add comments. A comment is a last resort and explains only _why_ something is done, never _what_ the code does.
- When you encounter an obsolete, redundant, or "what" comment, delete it.
- Keep PHPDoc only when it carries type information, public API intent, static-analysis value, or a non-obvious
constraint. Keep comments that explain framework quirks, ordering requirements, or cache and build behavior.
8 changes: 8 additions & 0 deletions .ai/guidelines/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Git & Pull Requests

- Never credit the agent. No `Co-Authored-By` trailer and no "generated by" attribution in commit messages or pull
request descriptions.
- Make meaningful commits: one logical change per commit with a conventional-commit subject (`feat:`, `fix:`, `chore:`,
`refactor:`, `test:`, `docs:`). Squash throwaway "wip" commits before opening a PR.
- Keep pull request descriptions compact: what changed and why, in a few lines. No filler.
- For visual changes, include screenshots or concrete before/after examples in the PR description.
30 changes: 30 additions & 0 deletions .ai/rules/context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
paths:
- "app/Providers/**"
- "app/Models/**"
---

# Context resolvers

## One place registers them

`AppServiceProvider::registerLatticeContext()` is the only place `Lattice::context()` is called. A key registered
there resolves at most once per request and cascades into every child component, so adding a key is how you make a
record reachable from a definition.

## Dependent keys resolve inside their parent

`member`, `invitation`, and `passkey` resolve through the record that owns them — the team, or the signed-in user —
rather than by a bare `findOrFail()` on the id. That scoping is what makes a forged id in a sealed reference a 404
instead of another team's record. Keep it when adding a key.

## Give a closure resolver a concrete return type

Lattice reads the declared return type to map a bound route model to its context key, which is what lets
`render(PageSchema $schema, Team $team)` seed the `team` frame. A resolver without one needs an explicit
`model: Team::class`.

## Model docblocks are generated

The `@property` blocks come from `barryvdh/laravel-ide-helper`. Where the generator is wrong about nullability — a
`NOT NULL` foreign key it types as `|null` — correct it in place rather than working around it at every call site.
31 changes: 31 additions & 0 deletions .ai/rules/frontend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
paths:
- "resources/js/**"
- "resources/css/**"
- "vite.config.ts"
---

# Frontend

## There are no page components

`resources/js/app.tsx` boots `createLatticeApp` and registers the handful of custom React components by string key.
A new screen is a PHP page class, not a `.tsx` file. Write React only for behavior the wire format cannot express —
the passkey components are the example: they talk to `@laravel/passkeys` in the browser.

## Import from the package that owns the component

Form controls (`Input`, `Label`, `InputError`, …) live in `@lattice-php/form`; layout and display components live in
`@lattice-php/ui`; the app runtime and `RendererComponent` come from `@lattice-php/lattice`. Both packages are direct
dependencies — do not reach through the umbrella for something it does not re-export.

## Wire types are generated

`resources/js/lattice/generated.d.ts` declares the props of every `#[AsComponent]` class. Do not hand-write a
`declare module "@lattice-php/core"` block for a component that has a PHP counterpart; run
`php artisan lattice:typescript` instead.

## Lattice owns the design tokens

`@import '@lattice-php/lattice/css'` pulls in the token layer. Override a token unlayered on `:root` so it wins
regardless of import order; do not restate Lattice's own defaults.
14 changes: 14 additions & 0 deletions .ai/rules/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Project Rules Index

Before planning or editing, find the row whose globs match the file's path and read that rule file.

Every glob is a code span. A bare `*` in a table cell reads as markdown emphasis, and a formatter will rewrite
`app/*/Concerns/**` into `app/_/Concerns/\**` — silently breaking the mapping this file exists to carry.

| Applies to | Rule file |
| --- | --- |
| `app/Actions/**`, `app/Forms/**`, `app/Tables/**`, `app/Fragments/**`, `app/Pages/**`, `app/Layouts/**` | .ai/rules/lattice.md |
| `app/Providers/**`, `app/Models/**` | .ai/rules/context.md |
| `resources/js/**`, `resources/css/**`, `vite.config.ts` | .ai/rules/frontend.md |
| `tests/**` | .ai/rules/testing.md |
| `lang/**` | .ai/rules/translations.md |
55 changes: 55 additions & 0 deletions .ai/rules/lattice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
paths:
- "app/Actions/**"
- "app/Forms/**"
- "app/Tables/**"
- "app/Fragments/**"
- "app/Pages/**"
- "app/Layouts/**"
---

# Lattice definitions

## Read context, never route parameters or session state

A definition runs twice: once while the page renders, and again on its own signed endpoint, which carries no route
parameters. Read records with `$this->contextModel('team')` (aborts 404 when the key is absent or resolves to
nothing) or `$this->contextModelOrNull('team')` (returns null, for render-time gates). Never reach for
`$request->route(...)` inside a definition.

The keys are registered once in `AppServiceProvider::registerLatticeContext()`. A key with a resolver cascades into
every child component Lattice builds, so a page that types `Team $team` in `render()` already seeds `team` for the
forms, tables, and actions on it — do not pass `['team' => $team->slug]` by hand.

## Declare authorization on the attribute

Put the ability and its subject on the definition attribute rather than in an `authorize()` body:

```php
#[AsForm('teams.update', can: 'update', on: 'team')]
#[AsAction('teams.members.remove', can: 'removeMember', on: 'team')]
```

`on` names a registered context key; Lattice resolves the subject before the definition runs and denies a missing
one. Keep `authorize()` only for conditions a gate subject cannot express. A plain component takes the same pair as
a method: `Heading::make(…)->can('update', on: 'team')`.

A page's `can` gates who may load the page; it does **not** gate the definitions rendered on it. Every definition
needs its own declaration.

## Page middleware merges with the config default

`config('lattice.pages.middleware')` is `['web']` and attribute middleware is appended, never substituted. Write
`middleware: ['auth', 'verified']`, not `['web', 'auth', 'verified']`.

## handle() takes validated data

Lattice validates a form or action form before `handle()` runs and passes the result in. Declare
`handle(FormData $data)` and read through `$data->string('name')`, `$data->enum('role', TeamRole::class)`, and the
rest of the `ValidatedInput` API. Never call `$this->validate($request)` yourself, and never read raw request input
for a field the schema declares.

## An empty ActionGroup still renders

Lattice drops an unauthorized row action, but the `ActionGroup` wrapping it is a plain container and would render as
an empty dropdown. Build the action list first and return `[]` from `actions()` when nothing survives.
62 changes: 62 additions & 0 deletions .ai/rules/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
paths:
- "tests/**"
---

# Testing

## Pest, with RefreshDatabase applied globally

`tests/Pest.php` binds `Tests\TestCase` plus `RefreshDatabase` to the `Feature` suite and `Tests\BrowserTestCase` to
the `Browser` suite. Never re-add `uses(RefreshDatabase::class)` in a test file.

`Tests\TestCase` stubs Vite (`withoutVite()`): a feature test renders a real route, and `@vite` throws without a
built manifest. That is what keeps the Feature suite off the node toolchain. `Tests\BrowserTestCase` turns the stub
off, because a browser test drives the built bundle.

## Prefer feature tests

Exercise the application through HTTP endpoints, Lattice forms, actions and tables, jobs, events, policies, and
database effects. Use unit tests only for complex pure algorithms or small deterministic value objects.

## Lattice UI is exercised via InteractsWithLatticeComponents

`Tests\TestCase` carries the trait: `submitForm(FormClass, $data, $context)`, `callAction(ActionClass, $data,
$context)`, `loadTable(TableClass, $query, $context)`. A definition whose gate denies the actor is **hidden** at
render time, so the normal helpers refuse to seal it — assert the 403 with the `…Denied` variants
(`submitDeniedForm`, `callDeniedAction`, `loadDeniedTable`) instead.

Where a context resolver scopes a lookup, an unauthorized reference is a **404**, not a 403: the record does not
exist for that actor.

## What a test must earn

Every test asserts an observable behavior change caused by an interaction, input, or state transition. Delete on
sight:

- **Render-only tests** — a page loads and shows static text, with no interaction.
- **Styling pins** — assertions on Tailwind utility classes. Assert semantic state instead.
- **Absence-only assertions** — `assertDontSee()` on initial render, unless the same test establishes the positive
case too.
- **Tautologies** — asserting a factory returns what it was configured with.
- **Duplicated coverage** — every behavior has exactly one owning test. Do not re-assert Lattice's own contract
(field validation, pagination internals) in every consumer.

## Keep library behavior in its owning library

Generic Lattice or framework behavior belongs in that package's suite, even when the regression first showed up
here. Test this app's configuration, composition, and observable integration behavior.

## tests/ is analysed at PHPStan level 8, with no baseline

Narrow a nullable at the source: prefer `->refresh()` (returns `$this`) over `->fresh()` (returns `?static`), reach
for `findOrFail()`/`firstOrFail()`, and use the `personalTeam()` helper in `tests/Pest.php` where a factory
guarantees the record. Never silence an error with `@phpstan-ignore`, a baseline entry, `assert()`, an inline
`@var`, or a cast.

## Browser tests for client-only behavior

UI behavior that is not about an endpoint's payload goes in `tests/Browser`. They serve the built bundle, so run
`npm run build` first. Target components by their `data-test` attribute — Lattice writes the node's full identity
(`action-teams.3.edit`), or the value of an explicit `->key()`. Adding a stable `key()` to make an assertion clearer
is fine.
34 changes: 34 additions & 0 deletions .ai/rules/translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
paths:
- "lang/**"
---

# Translations

## Lowercase keys only

Key segments use lowercase letters, numbers, and dashes. Never camelCase — `teams.invite.already-member`, not
`teams.invite.alreadyMember`.

## Dot notation via nested arrays

Use nested PHP arrays to build dot-separated keys: `'invite' => ['submit' => '...']` resolves to
`teams.invite.submit`. Dots express hierarchy; keep dashes for compound terms (`email-address`, `recovery-codes`).

## Suffixes for secondary strings

`.label` for a form label that also has helper text, `.help-text` for the helper text, `.title`/`.body` for a
notification that has both.

## common.* for reusable strings

Shared field labels (`common.field.email-address`), actions (`common.action.save`), and statuses live in
`lang/{locale}/common.php`.

## File naming

Translation files use kebab-case filenames matching the area (`teams.php`, `settings.php`, `navigation.php`).

## Always update both locales

Every addition or change lands in `lang/en/` **and** `lang/de/`.
80 changes: 80 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/bin/sh
set -e

root="$(git rev-parse --show-toplevel)"
cd "$root"

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# A file that is staged AND dirty carries hunks the author deliberately held
# back. Formatting it in place and re-staging would sweep those into the commit,
# so those files are only checked and the commit is refused instead.
split_staged() {
kind="$1"
shift
git diff --cached --name-only --diff-filter=ACMR -- "$@" | sort >"$tmp/staged.$kind"
git diff --name-only -- "$@" | sort >"$tmp/dirty.$kind"
comm -23 "$tmp/staged.$kind" "$tmp/dirty.$kind" >"$tmp/fix.$kind"
comm -12 "$tmp/staged.$kind" "$tmp/dirty.$kind" >"$tmp/check.$kind"
}

# NUL-delimited so paths with spaces survive.
run() {
list="$1"
shift
tr '\n' '\0' <"$list" | xargs -0 "$@"
}

has() {
[ -s "$1" ]
}

split_staged php '*.php'
split_staged ts '*.ts' '*.tsx' ':(exclude)resources/js/types/sprite-icons.ts'
split_staged md '*.md'

if has "$tmp/fix.php"; then
printf 'rector + pint (staged PHP)\n'
# Rector before pint: rector's output is not pint-clean, pint normalizes it.
run "$tmp/fix.php" ./vendor/bin/rector process --config=rector.php --no-progress-bar
run "$tmp/fix.php" ./vendor/bin/pint
run "$tmp/fix.php" git add
fi

if has "$tmp/fix.ts"; then
printf 'oxfmt + oxlint (staged TypeScript)\n'
run "$tmp/fix.ts" ./node_modules/.bin/oxfmt --write
run "$tmp/fix.ts" ./node_modules/.bin/oxlint --fix --no-error-on-unmatched-pattern
run "$tmp/fix.ts" git add
run "$tmp/fix.ts" ./node_modules/.bin/oxlint --no-error-on-unmatched-pattern
fi

if has "$tmp/fix.md"; then
printf 'oxfmt (staged Markdown)\n'
run "$tmp/fix.md" ./node_modules/.bin/oxfmt --write
run "$tmp/fix.md" git add
fi

if has "$tmp/check.php" || has "$tmp/check.ts" || has "$tmp/check.md"; then
printf '\npartially staged — checked, not fixed:\n'
cat "$tmp/check.php" "$tmp/check.ts" "$tmp/check.md"

failed=0
if has "$tmp/check.php"; then
run "$tmp/check.php" ./vendor/bin/pint --test || failed=1
run "$tmp/check.php" ./vendor/bin/rector process --config=rector.php --dry-run --no-progress-bar || failed=1
fi
if has "$tmp/check.ts"; then
run "$tmp/check.ts" ./node_modules/.bin/oxfmt --check || failed=1
run "$tmp/check.ts" ./node_modules/.bin/oxlint --no-error-on-unmatched-pattern || failed=1
fi
if has "$tmp/check.md"; then
run "$tmp/check.md" ./node_modules/.bin/oxfmt --check || failed=1
fi

if [ "$failed" -ne 0 ]; then
printf '\nStage or stash the rest of these files, then commit again.\n' >&2
exit 1
fi
fi
Loading