diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index 6eb52ecf..7e52658b 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -472,16 +472,16 @@ The `illustration` slot draws the application's own artwork in place of the shap
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
-A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
+A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
```blade
-
- {{ $share->name }}
+
+ {{ $share->name }}
```
-`data-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires.
+`data-md-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires.
That is M3's *non-actionable card with actionable elements*: Tab walks the controls inside. For M3's *directly actionable card*, where Tab lands on the card and then moves to the next card, add `actionable` (and `role="link"` where it goes somewhere) and put `tabindex="-1"` on the opener — the card is then the tab stop, named by its `title`, and Enter or Space on it reaches the opener while the card's other actions follow it.
@@ -1079,7 +1079,7 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
### ``, ``
-A data table: write plain ``, `
`, `
`, `
` inside ``; cell utilities (`text-end`, `whitespace-nowrap`) always win. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in `
`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
+A data table: write plain ``, `
`, `
`, `
` inside ``; cell utilities (`text-end`, `whitespace-nowrap`) always win. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in `
`. A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`.
`Size` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
@@ -1089,8 +1089,8 @@ A data table: write plain ``, `
@endforeach
diff --git a/resources/css/components.css b/resources/css/components.css
index 6abf51ab..d4462cac 100644
--- a/resources/css/components.css
+++ b/resources/css/components.css
@@ -58,5 +58,6 @@
/* Containment */
@import './components/divider.css';
@import './components/collapse.css';
+@import './components/card.css';
/* Navigation */
diff --git a/resources/css/components/card.css b/resources/css/components/card.css
new file mode 100644
index 00000000..bf6dffeb
--- /dev/null
+++ b/resources/css/components/card.css
@@ -0,0 +1,177 @@
+/*
+ * : M3's three card variants (ElevatedCardTokens.kt, FilledCardTokens.kt,
+ * OutlinedCardTokens.kt, androidx Compose Material 3, Apache-2.0; docs/reference/m3
+ * § Cards → Specs) — filled (surface-container-highest), elevated (surface-container-low at
+ * elevation 1) and outlined (surface, an outline-variant edge); one 12dp corner for all three,
+ * which never morphs (M3 specifies shape morph for buttons, FABs and list items, never cards —
+ * docs/audits/m3-alignment/containment.md, C-23).
+ *
+ * A row (`data-md-list-row`, `resources/js/list-rows.js`) renders the shared `md-state-layer` and
+ * `md-focus-ring` classes (foundation/interaction.css) for its tint and its ring; this file adds
+ * only what a card needs beyond them:
+ *
+ * - per-state *elevation*, which the shared class has no opinion on: elevated 1dp rest → 2dp
+ * hover → 1dp focus/pressed; filled and outlined 0dp rest → 1dp hover → 0dp focus/pressed
+ * (the cross-check table's Hover/Focus/Pressed rows). Pressed and focus fall back to the rest
+ * level, so the `:active` rule (declared after `:hover`, same specificity) resets it even
+ * while still hovering;
+ * - the *press/hover exclusion*: a card's own nested button (the "Copy link" in a card that is
+ * also a row) should light only itself, never the row's tint or elevation. `material.components`
+ * out-layers `material.base`, so these two rules win over the shared class's plain `:hover`/
+ * `:active` without needing extra specificity;
+ * - the *opener-focused ring*: a non-actionable row's focus never lands on the card itself (its
+ * opener carries the tab stop), so `md-focus-ring`'s own `:focus-visible` never fires for it;
+ * `:has([data-md-list-open]:focus-visible)` draws the same ring from here instead. An
+ * actionable card (`tabindex="0"`) is focused directly, which `md-focus-ring` already covers.
+ *
+ * `data-md-dragged` (an application sets it while its drag is in progress) is the top of the
+ * elevation scale — Level4 (8dp) elevated, Level3 (6dp) filled and outlined — under the shared
+ * class's own 16% dragged tint, which already reads `data-md-dragged` without any help from here.
+ */
+
+@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+
+@import './divider.css';
+
+@layer material.components {
+ [data-md-card] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: var(--md-sys-shape-corner-md);
+ color: var(--md-sys-color-on-surface);
+ }
+
+ [data-md-card='filled'] {
+ background-color: var(--md-sys-color-surface-container-highest);
+ }
+
+ [data-md-card='elevated'] {
+ background-color: var(--md-sys-color-surface-container-low);
+ box-shadow: var(--md-sys-elevation-1);
+ }
+
+ [data-md-card='outlined'] {
+ border: 1px solid var(--md-sys-color-outline-variant);
+ background-color: var(--md-sys-color-surface);
+ }
+
+ [data-md-card][data-md-list-row] {
+ cursor: pointer;
+ transition-property: box-shadow;
+ transition-duration: var(--md-sys-motion-spatial-default-duration);
+ transition-timing-function: var(--md-sys-motion-spatial-default);
+ }
+
+ @media (hover: hover) {
+ [data-md-card][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
+ box-shadow: var(--md-sys-elevation-1);
+ }
+
+ [data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
+ box-shadow: var(--md-sys-elevation-2);
+ }
+
+ [data-md-card][data-md-list-row]:not([data-md-dragged]):hover:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)::before {
+ opacity: 0;
+ }
+ }
+
+ [data-md-card][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
+ box-shadow: none;
+ }
+
+ [data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
+ box-shadow: var(--md-sys-elevation-1);
+ }
+
+ [data-md-card][data-md-list-row]:not([data-md-dragged]):has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)::before {
+ opacity: 0;
+ }
+
+ [data-md-card][data-md-dragged] {
+ box-shadow: var(--md-sys-elevation-3);
+ }
+
+ [data-md-card='elevated'][data-md-dragged] {
+ box-shadow: var(--md-sys-elevation-4);
+ }
+
+ [data-md-card][data-md-list-row]:has([data-md-list-open]:focus-visible) {
+ outline: 3px solid var(--md-sys-color-secondary);
+ outline-offset: 2px;
+ }
+
+ [data-md-card][data-md-list-row] [data-md-list-open]:focus-visible {
+ outline: none;
+ }
+
+ [data-md-card-figure] {
+ flex-shrink: 0;
+ overflow: hidden;
+
+ & > img {
+ width: 100%;
+ object-fit: cover;
+ }
+ }
+
+ [data-md-card-body] {
+ display: flex;
+ flex: 1 1 0%;
+ flex-direction: column;
+ gap: var(--md-sys-measurement-space200);
+ padding: var(--md-sys-measurement-space200);
+ }
+
+ [data-md-card-header-row] {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--md-sys-measurement-space100);
+ }
+
+ [data-md-card-heading] {
+ min-width: 0;
+ flex: 1 1 0%;
+ }
+
+ [data-md-card-title] {
+ font: var(--md-sys-typescale-title-md);
+ letter-spacing: var(--md-sys-typescale-title-md-tracking);
+ }
+
+ [data-md-card-subtitle] {
+ margin-top: var(--md-sys-measurement-space25);
+ color: var(--md-sys-color-on-surface-variant);
+ font: var(--md-sys-typescale-body-md);
+ letter-spacing: var(--md-sys-typescale-body-md-tracking);
+ }
+
+ [data-md-card-menu] {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--md-sys-measurement-space50);
+ margin-block-start: calc(-1 * var(--md-sys-measurement-space100));
+ margin-inline-end: calc(-1 * var(--md-sys-measurement-space100));
+ }
+
+ [data-md-card-header] [data-md-divider] {
+ margin-top: var(--md-sys-measurement-space200);
+ }
+
+ [data-md-card-content] {
+ flex: 1 1 0%;
+ font: var(--md-sys-typescale-body-md);
+ letter-spacing: var(--md-sys-typescale-body-md-tracking);
+ }
+
+ [data-md-card-actions] {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--md-sys-measurement-space100);
+ }
+}
diff --git a/resources/css/components/table.css b/resources/css/components/table.css
index eb200f4f..afa4e225 100644
--- a/resources/css/components/table.css
+++ b/resources/css/components/table.css
@@ -7,8 +7,8 @@
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* outline-variant rule, body cells in body-medium between outline-variant rules — the role itself,
* never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row
- * (`aria-selected="true"`) is secondary-container. A row that opens something is `data-list-row` and
- * answers a pointer as a list row does (components/list.css).
+ * (`aria-selected="true"`) is secondary-container. A row that opens something is `data-md-list-row`
+ * and answers a pointer as a list row does (components/list.css).
*
* A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
* applied by itself — "don't apply density by default; offer an explicit density opt-in instead,
diff --git a/resources/js/search.js b/resources/js/search.js
index 85612782..2521a275 100644
--- a/resources/js/search.js
+++ b/resources/js/search.js
@@ -22,7 +22,7 @@ import { upTo } from './breakpoints.js'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
-const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
+const CHOOSES = 'a[href], button:not([disabled]), [data-md-list-open]'
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php
index f4a092bf..b898433e 100644
--- a/resources/views/components/card.blade.php
+++ b/resources/views/components/card.blade.php
@@ -12,8 +12,8 @@
the next card; on a **directly actionable card** Tab moves to the card, then to the next card
(docs/reference/m3/components-actions-communication-containment.md § Cards → Accessibility).
- Both are a row: give the card `data-list-row` and one `data-list-open` control inside (the
- title link or a button), and a press anywhere on it reaches that control while its other
+ Both are a row: give the card `data-md-list-row` and one `data-md-list-open` control inside
+ (the title link or a button), and a press anywhere on it reaches that control while its other
buttons keep their own (resources/js/list-rows.js). That is the first kind — the opener is
the tab stop, the accessible name and the owner of the `href` or the `wire:click`.
@@ -22,21 +22,29 @@
reaches the opener. Put `tabindex="-1"` on that opener so the card is the one stop, and leave
the card's other actions as they are — they follow it in the tab order.
- A row answers with M3's state layer and one step of elevation (elevated 1 → 2 hovered,
- filled and outlined 0 → 1); its corner does not move, because M3 gives a card one shape.
- Never a stretched link, and never a whole-card `` around buttons.
+ A row (`data-md-list-row`, `actionable` or not) renders the shared `md-state-layer` and
+ `md-focus-ring` classes (foundation/interaction.css), which draw the tint and the ring for a
+ card focused directly; resources/css/components/card.css refines their opacity when the
+ pointer or the keyboard is on one of the card's *own* buttons instead (which should light only
+ itself), and adds the `:has([data-md-list-open]:focus-visible)` ring for the non-actionable
+ row, whose focus never lands on the card itself. Elevation is separate from the tint — M3
+ gives each variant its own per-state box-shadow (elevated 1dp rest → 2dp hover → 1dp
+ focus/pressed; filled and outlined 0dp rest → 1dp hover → 0dp focus/pressed) — so it is drawn
+ directly on `[data-md-card]`, never inside the shared class. Its corner does not move, because
+ M3 gives a card one shape (docs/audits/m3-alignment/containment.md, C-13, C-23).
- `data-dragged` is M3's dragged card: the top of a card's elevation scale — 8dp elevated, 6dp
- filled and outlined — under the 16% dragged state layer, and it holds while the pointer is
- down rather than falling back to the press state. Nothing in the browser tells a card it is
+ `data-md-dragged` is M3's dragged card: the top of a card's elevation scale — 8dp elevated,
+ 6dp filled and outlined — under the shared class's own 16% dragged tint (it already reads
+ `data-md-dragged`, foundation/interaction.css). Nothing in the browser tells a card it is
being carried, so the application sets the attribute when its drag starts and takes it off on
drop. M3 requires a single-pointer alternative beside any drag, so keep the same reorder or
delete actions in a menu on the card
(docs/reference/m3/components-actions-communication-containment.md § Cards → Specs,
- Accessibility; resources/css/components/list.css draws it).
+ Accessibility).
- Do not pass a `bg-*` class to change its fill — it races the card's own in Tailwind's emit
- order; use `variant`, or colour a wrapper inside. --}}
+ Do not pass a `bg-*` class to change its fill — the package's own rule for the variant sits in
+ `material.components`, and an application's unlayered CSS is the place to override it; use
+ `variant`, or colour a wrapper inside. --}}
@props([
'title' => null,
@@ -49,60 +57,59 @@
@php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null;
+ $isRow = $actionable || $attributes->has('data-md-list-row');
+
$attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp