Let an application replace the safe-area insets and dock on the bottom bar

Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 18:03:20 +02:00
co-authored by Claude Opus 5
parent bcf0d4f4e3
commit 53bb000425
13 changed files with 91 additions and 24 deletions
@@ -95,6 +95,12 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on. `theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
## Safe areas
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
## Toasts ## Toasts
```php ```php
@@ -650,7 +656,7 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). - `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). - Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`). - The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it. - Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside. - The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
### `<x-navigation-bar>`, `<x-navigation-bar-item>` ### `<x-navigation-bar>`, `<x-navigation-bar-item>`
+1 -1
View File
@@ -24,7 +24,7 @@
z-index: 20; z-index: 20;
display: block; display: block;
min-height: var(--app-bar-height); min-height: var(--app-bar-height);
padding-top: env(safe-area-inset-top); padding-top: var(--material-safe-top, env(safe-area-inset-top));
background-color: var(--md-sys-color-surface); background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
+5 -5
View File
@@ -66,8 +66,8 @@
[data-navigation-bar] { [data-navigation-bar] {
container-type: inline-size; container-type: inline-size;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container); background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
} }
@@ -206,7 +206,7 @@
at once when the rail expands, while the width is still growing; the clip keeps it at once when the rail expands, while the width is still growing; the clip keeps it
from spilling over the page for those frames. */ from spilling over the page for those frames. */
overflow-x: clip; overflow-x: clip;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface); background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
transition: transition:
@@ -307,12 +307,12 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
padding-top: calc(env(safe-area-inset-top) + 2.75rem); padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
padding-bottom: 2rem; padding-bottom: 2rem;
} }
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child { [data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
padding-top: calc(env(safe-area-inset-top) + 2.75rem); padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
} }
[data-navigation-rail-destinations] { [data-navigation-rail-destinations] {
+3 -3
View File
@@ -165,8 +165,8 @@
[data-search][data-full-screen] [data-search-bar] { [data-search][data-full-screen] [data-search-bar] {
position: fixed; position: fixed;
inset: 0 0 auto; inset: 0 0 auto;
height: calc(4.5rem + env(safe-area-inset-top)); height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
padding-top: env(safe-area-inset-top); padding-top: var(--material-safe-top, env(safe-area-inset-top));
padding-inline: 0.25rem; padding-inline: 0.25rem;
border-radius: 0; border-radius: 0;
} }
@@ -175,7 +175,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
max-height: none; max-height: none;
padding-top: calc(4.5rem + env(safe-area-inset-top)); padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
} }
+4 -4
View File
@@ -19,11 +19,11 @@
[data-toolbar][data-variant="docked"] { [data-toolbar][data-variant="docked"] {
width: 100%; width: 100%;
min-height: calc(4rem + env(safe-area-inset-bottom)); min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
justify-content: center; justify-content: center;
column-gap: clamp(0.25rem, 4vw, 2rem); column-gap: clamp(0.25rem, 4vw, 2rem);
padding-inline: 1rem; padding-inline: 1rem;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container); background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
} }
@@ -74,7 +74,7 @@
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */ /* Placed over the page: centred above the bottom edge, or centred against the end edge. */
[data-toolbar-place="bottom"] { [data-toolbar-place="bottom"] {
position: fixed; position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom)); bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
left: 50%; left: 50%;
z-index: 30; z-index: 30;
translate: -50% 0; translate: -50% 0;
@@ -83,7 +83,7 @@
[data-toolbar-place="end"] { [data-toolbar-place="end"] {
position: fixed; position: fixed;
top: 50%; top: 50%;
inset-inline-end: calc(1rem + env(safe-area-inset-right)); inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
z-index: 30; z-index: 30;
translate: 0 -50%; translate: 0 -50%;
} }
@@ -35,7 +35,10 @@
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell; the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`. below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays `max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
@@ -79,13 +82,13 @@
data-app-shell data-app-shell
@class([ @class([
'min-h-dvh bg-surface text-on-surface sm:flex', 'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(), 'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
]) ])
> >
<a <a
href="#content" href="#content"
data-skip-link data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none" class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a> >{{ __('Skip to content') }}</a>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth"> <x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@@ -52,7 +52,7 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: {{ $height }}" style="--sheet-max-height: {{ $height }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1', 'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
$attributes->get('class'), $attributes->get('class'),
]) }} ]) }}
> >
+3 -3
View File
@@ -59,7 +59,7 @@
data-sheet="{{ $id }}" data-sheet="{{ $id }}"
@if ($pane) @if ($pane)
x-bind:class="! open && 'xl:hidden'" x-bind:class="! open && 'xl:hidden'"
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start" class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
data-pane data-pane
@endif @endif
> >
@@ -90,11 +90,11 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}" style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1', 'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'end-0 sm:rounded-s-corner-lg' => ! $start, 'end-0 sm:rounded-s-corner-lg' => ! $start,
'start-0 sm:rounded-e-corner-lg' => $start, 'start-0 sm:rounded-e-corner-lg' => $start,
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]', 'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane, 'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
$attributes->get('class'), $attributes->get('class'),
]) }} ]) }}
> >
+1 -1
View File
@@ -64,7 +64,7 @@
> >
<div @class([ <div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3', 'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen, 'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass, $boxClass,
])> ])>
@if ($fullscreen) @if ($fullscreen)
+1 -1
View File
@@ -59,7 +59,7 @@
</x-slot:actions> </x-slot:actions>
<x-slot:top> <x-slot:top>
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4"> <header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4">
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span> <span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1> <h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
<x-livewire-material::button icon="search" tooltip="Search" /> <x-livewire-material::button icon="search" tooltip="Search" />
+28
View File
@@ -176,3 +176,31 @@ it('skips to the content', function () {
->assertScript("location.hash === '#content'") ->assertScript("location.hash === '#content'")
->assertScript("document.activeElement === document.getElementById('content')"); ->assertScript("document.activeElement === document.getElementById('content')");
}); });
it('moves an app bar\'s content under a safe area an application sets', function () {
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)";
$page = navigationReady(visit('/material'));
$top = (int) $page->script($row);
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
$page->assertScript("{$row} === ".($top + 47))
->assertNoJavaScriptErrors();
});
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
$page = shellPage(400, 860);
$snackbar = (int) $page->script($snackbarBottom);
$content = (int) $page->script($contentPadding);
expect($content)->toBe(64)->and($snackbar)->toBe(80);
$page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')");
$page->assertScript("{$snackbarBottom} === 120")
->assertScript("{$contentPadding} === 104");
});
+10 -1
View File
@@ -56,12 +56,21 @@ it('marks the destination at the current URL when none says it is active', funct
it('lifts the snackbar above the bar only when there is a bar', function () { it('lifts the snackbar above the bar only when there is a bar', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()])) expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]') ->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->not->toContain('data-app-shell-bar') ->not->toContain('data-app-shell-bar')
->not->toContain('--material-bottom-bar:'); ->not->toContain('--material-bottom-bar:');
}); });
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
expect($html)
->toContain('+var(--material-bottom-extra,0px))]')
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
->not->toMatch('/(?<!,)env\(safe-area-inset/');
});
it('places each slot once', function () { it('places each slot once', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations"> <x-app-shell :destinations="$destinations">
+21
View File
@@ -64,6 +64,27 @@ it('leaves the choice of theme to the head script, never to a media query', func
} }
}); });
it('reads every safe-area inset through a variable that can replace it', function () {
$files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js'])
->flatMap(fn (string $path): array => File::allFiles($path));
$insets = 0;
foreach ($files as $file) {
preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as [$side, $offset]) {
$insets++;
expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-')))
->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly");
}
}
expect($insets)->toBeGreaterThan(10)
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
});
it('makes every motion token instant under reduced motion', function () { it('makes every motion token instant under reduced motion', function () {
$motion = File::get(packageCss('tokens/motion.css')); $motion = File::get(packageCss('tokens/motion.css'));
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString(); $reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();