From e99c5993d8d09bff9dd12b1db266b4e0e5bc59d8 Mon Sep 17 00:00:00 2001
From: Andreas Reinhold / reini
Date: Mon, 14 Sep 2026 14:50:55 +0200
Subject: [PATCH] Add the surface layout component
Plan step 35: , a tonal region
in surface or a surface-container step, padded with a spacing token,
rounded with a corner token and optionally edged in outline-variant. A
pane on a surface keeps its margin again, since the surface is a new edge.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
---
.../livewire-material-development/SKILL.md | 7 ++
resources/css/layout.css | 1 +
resources/css/layout/surface.css | 107 ++++++++++++++++++
resources/views/components/surface.blade.php | 48 ++++++++
.../views/showcase/sections/layout.blade.php | 1 +
tests/Browser/LayoutTest.php | 16 +++
tests/Feature/Components/SurfaceTest.php | 73 ++++++++++++
7 files changed, 253 insertions(+)
create mode 100644 resources/css/layout/surface.css
create mode 100644 resources/views/components/surface.blade.php
create mode 100644 tests/Feature/Components/SurfaceTest.php
diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index 55c8832e..751cb3d8 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -783,6 +783,13 @@ Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 84
M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an ``, a new edge, draws it again.
+#### ``
+
+A tonal region: `…`.
+
+- `level`: `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest`, `surface-container-low`, `surface-container` (default), `surface-container-high`, `surface-container-highest`; the text on it is `on-surface`. A higher container step reads as nearer — how M3 separates regions without a shadow.
+- `padding`: a spacing token. `corner`: `none`, `xs`, `sm`, `md`, `lg`, `lg-increased`, `xl`, `xl-increased`, `xxl`, `full`. `outlined`: M3's 1dp outline-variant edge.
+
#### ``
Children one under another inside a pane: `…`. `align` across it: `stretch` (default), `start`, `center`, `end`.
diff --git a/resources/css/layout.css b/resources/css/layout.css
index cc9cf0d4..ab0f41e5 100644
--- a/resources/css/layout.css
+++ b/resources/css/layout.css
@@ -9,3 +9,4 @@
@import './layout/stack.css';
@import './layout/row.css';
@import './layout/grid.css';
+@import './layout/surface.css';
diff --git a/resources/css/layout/surface.css b/resources/css/layout/surface.css
new file mode 100644
index 00000000..f8ef8856
--- /dev/null
+++ b/resources/css/layout/surface.css
@@ -0,0 +1,107 @@
+/*
+ * : a tonal region, M3's surface or one of its container steps.
+ *
+ * `data-md-level` names the colour role the region is filled with — `surface`, `surface-dim`,
+ * `surface-bright`, or `surface-container-lowest` … `surface-container-highest` — and its text is
+ * `on-surface`, the role M3 pairs with every one of them (docs/reference/m3/styles.md § Color →
+ * surface roles). The container steps are how M3 separates regions without a shadow: a higher step
+ * reads as nearer. `data-md-corner` rounds it with a corner token (`xs` … `full`,
+ * resources/css/tokens/shape.css), `data-md-padding` pads it with a spacing token (spacing.css),
+ * and `data-md-outlined` draws the 1dp outline-variant edge M3 gives an outlined container, for a
+ * boundary where the tones alone would be too close.
+ *
+ * A surface is an edge of its own: a pane or a canonical layout on it keeps its content off the
+ * surface's edge by M3's margin again, even inside a region that already drew one (pane.css,
+ * `--md-layout-margin`), which `initial` puts back to "not drawn yet".
+ *
+ * In `material.layout`, under every component, so a component drawn on a surface keeps its own
+ * container; `hide-below`/`hide-from` come from visibility.css.
+ */
+
+@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+
+@import './spacing.css';
+@import './visibility.css';
+
+@layer material.layout {
+ [data-md-surface] {
+ background-color: var(--md-sys-color-surface-container);
+ color: var(--md-sys-color-on-surface);
+ }
+
+ [data-md-surface][data-md-level='surface'] {
+ background-color: var(--md-sys-color-surface);
+ }
+
+ [data-md-surface][data-md-level='surface-dim'] {
+ background-color: var(--md-sys-color-surface-dim);
+ }
+
+ [data-md-surface][data-md-level='surface-bright'] {
+ background-color: var(--md-sys-color-surface-bright);
+ }
+
+ [data-md-surface][data-md-level='surface-container-lowest'] {
+ background-color: var(--md-sys-color-surface-container-lowest);
+ }
+
+ [data-md-surface][data-md-level='surface-container-low'] {
+ background-color: var(--md-sys-color-surface-container-low);
+ }
+
+ [data-md-surface][data-md-level='surface-container-high'] {
+ background-color: var(--md-sys-color-surface-container-high);
+ }
+
+ [data-md-surface][data-md-level='surface-container-highest'] {
+ background-color: var(--md-sys-color-surface-container-highest);
+ }
+
+ [data-md-surface][data-md-corner='none'] {
+ border-radius: var(--md-sys-shape-corner-none);
+ }
+
+ [data-md-surface][data-md-corner='xs'] {
+ border-radius: var(--md-sys-shape-corner-xs);
+ }
+
+ [data-md-surface][data-md-corner='sm'] {
+ border-radius: var(--md-sys-shape-corner-sm);
+ }
+
+ [data-md-surface][data-md-corner='md'] {
+ border-radius: var(--md-sys-shape-corner-md);
+ }
+
+ [data-md-surface][data-md-corner='lg'] {
+ border-radius: var(--md-sys-shape-corner-lg);
+ }
+
+ [data-md-surface][data-md-corner='lg-increased'] {
+ border-radius: var(--md-sys-shape-corner-lg-increased);
+ }
+
+ [data-md-surface][data-md-corner='xl'] {
+ border-radius: var(--md-sys-shape-corner-xl);
+ }
+
+ [data-md-surface][data-md-corner='xl-increased'] {
+ border-radius: var(--md-sys-shape-corner-xl-increased);
+ }
+
+ [data-md-surface][data-md-corner='xxl'] {
+ border-radius: var(--md-sys-shape-corner-xxl);
+ }
+
+ [data-md-surface][data-md-corner='full'] {
+ border-radius: var(--md-sys-shape-corner-full);
+ }
+
+ [data-md-surface][data-md-outlined] {
+ border: 1px solid var(--md-sys-color-outline-variant);
+ }
+
+ [data-md-surface] > * {
+ --md-layout-margin: initial;
+ }
+}
diff --git a/resources/views/components/surface.blade.php b/resources/views/components/surface.blade.php
new file mode 100644
index 00000000..f9fe7ed4
--- /dev/null
+++ b/resources/views/components/surface.blade.php
@@ -0,0 +1,48 @@
+{{-- A tonal region: M3's surface, or a step of its surface containers.
+
+ …
+
+ `level` is the colour role it is filled with: `surface`, `surface-dim`, `surface-bright`,
+ `surface-container-lowest`, `surface-container-low`, `surface-container` (the default),
+ `surface-container-high` or `surface-container-highest`; the text on it is `on-surface`
+ (docs/reference/m3/styles.md § Color). Higher steps read as nearer, which is how M3 sets
+ regions apart without a shadow. An unknown level is the default.
+
+ `padding` is a spacing token's name, `space25` … `space900`
+ (docs/reference/m3/styles-supplement.md § Spacing); anything else is no padding. `corner` is a
+ corner token, `none`, `xs`, `sm`, `md`, `lg`, `lg-increased`, `xl`, `xl-increased`, `xxl` or
+ `full` (resources/css/tokens/shape.css); anything else leaves it square. `outlined` draws
+ the 1dp outline-variant edge.
+
+ Like every layout component it takes `as` (the element, `div` by default: `section`,
+ `article`, `aside` …) and `hide-below` / `hide-from` (`medium`, `expanded`, `large` or
+ `extra-large`), and the caller's `class` and `style` land on it untouched. Drawn by
+ resources/css/layout/surface.css; the props are read in src/Support/Layout.php. --}}
+
+@props([
+ 'as' => null,
+ 'level' => null,
+ 'padding' => null,
+ 'corner' => null,
+ 'outlined' => false,
+ 'hideBelow' => null,
+ 'hideFrom' => null,
+])
+
+@php
+ $layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
+ $element = $layout::element($as);
+
+ $attributes = $attributes->merge(array_filter([
+ 'data-md-surface' => true,
+ 'data-md-level' => $layout::choice($level, [
+ 'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
+ 'surface-container', 'surface-container-high', 'surface-container-highest',
+ ], 'surface-container'),
+ 'data-md-padding' => $layout::spacing($padding),
+ 'data-md-corner' => $layout::choice($corner, ['none', 'xs', 'sm', 'md', 'lg', 'lg-increased', 'xl', 'xl-increased', 'xxl', 'full']),
+ 'data-md-outlined' => $outlined ? true : null,
+ ] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
+@endphp
+
+<{{ $element }} {{ $attributes }}>{{ $slot }}{{ $element }}>
diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php
index 45d6869f..9660cffb 100644
--- a/resources/views/showcase/sections/layout.blade.php
+++ b/resources/views/showcase/sections/layout.blade.php
@@ -113,6 +113,7 @@
+ <x-surface> — a tonal region: a surface role, padding, a corner and an outline.
<x-stack>, <x-row> and <x-grid> — arrangement inside a pane.
diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php
index 2babc284..d4ae986e 100644
--- a/tests/Browser/LayoutTest.php
+++ b/tests/Browser/LayoutTest.php
@@ -126,3 +126,19 @@ it('fills a grid with columns of a minimum width, capped by the count', function
->assertScript(layoutColumns('#fill').' === 3')
->assertScript(layoutColumns('#capped').' === 2');
});
+
+it('draws a surface in its role, padding and corner, and hides it below a breakpoint', function () {
+ $body = 'Surface'
+ .'';
+
+ layoutPage($body, 599)
+ ->assertScript(layoutStyle('#surface', 'display')." === 'none'");
+
+ layoutPage($body, 600)
+ ->assertScript(layoutStyle('#surface', 'display')." === 'block'")
+ ->assertScript(layoutStyle('#surface', 'backgroundColor').' === '.layoutStyle('#role', 'backgroundColor'))
+ ->assertScript(layoutStyle('#surface', 'paddingTop')." === '24px'")
+ ->assertScript(layoutStyle('#surface', 'borderTopLeftRadius')." === '16px'")
+ ->assertScript(layoutStyle('#surface', 'borderTopWidth')." === '1px'")
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Feature/Components/SurfaceTest.php b/tests/Feature/Components/SurfaceTest.php
new file mode 100644
index 00000000..23986810
--- /dev/null
+++ b/tests/Feature/Components/SurfaceTest.php
@@ -0,0 +1,73 @@
+blade('Content'));
+
+ expect($root)->toMatchArray(['<' => 'div', 'data-md-surface' => 'data-md-surface', 'data-md-level' => 'surface-container'])
+ ->not->toHaveKeys(['data-md-padding', 'data-md-corner', 'data-md-outlined', 'data-md-hide-below', 'data-md-hide-from', 'class', 'style']);
+});
+
+it('fills the surface with each of M3\'s surface roles, and the default for any other', function () {
+ foreach (['surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low', 'surface-container', 'surface-container-high', 'surface-container-highest'] as $level) {
+ expect(layoutRoot((string) $this->blade('', ['level' => $level]))['data-md-level'])->toBe($level);
+ }
+
+ expect(layoutRoot((string) $this->blade(''))['data-md-level'])->toBe('surface-container');
+});
+
+it('pads the surface with a spacing token only, and with none for anything else', function () {
+ expect(layoutRoot((string) $this->blade(''))['data-md-padding'])->toBe('space300');
+
+ foreach (['24px', '3', 'space1000', 'roomy'] as $padding) {
+ expect(layoutRoot((string) $this->blade('', ['padding' => $padding]))['data-md-padding'])->toBe('none');
+ }
+});
+
+it('rounds the surface with a corner token, and leaves it square for anything else', function () {
+ foreach (['none', 'xs', 'sm', 'md', 'lg', 'lg-increased', 'xl', 'xl-increased', 'xxl', 'full'] as $corner) {
+ expect(layoutRoot((string) $this->blade('', ['corner' => $corner]))['data-md-corner'])->toBe($corner);
+ }
+
+ expect(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-corner');
+});
+
+it('draws an outline only when asked', function () {
+ expect(layoutRoot((string) $this->blade('')))->toHaveKey('data-md-outlined')
+ ->and(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-outlined');
+});
+
+it('draws the element it is asked for, and a div for one it does not take', function () {
+ expect(layoutRoot((string) $this->blade('Content'))['<'])->toBe('section')
+ ->and((string) $this->blade('Content'))->toContain('')
+ ->and(layoutRoot((string) $this->blade('Content'))['<'])->toBe('div')
+ ->and(layoutRoot((string) $this->blade(''))['<'])->toBe('div');
+});
+
+it('hides below and from a breakpoint, never below or from compact', function () {
+ expect(layoutRoot((string) $this->blade('')))
+ ->toMatchArray(['data-md-hide-below' => 'expanded', 'data-md-hide-from' => 'extra-large']);
+
+ foreach (['compact', 'tablet', 'Medium', '840'] as $breakpoint) {
+ expect(layoutRoot((string) $this->blade('', ['b' => $breakpoint])))
+ ->not->toHaveKeys(['data-md-hide-below', 'data-md-hide-from']);
+ }
+});
+
+it('puts the caller\'s class and style on the surface untouched', function () {
+ expect(layoutRoot((string) $this->blade('')))
+ ->toMatchArray(['class' => 'upload-zone', 'style' => 'min-height: 12rem;', 'data-test' => 'zone']);
+});
+
+it('draws the surface from a stylesheet in the layout layer, imported by layout.css', function () {
+ $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/surface.css');
+
+ expect($css)->toContain('@layer material.layout')
+ ->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
+ ->toContain('background-color: var(--md-sys-color-surface-container-high);')
+ ->toContain('border: 1px solid var(--md-sys-color-outline-variant);')
+ ->toContain("@import './spacing.css';")
+ ->toContain("@import './visibility.css';")
+ // A pane on a surface keeps its content off the surface's edge again.
+ ->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;")
+ ->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/surface.css';");
+});