diff --git a/.ai/rules/frontend.md b/.ai/rules/frontend.md index b4e8e20..d1c02b1 100644 --- a/.ai/rules/frontend.md +++ b/.ai/rules/frontend.md @@ -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 diff --git a/.ai/rules/lattice.md b/.ai/rules/lattice.md index 14ef27b..773dc49 100644 --- a/.ai/rules/lattice.md +++ b/.ai/rules/lattice.md @@ -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 diff --git a/README.md b/README.md index 889af26..fd38912 100644 --- a/README.md +++ b/README.md @@ -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 — diff --git a/app/Components/PageHeader.php b/app/Components/PageHeader.php new file mode 100644 index 0000000..7790bb1 --- /dev/null +++ b/app/Components/PageHeader.php @@ -0,0 +1,38 @@ +gap(Gap::Small)->schema($schema); + } +} diff --git a/app/Layouts/AppLayout.php b/app/Layouts/AppLayout.php index a30e78d..e95d5d2 100644 --- a/app/Layouts/AppLayout.php +++ b/app/Layouts/AppLayout.php @@ -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; @@ -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; @@ -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(), ]), ]), @@ -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) @@ -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) diff --git a/app/Models/User.php b/app/Models/User.php index 1619c6a..896e0ce 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -82,11 +82,12 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; /** - * @return BelongsToMany + * @return BelongsToMany */ public function teams(): BelongsToMany { return $this->belongsToMany(Team::class, 'team_members', 'user_id', 'team_id') + ->using(Membership::class) ->withPivot(['role']) ->withTimestamps(); } diff --git a/app/Pages/AppPage.php b/app/Pages/AppPage.php new file mode 100644 index 0000000..adb079c --- /dev/null +++ b/app/Pages/AppPage.php @@ -0,0 +1,19 @@ +currentUser(); @@ -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([ diff --git a/app/Pages/SettingsPage.php b/app/Pages/SettingsPage.php index 0038170..088fbdb 100644 --- a/app/Pages/SettingsPage.php +++ b/app/Pages/SettingsPage.php @@ -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; @@ -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 @@ -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([ @@ -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), ]; @@ -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) @@ -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), @@ -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') diff --git a/app/Pages/Teams/TeamPage.php b/app/Pages/Teams/TeamPage.php index 91f6e1f..18f0f05 100644 --- a/app/Pages/Teams/TeamPage.php +++ b/app/Pages/Teams/TeamPage.php @@ -3,31 +3,26 @@ namespace App\Pages\Teams; +use App\Components\PageHeader; use App\Forms\Teams\DeleteTeamForm; use App\Forms\Teams\InviteTeamMemberForm; use App\Forms\Teams\UpdateTeamForm; use App\Models\Team; -use App\Pages\Concerns\ListensForUserNotifications; +use App\Pages\AppPage; use App\Tables\Teams\TeamInvitationsTable; use App\Tables\Teams\TeamMembersTable; -use Illuminate\Http\Request; use Lattice\Core\Attributes\AsPage; -use Lattice\Core\Enums\PageLayout; +use Lattice\Core\Breadcrumb; use Lattice\Form\Components\Form; -use Lattice\Http\Page; use Lattice\Table\Components\Table; -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: 'settings/teams/{team}', name: 'teams.edit', layout: PageLayout::App, middleware: ['auth', 'verified'], can: 'view', on: 'team')] -class TeamPage extends Page +#[AsPage(route: 'settings/teams/{team}', name: 'teams.edit', can: 'view', on: 'team')] +class TeamPage extends AppPage { - use ListensForUserNotifications; - private ?Team $team = null; public function title(): ?string @@ -35,7 +30,24 @@ public function title(): ?string return $this->team?->name; } - public function render(PageSchema $schema, Request $request, Team $team): PageSchema + /** + * @return array + */ + public function breadcrumbs(): array + { + $trail = [Breadcrumb::make(__('teams.index.title'), route('teams.index', absolute: false))]; + + if ($this->team instanceof Team) { + $trail[] = Breadcrumb::make( + $this->team->name, + route('teams.edit', ['team' => $this->team->slug], absolute: false), + ); + } + + return $trail; + } + + public function render(PageSchema $schema, Team $team): PageSchema { $this->team = $team; @@ -44,29 +56,19 @@ public function render(PageSchema $schema, Request $request, Team $team): PageSc ->gap(Gap::Large) ->width(Width::Medium) ->schema([ - $this->section('team-heading', $team->name, __('teams.show.subtitle'), 1), - $this->section('team-details-heading', __('teams.show.details-heading'), __('teams.show.details-subtitle')) + PageHeader::make('team-heading', $team->name, __('teams.show.subtitle')), + PageHeader::section('team-details-heading', __('teams.show.details-heading'), __('teams.show.details-subtitle')) ->can('update', on: 'team'), Form::use(UpdateTeamForm::class), - $this->section('team-invite-heading', __('teams.show.invite-heading'), __('teams.show.invite-subtitle')) + PageHeader::section('team-invite-heading', __('teams.show.invite-heading'), __('teams.show.invite-subtitle')) ->can('inviteMember', on: 'team'), Form::use(InviteTeamMemberForm::class), - $this->section('team-members-heading', __('teams.show.members-heading'), __('teams.show.members-subtitle')), + PageHeader::section('team-members-heading', __('teams.show.members-heading'), __('teams.show.members-subtitle')), Table::lazy(TeamMembersTable::class), - $this->section('team-invitations-heading', __('teams.show.invitations-heading'), __('teams.show.invitations-subtitle')), + PageHeader::section('team-invitations-heading', __('teams.show.invitations-heading'), __('teams.show.invitations-subtitle')), Table::lazy(TeamInvitationsTable::class), Form::use(DeleteTeamForm::class), ]), ]); } - - private function section(string $key, string $heading, string $subtitle, int $level = 2): Stack - { - return Stack::make($key) - ->gap(Gap::Small) - ->schema([ - Heading::make($heading, $level), - Text::make($subtitle), - ]); - } } diff --git a/app/Pages/Teams/TeamsPage.php b/app/Pages/Teams/TeamsPage.php index 0c92373..7b6858b 100644 --- a/app/Pages/Teams/TeamsPage.php +++ b/app/Pages/Teams/TeamsPage.php @@ -3,45 +3,45 @@ namespace App\Pages\Teams; +use App\Components\PageHeader; use App\Forms\Teams\CreateTeamForm; -use App\Pages\Concerns\ListensForUserNotifications; +use App\Pages\AppPage; use App\Tables\Teams\TeamsTable; -use Illuminate\Http\Request; use Lattice\Core\Attributes\AsPage; -use Lattice\Core\Enums\PageLayout; +use Lattice\Core\Breadcrumb; use Lattice\Form\Components\Form; -use Lattice\Http\Page; use Lattice\Table\Components\Table; -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: 'settings/teams', name: 'teams.index', layout: PageLayout::App, middleware: ['auth', 'verified'])] -class TeamsPage extends Page +#[AsPage(route: 'settings/teams', name: 'teams.index')] +class TeamsPage extends AppPage { - use ListensForUserNotifications; - public function title(): string { return __('teams.index.title'); } - public function render(PageSchema $schema, Request $request): PageSchema + /** + * @return array + */ + public function breadcrumbs(): array + { + return [ + Breadcrumb::make(__('teams.index.title'), route('teams.index', absolute: false)), + ]; + } + + public function render(PageSchema $schema): PageSchema { return $schema->schema([ Stack::make('teams-page') ->gap(Gap::Large) ->width(Width::Large) ->schema([ - Stack::make('teams-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('teams.index.heading'), 1), - Text::make(__('teams.index.subtitle')), - ]), + PageHeader::make('teams-heading', __('teams.index.heading'), __('teams.index.subtitle')), Form::use(CreateTeamForm::class), Table::use(TeamsTable::class), ]), diff --git a/app/Tables/Concerns/AuthorizesRowActions.php b/app/Tables/Concerns/AuthorizesRowActions.php new file mode 100644 index 0000000..4599946 --- /dev/null +++ b/app/Tables/Concerns/AuthorizesRowActions.php @@ -0,0 +1,24 @@ + + */ + private array $allowed = []; + + private function allows(string $ability, Model $subject): bool + { + return $this->allowed[$ability] ??= auth()->user()?->can($ability, $subject) ?? false; + } +} diff --git a/app/Tables/Teams/TeamInvitationsTable.php b/app/Tables/Teams/TeamInvitationsTable.php index 79b5b3e..fe849f7 100644 --- a/app/Tables/Teams/TeamInvitationsTable.php +++ b/app/Tables/Teams/TeamInvitationsTable.php @@ -6,7 +6,7 @@ use App\Actions\Teams\CancelInvitation; use App\Models\Team; use App\Models\TeamInvitation; -use App\Models\User; +use App\Tables\Concerns\AuthorizesRowActions; use Lattice\Actions\Components\Action; use Lattice\Actions\Components\ActionGroup; use Lattice\Core\Enums\ColorName; @@ -25,6 +25,8 @@ #[AsTable('teams.invitations', can: 'view', on: 'team')] class TeamInvitationsTable extends TableDefinition { + use AuthorizesRowActions; + public function layout(): string { return 'grid'; @@ -52,9 +54,8 @@ public function actions(array $row): array { /** @var Team $team */ $team = $this->contextModel('team'); - $user = auth()->user(); - if (! $user instanceof User || ! $user->can('cancelInvitation', $team)) { + if (! $this->allows('cancelInvitation', $team)) { return []; } diff --git a/app/Tables/Teams/TeamMembersTable.php b/app/Tables/Teams/TeamMembersTable.php index d5880f1..6f2bcfb 100644 --- a/app/Tables/Teams/TeamMembersTable.php +++ b/app/Tables/Teams/TeamMembersTable.php @@ -8,6 +8,7 @@ use App\Enums\TeamRole; use App\Models\Team; use App\Models\User; +use App\Tables\Concerns\AuthorizesRowActions; use Lattice\Actions\Components\Action; use Lattice\Actions\Components\ActionGroup; use Lattice\Core\Enums\ColorName; @@ -26,6 +27,8 @@ #[AsTable('teams.members', can: 'view', on: 'team')] class TeamMembersTable extends TableDefinition { + use AuthorizesRowActions; + public function layout(): string { return 'grid'; @@ -53,20 +56,19 @@ public function actions(array $row): array { /** @var Team $team */ $team = $this->contextModel('team'); - $user = auth()->user(); - if (! $user instanceof User || TeamRole::tryFrom((string) ($row['role'] ?? '')) === TeamRole::Owner) { + if (TeamRole::tryFrom((string) ($row['role'] ?? '')) === TeamRole::Owner) { return []; } $context = ['member' => $row['id']]; $actions = []; - if ($user->can('updateMember', $team)) { + if ($this->allows('updateMember', $team)) { $actions[] = Action::use(UpdateMemberRole::class, $context); } - if ($user->can('removeMember', $team)) { + if ($this->allows('removeMember', $team)) { $actions[] = Action::use(RemoveMember::class, $context); } diff --git a/app/Tables/Teams/TeamsTable.php b/app/Tables/Teams/TeamsTable.php index 2d1cf2c..d6f0ecd 100644 --- a/app/Tables/Teams/TeamsTable.php +++ b/app/Tables/Teams/TeamsTable.php @@ -78,19 +78,21 @@ public function source(): TableSource return TableResult::fromItems([]); } + // The membership pivot already carries the role, so reading it off the + // loaded row keeps this to one query instead of one per team. return TableResult::fromItems( $user->teams()->get()->map(fn (Team $team): array => [ 'id' => $team->id, 'name' => $team->name, 'slug' => $team->slug, - 'roleLabel' => $user->teamRole($team)?->getLabel(), + 'roleLabel' => $team->pivot->role->getLabel(), 'status' => $this->statusFor($team->is_personal, $user->isCurrentTeam($team)), ]), ); }); } - private function statusFor(bool $isPersonal, ?bool $isCurrent): string + private function statusFor(bool $isPersonal, bool $isCurrent): string { return collect([ $isPersonal ? __('teams.status.personal') : null, diff --git a/lang/de/navigation.php b/lang/de/navigation.php index 6dcd115..cbc342b 100644 --- a/lang/de/navigation.php +++ b/lang/de/navigation.php @@ -5,5 +5,6 @@ 'dashboard' => 'Dashboard', 'settings' => 'Einstellungen', 'team-settings' => 'Team-Einstellungen', + 'toggle-sidebar' => 'Seitenleiste umschalten', 'select-team' => 'Team auswählen', ]; diff --git a/lang/en/navigation.php b/lang/en/navigation.php index 6bed68a..20949df 100644 --- a/lang/en/navigation.php +++ b/lang/en/navigation.php @@ -5,5 +5,6 @@ 'dashboard' => 'Dashboard', 'settings' => 'Settings', 'team-settings' => 'Team settings', + 'toggle-sidebar' => 'Toggle sidebar', 'select-team' => 'Select team', ]; diff --git a/resources/css/app.css b/resources/css/app.css index da792a3..2aeb525 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4,6 +4,14 @@ @import '@fontsource-variable/instrument-sans/wght.css'; @source '../views'; +/* + * The lattice vite plugin sources every discovered *component* package, and + * @lattice-php/ui/css sources its own dist — but not the sibling runtime + * packages. @lattice-php/core owns the responsive-visibility wrapper + * (`hidden md:contents`), so without this glob `visibleFrom()`/`hiddenFrom()` + * silently hide a node at every width. + */ +@source '../../node_modules/@lattice-php/*/dist'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @custom-variant dark (&:is(.dark *)); diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php new file mode 100644 index 0000000..29b3fbb --- /dev/null +++ b/tests/Browser/NavigationTest.php @@ -0,0 +1,19 @@ +create(['name' => 'Ada Lovelace']); + + $this->actingAs($user); + + // The sidebar ships no trigger of its own, so the topbar button is the only + // way into the navigation below the md breakpoint. + visit('/dashboard')->on()->mobile() + ->assertMissing('[data-test="sidebar-backdrop"]') + ->click('[data-test="sidebar-toggle"]') + ->assertVisible('[data-test="sidebar-backdrop"]') + ->assertSee("Ada Lovelace's Team") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/TeamsTest.php b/tests/Browser/TeamsTest.php index c0a2620..7354262 100644 --- a/tests/Browser/TeamsTest.php +++ b/tests/Browser/TeamsTest.php @@ -39,6 +39,7 @@ ->assertNoJavaScriptErrors() ->click('[data-test="action-teams.'.$team->id.'.edit"]') ->assertPathIs('/settings/teams/lattice-core') + ->assertSeeIn('[data-test="app-breadcrumbs"]', 'Lattice Core') ->assertSee('Manage team settings, members, and invitations.') ->assertSee('Grace Hopper') ->click('[data-test="teams.members.'.$member->id.'.actions"]')