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
8 changes: 8 additions & 0 deletions .ai/rules/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ dependencies — do not reach through the umbrella for something it does not re-
`declare module "@lattice-php/core"` block for a component that has a PHP counterpart; run
`php artisan lattice:typescript` instead.

## Tailwind has to be pointed at the Lattice packages

`@lattice-php/ui/css` sources its own `dist`, and the Vite plugin sources every discovered _component_ package — but
neither covers the sibling runtime packages. `@lattice-php/core` owns the responsive-visibility wrapper
(`hidden md:contents`), so without the `@source '../../node_modules/@lattice-php/*/dist'` glob in `app.css`,
`visibleFrom()`/`hiddenFrom()` compile to a class that does not exist and hide the node at every width. Keep the glob,
and suspect it first when a Lattice utility class silently does nothing.

## Lattice owns the design tokens

`@import '@lattice-php/lattice/css'` pulls in the token layer. Override a token unlayered on `:root` so it wins
Expand Down
6 changes: 6 additions & 0 deletions .ai/rules/lattice.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Lattice validates a form or action form before `handle()` runs and passes the re
rest of the `ValidatedInput` API. Never call `$this->validate($request)` yourself, and never read raw request input
for a field the schema declares.

## A table calls actions() once per row

An unmemoized `$user->can(...)` in `actions()` is a query per row. `App\Tables\Concerns\AuthorizesRowActions` caches
the answer on the definition instance, which lives exactly one render — the window in which it cannot change. Reach
for it rather than a cache on the model, where a later `attach()` would leave it stale.

## An empty ActionGroup still renders

Lattice drops an unauthorized row action, but the `ActionGroup` wrapping it is a plain container and would render as
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ There are no page components to write. A screen is a PHP class:
| `app/Forms/` | `#[AsForm]` classes — fields, validation, and submit handling |
| `app/Tables/` | `#[AsTable]` classes — columns, sources, and row actions |
| `app/Actions/` | `#[AsAction]` classes — server-side effects (toast, redirect, reload) |
| `app/Components/` | custom wire components, plus the `PageHeader` factory pages open with |
| `resources/js/components/` | the handful of custom React components, registered in `app.tsx` |

Records reach a definition through Lattice's context registry rather than through route parameters —
Expand Down
38 changes: 38 additions & 0 deletions app/Components/PageHeader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);

namespace App\Components;

use Lattice\Ui\Components\Heading;
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Components\Text;
use Lattice\Ui\Enums\Gap;

/**
* The title block a page or one of its sections opens with. A factory rather
* than a wire component: it composes existing components and adds nothing the
* client has to know about.
*/
final class PageHeader
{
public static function make(string $key, string $title, ?string $description = null): Stack
{
return self::build($key, $title, $description, 1);
}

public static function section(string $key, string $title, ?string $description = null): Stack
{
return self::build($key, $title, $description, 2);
}

private static function build(string $key, string $title, ?string $description, int $level): Stack
{
$schema = [Heading::make($title, $level)];

if ($description !== null) {
$schema[] = Text::make($description);
}

return Stack::make($key)->gap(Gap::Small)->schema($schema);
}
}
52 changes: 37 additions & 15 deletions app/Layouts/AppLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
use App\Pages\SettingsPage;
use Illuminate\Http\Request;
use Lattice\Core\Attributes\AsLayout;
use Lattice\Core\Enums\Breakpoint;
use Lattice\Core\Enums\ColorName;
use Lattice\Core\Support\Affix;
use Lattice\Facades\Effects;
use Lattice\Layouts\Components\Outlet;
use Lattice\Layouts\LayoutDefinition;
use Lattice\Ui\Components\Avatar;
use Lattice\Ui\Components\Breadcrumbs;
use Lattice\Ui\Components\Button;
use Lattice\Ui\Components\Callouts;
use Lattice\Ui\Components\Component;
use Lattice\Ui\Components\Dropdown;
use Lattice\Ui\Components\Icon as IconComponent;
Expand All @@ -24,15 +29,17 @@
use Lattice\Ui\Components\Sidebar;
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Components\Text;
use Lattice\Ui\Components\Topbar;
use Lattice\Ui\Enums\Align;
use Lattice\Ui\Enums\AvatarShape;
use Lattice\Ui\Enums\Emphasis;
use Lattice\Ui\Enums\Gap;
use Lattice\Ui\Enums\Height;
use Lattice\Ui\Enums\HttpMethod;
use Lattice\Ui\Enums\Icon;
use Lattice\Ui\Enums\Justify;
use Lattice\Ui\Enums\Orientation;
use Lattice\Ui\Enums\Placement;
use Lattice\Ui\Enums\Side;
use Lattice\Ui\Enums\Size;
use Lattice\Ui\Enums\Width;
use Lattice\Ui\PageSchema;
Expand All @@ -51,23 +58,37 @@ public function schema(PageSchema $schema, Request $request): PageSchema
->direction(Orientation::Horizontal)
->height(Height::Screen)
->schema([
Sidebar::make('app-sidebar')->collapsible()->items([
Stack::make('sidebar-body')
->width(Width::Fill)
->justify(Justify::Between)
->schema([
Stack::make('sidebar-top')->schema([
$this->teamSwitcher($user),
Menu::make('sidebar')->items([
MenuItem::fromPage(DashboardPage::class)->label(__('navigation.dashboard'))->prefix(Icon::LayoutDashboard),
]),
]),
$this->userMenu($user),
Sidebar::make('app-sidebar')
->collapsible()
->items([
$this->teamSwitcher($user),
Menu::make('sidebar')->items([
MenuItem::fromPage(DashboardPage::class)->label(__('navigation.dashboard'))->prefix(Icon::LayoutDashboard),
]),
]),
]),
Stack::make('app-main')
->width(Width::Fill)
->schema([
Topbar::make('app-topbar')->sticky()->items([
// The sidebar ships no trigger of its own: without this
// button the mobile drawer can never open and the desktop
// rail can never collapse.
Button::make(__('navigation.toggle-sidebar'), 'sidebar-toggle')
->icon(Icon::PanelLeft)
->emphasis(Emphasis::Ghost)
->effects(Effects::toggleSidebar('app-sidebar')),
Breadcrumbs::make('app-breadcrumbs')->visibleFrom(Breakpoint::Md),
Stack::make('topbar-end')
->float(Side::End)
->width(Width::Auto)
->direction(Orientation::Horizontal)
->align(Align::Center)
->gap(Gap::Small)
->schema([
$this->userMenu($user),
]),
]),
Callouts::make('app-callouts'),
Outlet::make(),
]),
]),
Expand Down Expand Up @@ -100,7 +121,7 @@ private function teamSwitcher(User $user): Dropdown
private function userMenu(User $user): Dropdown
{
return Dropdown::make('user-menu')
->placement(Placement::Top)
->placement(Placement::Bottom)
->trigger([
Stack::make()
->direction(Orientation::Horizontal)
Expand All @@ -111,6 +132,7 @@ private function userMenu(User $user): Dropdown
Stack::make()
->width(Width::Fill)
->gap(Gap::None)
->visibleFrom(Breakpoint::Md)
->schema([
Text::make($user->name)
->size(Size::Sm)
Expand Down
3 changes: 2 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,12 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;

/**
* @return BelongsToMany<Team, $this>
* @return BelongsToMany<Team, $this, Membership, 'pivot'>
*/
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_members', 'user_id', 'team_id')
->using(Membership::class)
->withPivot(['role'])
->withTimestamps();
}
Expand Down
19 changes: 19 additions & 0 deletions app/Pages/AppPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);

namespace App\Pages;

use App\Pages\Concerns\ListensForUserNotifications;
use Lattice\Core\Attributes\AsPage;
use Lattice\Core\Enums\PageLayout;
use Lattice\Http\Page;

/**
* The signed-in shell. Layout and middleware are inherited by every concrete
* page, which then only declares its own route, name, and gate.
*/
#[AsPage(layout: PageLayout::App, middleware: ['auth', 'verified'])]
abstract class AppPage extends Page
{
use ListensForUserNotifications;
}
31 changes: 15 additions & 16 deletions app/Pages/DashboardPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,28 @@

namespace App\Pages;

use App\Components\PageHeader;
use App\Concerns\ResolvesCurrentUser;
use App\Http\Middleware\SwitchesCurrentTeam;
use App\Models\Team;
use App\Pages\Concerns\ListensForUserNotifications;
use Illuminate\Http\Request;
use Lattice\Core\Attributes\AsPage;
use Lattice\Core\Breadcrumb;
use Lattice\Core\Enums\PageLayout;
use Lattice\Http\Page;
use Lattice\Ui\Components\Card;
use Lattice\Ui\Components\Grid;
use Lattice\Ui\Components\Heading;
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Components\Text;
use Lattice\Ui\Enums\Gap;
use Lattice\Ui\Enums\Width;
use Lattice\Ui\PageSchema;

#[AsPage(route: '{current_team}/dashboard', name: 'dashboard', layout: PageLayout::App, middleware: ['auth', 'verified', SwitchesCurrentTeam::class], can: 'view', on: 'current_team')]
class DashboardPage extends Page
#[AsPage(
route: '{current_team}/dashboard',
name: 'dashboard',
middleware: ['auth', 'verified', SwitchesCurrentTeam::class],
can: 'view',
on: 'current_team',
)]
class DashboardPage extends AppPage
{
use ListensForUserNotifications;
use ResolvesCurrentUser;

private ?Team $team = null;
Expand All @@ -51,7 +51,7 @@ public function breadcrumbs(): array
];
}

public function render(PageSchema $schema, Request $request, Team $current_team): PageSchema
public function render(PageSchema $schema, Team $current_team): PageSchema
{
$user = $this->currentUser();

Expand All @@ -62,12 +62,11 @@ public function render(PageSchema $schema, Request $request, Team $current_team)
->gap(Gap::Large)
->width(Width::Large)
->schema([
Stack::make('dashboard-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('dashboard.heading'), 1),
Text::make(__('dashboard.welcome', ['name' => $user->name, 'team' => $current_team->name])),
]),
PageHeader::make(
'dashboard-heading',
__('dashboard.heading'),
__('dashboard.welcome', ['name' => $user->name, 'team' => $current_team->name]),
),
Grid::make('dashboard-overview')
->columns(3)
->schema([
Expand Down
46 changes: 10 additions & 36 deletions app/Pages/SettingsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,19 @@
use App\Actions\Settings\DisableTwoFactorAuthenticationAction;
use App\Actions\Settings\EnableTwoFactorAuthenticationAction;
use App\Actions\Settings\RegenerateRecoveryCodesAction;
use App\Components\PageHeader;
use App\Components\Settings\PasskeyRegistration;
use App\Concerns\ResolvesCurrentUser;
use App\Forms\Settings\DeleteAccountForm;
use App\Forms\Settings\PasswordSettingsForm;
use App\Forms\Settings\ProfileSettingsForm;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use App\Pages\Concerns\ListensForUserNotifications;
use App\Tables\Settings\PasskeysTable;
use Illuminate\Http\Request;
use Laravel\Fortify\Features;
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsPage;
use Lattice\Core\Enums\PageLayout;
use Lattice\Form\Components\Form;
use Lattice\Http\Page;
use Lattice\Table\Components\Table;
use Lattice\Ui\Components\Component;
use Lattice\Ui\Components\Heading;
Expand All @@ -33,10 +31,11 @@
use Lattice\Ui\Enums\Width;
use Lattice\Ui\PageSchema;

#[AsPage(route: 'settings', name: 'settings.edit', layout: PageLayout::App, middleware: ['auth'])]
class SettingsPage extends Page
// No `verified` here, unlike every other app page: the profile tab is where an
// unverified user resends their verification mail.
#[AsPage(route: 'settings', name: 'settings.edit', middleware: ['auth'])]
class SettingsPage extends AppPage
{
use ListensForUserNotifications;
use ResolvesCurrentUser;

public function title(): string
Expand All @@ -55,12 +54,7 @@ public function render(PageSchema $schema, TwoFactorAuthenticationRequest $reque
->gap(Gap::Large)
->width(Width::Medium)
->schema([
Stack::make('settings-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('settings.heading'), 1),
Text::make(__('settings.subtitle')),
]),
PageHeader::make('settings-heading', __('settings.heading'), __('settings.subtitle')),
Tabs::make('settings-tabs')
->defaultValue('profile')
->schema([
Expand Down Expand Up @@ -89,12 +83,7 @@ public function render(PageSchema $schema, TwoFactorAuthenticationRequest $reque
private function profileTab(): array
{
return [
Stack::make('profile-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('settings.profile.heading'), 2),
Text::make(__('settings.profile.subtitle')),
]),
PageHeader::section('profile-heading', __('settings.profile.heading'), __('settings.profile.subtitle')),
Form::use(ProfileSettingsForm::class),
Form::use(DeleteAccountForm::class),
];
Expand All @@ -111,12 +100,7 @@ private function securityTab(
array $recoveryCodes = [],
): array {
return [
Stack::make('security-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('settings.security.heading'), 2),
Text::make(__('settings.security.subtitle')),
]),
PageHeader::section('security-heading', __('settings.security.heading'), __('settings.security.subtitle')),
Form::use(PasswordSettingsForm::class),
Stack::make('two-factor-authentication')
->gap(Gap::Small)
Expand All @@ -131,12 +115,7 @@ private function securityTab(
->visible($canManageTwoFactor),
$this->recoveryCodesSection($recoveryCodes)
->visible($canManageTwoFactor && $twoFactorEnabled),
Stack::make('passkey-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('settings.passkeys.heading'), 2),
Text::make(__('settings.passkeys.subtitle')),
])
PageHeader::section('passkey-heading', __('settings.passkeys.heading'), __('settings.passkeys.subtitle'))
->visible($canManagePasskeys),
Table::lazy(PasskeysTable::class)
->visible($canManagePasskeys),
Expand Down Expand Up @@ -183,12 +162,7 @@ private function recoveryCodesSection(array $recoveryCodes): Stack
private function appearanceTab(string $appearance): array
{
return [
Stack::make('appearance-heading')
->gap(Gap::Small)
->schema([
Heading::make(__('settings.appearance.heading'), 2),
Text::make(__('settings.appearance.subtitle')),
]),
PageHeader::section('appearance-heading', __('settings.appearance.heading'), __('settings.appearance.subtitle')),
SegmentedControl::make('appearance', __('settings.appearance.label'))
->value($appearance)
->emits('lattice:appearance-change')
Expand Down
Loading