Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
sparrowhawk-labs/pinion-ui
claude-code
cursor
blade-components
tailwind
daisyui
alpinejs
mermaid
component-composition

Read this file before writing code that uses sparrowhawk-labs/pinion-ui. It captures the rules, gotchas, and lookup paths an AI agent needs to use Pinion UI correctly.

Install
php artisan boost:add-skill sparrowhawk-labs/pinion-ui

Save this content to: AGENTS.md

# AGENTS.md — pinion-ui (Laravel Blade adapter)

> **Stack**: Laravel Blade + Tailwind v4 + daisyUI v5 + Alpine.js. This is the Blade stack adapter for Pinion UI. Vanilla / React / Vue / Web Components adapters will each ship as their own NPM package with their own `AGENTS.md` (planned v0.5+). One package = one stack adapter, self-contained.

**Read this file before writing code that uses `sparrowhawk-labs/pinion-ui`.** It captures the rules, gotchas, and lookup paths an AI agent needs to use Pinion UI correctly.

## What this package is

A Laravel Blade component library (Tailwind v4 + daisyUI v5 + Alpine.js). 47 components — see [`reference/components/index.md`](./reference/components/index.md).

The package also ships the **mermaid/SVG color glue** as build artifacts in [`dist/`](./dist/): `dist/mermaid-theme.js` (runtime JS — inline it inside a `type="module"` mermaid block so figures follow `data-theme`/`data-tune`) and `dist/mermaid-theme.json` (expanded color/theme data for server-side SVG rendering). Canon + regen live in [`scripts/mermaid-theme/`](./scripts/mermaid-theme/) (`node scripts/mermaid-theme/build.mjs`).

## Calling convention

- **Anonymous (default)**: `<x-button>`, `<x-modal>`, `<x-tabs>`, etc. Use this in app code.
- **Namespaced (disambiguation)**: `<x-pn::button>` — use only when a consumer app has its own `<x-button>` that conflicts.
- **Never use the old `<x-pinion-ui::xxx>` prefix** — it was renamed to `pn::` in v0.2.1.

### Nested parent + children (v0.4.0+)

Two components compose as a parent with nested children rather than driven by a single array prop:

| Parent | Child | What the child carries |
|---|---|---|
| `<x-tabs>` | `<x-tab name label :icon>{{ slot }}</x-tab>` | One tab button + one panel. |
| `<x-accordion>` | `<x-accordion-item title :name>{{ slot }}</x-accordion-item>` | One header + one disclosure region. |
| `<x-table>` | `<x-table.th>` / `<x-table.td>` / `<x-table.empty>` (body rows stay plain `<tr>`) | One header/body cell / the empty-state row. |

**Never hand-roll a `<table>` for data listings — use `<x-table>`** ([`reference/components/table.md`](./reference/components/table.md)). It owns the whole "table in a card" recipe (card face, header row, cell rhythm, hover, last-row border, `tabular-nums` numeric columns, edge padding aligned to `x-card`'s `p-lg`, empty state) that hand-rolled tables re-invent and get subtly wrong. Editable grids are the exception (`<x-data-grid>` / `<x-sheet>`, in the private `sparrowhawk-labs/pinion-sections` since v0.21.0).

```blade
<x-tabs variant="boxed">
    <x-tab name="overview" label="Overview"><p>…</p></x-tab>
    <x-tab name="specs"    label="Specs"><p>…</p></x-tab>
</x-tabs>
```

How it works under the hood (so the pattern doesn't surprise you):

- **Shared props via `@aware`**: parent props (`variant`, `size`, `multiple`) flow into children through Blade `@aware`. Don't declare them again on the child call site — they're inherited.
- **Shared state via Alpine scope**: the parent owns the only `x-data` (`activeTab` / `open`); children read and write it via Alpine's normal scope chain. Don't add a new `x-data` on a child.
- **Composer is called from both Blades**: parent and child each call the same `Composer::compose()` so the class strings line up. This stays consistent with the rest of the architecture rules below.

Per-component docs cover the full prop tables and slot contracts: [`reference/components/tabs.md`](./reference/components/tabs.md), [`reference/components/accordion.md`](./reference/components/accordion.md). The previous array-driven shape (`:tabs="[…]"` / `:items="[…]"`) was removed in v0.4.0 — see [`SEMVER.md`](./SEMVER.md).

## Architecture rules (do not violate)

1. **Compose pattern**: For most components, class strings live in `src/Compose/{Name}Composer.php`, not the Blade. Each composer has a static `compose(array $props): array` that returns a flat dict of class strings. The Blade reads `$c['root']`, `$c['title']`, etc. — it is render-only.
   - Components **without** a composer (classes embedded in the Blade): `button`, `alert`, `card`, `badge`, `avatar`, `menu-item`, `section.hero`, `theme-switcher`. These predate the pattern.
2. **Composer returns class strings only — no markup, no array values.** If you need conditional markup, branch in the Blade based on a prop.
3. **Fixture tests**: Each composer has `tests/fixtures/compose/{name}.json` covering its variant matrix. Run with `php tests/Compose/run.php`. Comparison is subset (only listed `expected` keys are checked). Add cases when you add props.
4. **Backwards compatibility**: Never rename props or silently change their defaults. New props are opt-in (default preserves previous behaviour).

## Three style layers (orthogonal)

| Layer | Where it lives | Examples |
|---|---|---|
| **Theme** (color palette) | `<html data-theme="...">` | `pinion`, `pinion-dark`, `monokai`, `payments-dark` (54 original light/dark pairs — see the theme lineup section below; daisyUI's built-in themes do **not** exist in the build) |
| **Tune** (shape / space / font) | `<html data-tune="...">` | `default`, `minimal`, `tech`, `editorial`, `soft`, `plush`, `proto` (13 presets) |
| **Component** (variant / size / state) | Blade props | `color="primary"`, `size="lg"`, `dismissible` |
| **Responsive root text** (opt-in) | `<html data-text-scale="a\|b">` | drops the ROOT font-size on narrow screens, so everything authored in rem — glyphs, t-shirt spacing, field heights — shrinks together. Omit for no change. |

**Responsive root text — `data-text-scale` (v0.18.0).** Opt-in, orthogonal to theme × tune, and a no-op unless you set it. Narrow screens fit fewer characters per line; lowering the *root* font-size is the one lever that shrinks a page proportionally, because every rem-authored value follows at once (per-element shrinking always drifts — some paddings follow, others don't). Two ladders, both written as a subtraction from the tune's own `--font-size-base` so they compose with `minimal` (.9375rem) and `pixel` (1.125rem) instead of replacing them:

| value | ≤768px | ≤425px | ≤375px | ≤320px | at a 16px base |
|---|---|---|---|---|---|
| `a` | −0.8px | −1.2px | −1.6px | −2.0px | 15.2 / 14.8 / 14.4 / 14.0 |
| `b` | −1.0px | −1.0px | −2.0px | −3.0px | 15.0 / 15.0 / 14.0 / 13.0 |

The mechanism is one custom property, `--pn-text-trim` (0 by default), subtracted in the single root `font-size` declaration — so an app can also set it directly for a bespoke ladder. Subtracting px rather than setting px preserves the reader's own browser font-size setting: on the root element `1rem` *is* that setting, so the ladder shifts with it.

Themes and Tunes mix freely. Both are activated by the `pinion-ui.css` preset (imported into `app.css` by `ui:install`), which bundles the `data-tune` token system from `tune.css` (since v0.11.0 a shim over `tune-fonts.css` = self-hosted PixelMplus `@font-face` + `tune-core.css` = tokens only). Both attributes have working defaults when omitted — a root without `data-tune` renders exactly as `data-tune="default"` (since v0.10.11) — but always declare them explicitly on `<html>` so the choice is visible (`ui:lint` flags omission). Webfonts are NOT auto-loaded: build per-tune Google Fonts links from `src/resources/tune-fonts.json` (recipe in README "Loading the fonts") instead of an all-tunes link — CJK families are ~330KB of font CSS each, so load only what your tunes use and lazy-load the CJK `<link>`.

### Class vocabulary — what you actually write

Because the layers are orthogonal, every class string you author — a composer, a Blade, raw app markup, or a non-Blade adapter — is **plain Tailwind v4 by default, with exactly two exceptions**:

| Concern | Use | Not |
|---|---|---|
| **Color** | daisyUI semantic color classes — `bg-primary` `text-primary-content` `bg-base-200` `text-base-content` `border-base-300` `text-error` `bg-success/10` (these track `data-theme`) | a fixed palette/hex (`bg-blue-500`, `#1d4ed8`) — ignores the theme |
| **Shape · border · size · _rhythmic_ spacing** (must follow the tune) | pinion-ui tune classes & tune tokens — `tune-border`; `tune-btn-{xs,sm,md,lg}` / `tune-input-*` / `tune-textarea-*` / `tune-card-pad`; **t-shirt spacing utilities** — every Tailwind spacing-namespace utility with a t-shirt suffix is tune-reactive: `p-md` `px-sm` `py-4xl` `gap-lg` `gap-2xs` `mt-2xl` `mb-md` `space-y-lg` `space-x-xs` … sizes `3xs 2xs xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl` (ramp: `3xs`=2px `2xs`=4px `xs`=8px `sm`=12px `md`=16px `lg`=24px `xl`=32px `2xl`=48px `3xl`=64px `4xl`=80px, then 96/128/160px — bases the tune then scales); raw tokens `rounded-[var(--radius-box)]` `[var(--spacing-*)]` `[var(--h-field-md)]` (all track `data-tune`) | a fixed Tailwind value (`rounded-lg`, `h-10`, `border`, `gap-12`, `mb-10`, `space-y-4`) where the value is *rhythm* and should follow `data-tune` |
| **Everything else** | plain Tailwind v4 — layout (`flex grid items-center`), **purely-optical/fixed** spacing (a `mt-0.5` baseline nudge, a fixed `gap-2` between an icon and its label *when you do not want it to breathe*), typography (`text-sm font-semibold`), state (`hover:… disabled:…`) | — |

**Ink — semantic colors as a foreground (v0.17.0 primary, v0.23.0 all eight).**

- Every theme block carries **`--pn-ink`** (primary; read by pinion-sections) and **`--pn-ink-<name>`** for `secondary` `accent` `neutral` `info` `success` `warning` `error`: the color itself at ≥ 4.5:1 on `base-100` and `base-200`, else pushed toward `base-content` to 5.0:1, hue kept.
- The preset re-routes these foreground utilities to ink at build time: `text-<name>[/N]`, `border-{,t,b,l,r,x,y}-<name>[/N]`, `ring-` `outline-` `decoration-` `fill-` `stroke-` `caret-<name>`, `bg-<name>/N` with **N ≤ 50**. Untouched: `bg-<name>`, `bg-<name>/N` N > 50, `text-<name>-content`.
- **Write `text-warning` / `border-neutral` / `bg-success/10` as before; never reach for another color or a hex to "fix" one that looks invisible.** Hand-written CSS: `var(--pn-ink-<name>, var(--color-<name>))` for text, lines, rings, thin bands.
- `text-ink-<name>` / `bg-ink-<name>` are explicit aliases; use **`bg-ink-<name>`** for an opaque fill that reads as a line or indicator (progress / upload fills, stepper / timeline connectors, range track, **the checked toggle fill** — `solid` / `wire` rail and `soft` thumb, v0.25.2: a toggle is a state lamp with no glyph, its `stateLabel` ON text is `base-100`). Checked checkbox / radio fills, tooltip arrow, theme-swatch dots stay `bg-<name>` (surfaces with a `-content` mark).
- **A filled face has no rim** (`border-<name>` is ink: a 1px rim wherever ink ≠ color). `solid-<name>` variants, checked solid checkbox / radio / toggle (the toggle's fill is ink since v0.25.2), active pagination item, tooltip bubble + arrow, stepper `done` circle: `bg-<name> border-transparent`. Hovered `outline` / `wire` button (it fills): `hover:border-transparent`. `outline` / `soft` / `wire` keep `border-<name>[/N]` at rest (the border is the signal). Exception: checked **`wire`** checkbox / radio / toggle keep `peer-checked:border-<name>` — line-drawn, its outline stays when filled. **`neutral` tone:** checkbox / radio route `neutral` → `base-content` in every appearance except `solid` (the `-content` glyph still reads); the **toggle** routes it in `solid` / `soft` / `wire` alike (v0.25.1 — no glyph, and on dark themes `neutral` ≈ the rail).
- `ui:eject` freezes the same routing (`ink` + `ink-<name>` columns in `eject-table.json`, `bg-ink-<name>` included).

**Never daisyUI _component_ classes** (`.btn` `.card` `.badge` `.input` `.menu` …): excluded from the build (see gotchas) — they produce no styling. Compose the look from the three vocabularies above, or use the `<x-…>` component. The rule in one line: **plain Tailwind, except daisyUI color classes (color) and pinion-ui tune classes/tokens (shape · size · rhythmic space).**

**Rhythmic vs optical spacing.** The dividing line for spacing is *not* size — it is **purpose**, and the two scales make it self-documenting: **t-shirt = rhythmic** (tune-reactive), **numeric = optical** (fixed). *Rhythmic* spacing is the page's breathing: section padding (`py-4xl`), gaps between cards/sections (`gap-2xl`, `gap-lg`), heading→body margins (`mb-md`), list vertical rhythm (`space-y-md`), down to the smallest `gap-2xs` (icon↔label, chip gaps). Use t-shirt sizes so a tight tune (`corporate`, `tech`) reads denser and an airy one (`minimal`, `soft`) reads roomier — otherwise the page only morphs its shape/font and the spacing stays frozen. *Optical* spacing is a fixed nudge that aligns one element (a `mt-0.5` shift to sit an icon on the text baseline, a deliberate `gap-2` that must never breathe); a t-shirt value would morph it and misalign, so keep it numeric/arbitrary. This convention is **deliberately not machine-enforced** — rhythmic-vs-optical is designer *intent*, which no token-level check can recover without false-positive noise; it stays an authoring guideline. `ui:lint --spacing` prints a **non-gating census** (rhythmic vs optical counts + optical locations) so drift stays visible: when a page barely reacts to `data-tune` spacing, a high optical share is the tell. To fix it in bulk, **`php artisan ui:spacing-migrate [paths…]`** converts numeric spacing to the nearest t-shirt size (`p-4`→`p-md`, `py-10`→`py-2xl`; log-space nearest, so `p-5`→`p-lg`) — dry-run by default, `--write` applies, `--json` for automation. It leaves `*-px` / `*-0` / arbitrary values / `pinion-lint-ignore`-marked lines untouched and skips values with no close t-shirt (>×1.5 off, e.g. `p-64`); after a bulk `--write`, review the diff and re-opticalize the few genuine fixed nudges. Every `--write` auto-snapshots to `storage/pinion-ui/rewrites/` — `--runs` lists them, `--undo` (or `--undo --run=<id>`) reverts, hash-guarded so files hand-edited since the rewrite are reported and left alone.

**Ejecting — freeze theme × tune to vanilla Tailwind.** The reverse direction also exists: **`php artisan ui:eject [paths…] [--tune=default] [--strength=md] [--theme=pinion]`** rewrites pinion-ui classes to the plain Tailwind that reproduces the chosen rendering — t-shirt spacing → numeric (`p-md`→`p-4` under default/md), tune utilities expanded (`tune-btn-md`→`h-9 px-3.5 text-[14px]`), `[var(--token)]` → measured values, semantic colors → hex (`bg-primary`→`bg-[#131110]`, opacity modifiers preserved). Migrate to adopt, eject to leave — no lock-in. Dry-run by default; `--write` applies (snapshot + `--undo` as above); rebuild your CSS afterwards so Tailwind generates the new classes. Values are browser-measured per tune×strength (`src/resources/eject-table.json`). Not ejected (reported): per-tune fonts (`font-heading` …, load families manually), unknown `var(--…)` tokens, and `<x-…>` component tags — those are structural Blade, replace them by hand if you are fully leaving.

**Which size at which structural level.** The t-shirt sizes form a ladder (`3xs` 2px < `2xs` 4px < `xs` 8px < `sm` 12px < `md` 16px < `lg` 24px < `xl` 32px < `2xl` 48px < `3xl` 64px < `4xl` 80px < `5xl`–`7xl` 96–160px, base values — all scale with tune strength). Pick the size by *what two things you're spacing apart*, not by eyeballing a pixel value:

| Structural level | What it separates | Size to use |
|---|---|---|
| **Between page sections** | One `<section>`/major page block to the next (hero → features → footer) | `4xl` (`py-4xl` / `mt-4xl`) |
| **Inside a section, between its sub-blocks** | A section's heading block → its content grid; a card's header → its body | `2xl` (`p-2xl` / `gap-2xl` / `mt-2xl`) |
| **Between sibling elements/components** | Cards in a grid, stacked form fields, list items, rows in a stack | `lg` (`gap-lg` / `mt-lg` / `mb-lg` / `p-lg` / `space-y-lg`) |
| **Within one component, between paragraphs/lines of running text** | Paragraph-to-paragraph inside a card body, label → helper text | `md` (`gap-md` / `mt-md` / `mb-md` / `space-y-md`) |
| **Within one component, compact internal padding/gaps** | Dense table cells, a compact card's own padding, tightly-packed toolbar groups | `sm` (`gap-sm` / `p-sm` / `px-sm` / `py-sm` / `space-y-sm`) |
| **Between small inline items on one line/row** | Icon + label, badge + adjacent text, breadcrumb segments | `xs` (`gap-xs` / `space-x-xs`) |
| **Tightest — within a tiny cluster** | Icon↔label inside a button, chip-internal gaps, dense list bullets | `2xs` (`gap-2xs` / `space-x-2xs` / `space-y-2xs`) |

When in doubt, pick the tier one level *tighter* than you'd guess — the tune's own strength setting (`data-tune-strength`) already amplifies the gap on airier tunes, so authoring at the "natural" density and letting the tune stretch it reads better than pre-inflating the choice.

### Edge dissolve — `pn-feather-{t,b,l,r,x,y}`

Mask utilities that melt an element's edge into the page background with **no visible boundary line** (the alpha curve is `(1 − smoothstep)²`, sampled so the fade's onset has zero slope — a plain linear fade always shows a Mach-band "line" where it starts). Use them wherever content should trail off instead of being cut: horizontal scroller / carousel / logo-marquee edges, a long-text bottom fade, an image blending into a section.

```html
<div class="overflow-x-auto pn-feather-x">…</div>          <!-- both horizontal edges -->
<div class="pn-feather-b" style="--pn-feather: 8rem">…</div> <!-- bottom, custom depth -->
```

- Depth knob: `--pn-feather` (default `4rem`; `%` of the element also works).
- One `mask-image` per element — `-x` and `-y` don't combine (later wins).
- The mask clips **everything** inside the feather zone, including children's box-shadows.
- Cost is a static compositor mask — zero per-frame work, safe on animated content.

### Enforcing the rule — `ui:lint` (universal) + automation adapters

The rule is machine-checkable. **`php artisan ui:lint [paths…] [--json]`** is the **universal interface** — pure PHP (no Laravel needed at the core), exits non-zero on violations. It flags excluded daisyUI component classes, fixed/hex colors (ignore `data-theme`), and a root `<html>` missing **`data-theme` / `data-tune`** (the theme × tune cascade root). **Any** CLI agent (Claude Code, Cursor, aider, …), CI, or human runs the same command — so there is **no per-agent adapter to maintain**; agents that want feedback simply call `ui:lint --json` and read the result. Add `--spacing` for the informational rhythmic-vs-optical spacing census (never affects the exit code; in `--json` it appears as a `spacing` key).

Three ways to automate it (pick any; all call the one command):

| Automation | Scope | Install |
|---|---|---|
| **CI / manual** | any | `php artisan ui:lint` (non-zero exit fails the job) |
| **git pre-commit** | **agent-agnostic** — human, CI, any agent | `php artisan ui:install --git-hook` (blocks a commit whose staged Blade violates; never clobbers an existing hook) |
| **Claude Code PostToolUse** | Claude Code (smooth in-edit feedback) | `ui:install` installs it by default (`--skip-hooks` to opt out) — runs `ui:lint` on each edited Blade and feeds violations back into the model's context |

Suppress an intentional exception with a `pinion-lint-ignore` comment on the line (or the line above).

## Theme lineup & selection guide (v0.7.0)

pinion-ui ships **only original themes**. daisyUI's built-in themes are disabled (`themes: false`) — setting `data-theme="dracula"` or `"light"` does nothing. The lineup is 54 themes, each a **light/dark pair** (Tonal = tone-on-tone sets since v0.15.0: primary / secondary / accent share the base hue; primary is a surface tone one step off the canvas in both modes, so `bg-primary` surfaces flip with the mode and never invert. Such a primary is unreadable as *text* — which is why every theme block carries an ink per semantic color (`--pn-ink` / `--pn-ink-<name>`) and the preset re-routes the foreground uses of every color to it; see **Ink** under the color vocabulary. Nothing to do on your side: keep writing `text-primary` / `text-warning`):

- **Naming**: `<name>` = light, `<name>-dark` = dark (`payments` / `payments-dark`), and **all names are bare** — the v0.6 `mood-` prefix was dropped in v0.7.0 (`mood-zen` → `zen`, …; the whole prefix is gone). A theme's category (Brand / Mood / SaaS / Industry / Tonal) is metadata: it lives in `lineup.json`, in the Group column below, and as the grouped headings + category chip in `<x-theme-tune-switcher>` — it is no longer encoded in the name.
- **Default**: `pinion` applies at `:root` automatically when no `data-theme` is set; if the OS prefers dark (`prefers-color-scheme: dark`), `pinion-dark` applies instead (it carries daisyUI's `prefersdark` flag). An explicit `data-theme` on any element always wins over both. To pin light regardless of OS setting, set `data-theme="pinion"` explicitly.
- **Page canvas vs component face**: every theme paints the page background (`:root`) with its tinted canvas color and puts components on `base-100` (white in light themes) — do **not** hardcode a body background. `bg-base-200` equals the canvas color (recessed wells, hover); `border-base-300` is the matching border tone.
- **`reactive`** — one extra opt-in, light-only theme (GitHub-Light-adjacent, for report tooling). Not part of the pairs. As of v0.6.1 the brand default `pinion` shares this palette family, so `reactive` mostly matters for the report tooling that hardcodes its name.
- **App-wide look (v0.20.0)**: `config('pinion-ui.look')` holds the default theme × tune, the allowlist and the `?theme=`/`?tune=`/`?bg=` policy. Put `pn_look()` on `<html>`, `<x-look-head />` in `<head>` before `@vite` (applies the remembered choice before first paint), and `<x-backdrop />` after `<body>` for the page background (`flow` is animated). The switchers default to the same config, so do not hand-write `data-theme` per page. See `reference/components/look-head.md` / `backdrop.md`.
- **Switchers**: `<x-theme-switcher>` cycles `pinion` ↔ `pinion-dark` by default; `<x-theme-tune-switcher>` shows the whole grouped lineup with a light/dark mode toggle; `<x-settings-switcher>` consolidates theme × tune × lang into one panel for tight (mobile) chrome; `<x-lang-switcher>` is the matching standalone locale picker. The theme/tune ones carry a faint "pinion-ui" attribution link pinned at the dropdown's top-right (default → the GitHub repo; `link="site"` → pinion-ui.dev; `:attribution="false"` removes it). **Switcher chrome is tune-neutral** (v0.8.2): the picker renders identically under every tune — only the per-option previews react. The mechanism is the `.tune-exempt` class (`tune.css`), which you can put on your own site chrome too (navbar, admin bars) to pin the base font and lift effect-level tune styling like `draft`'s rough filter.

### Picking a theme (for AI agents scaffolding an app)

Match the app's domain/vibe against the trigger column; when nothing clearly matches, use `pinion`. Wire it as `<html data-theme="{theme}" data-tune="{tune}">` and offer dark mode with `{theme}-dark`.

| Theme (light / dark) | Group | Use when the app is… |
|---|---|---|
| `pinion` / `pinion-dark` | Brand | **The default — anything without a clearer match.** The pinion-ui face: clean white technical-document palette (GitHub-Primer-adjacent blue / green / purple; near-black ink). |
| `monokai` / `-dark` | Mood | Editor-like or developer-facing UI(エディタ系・開発者向け) |
| `neotokyo` / `-dark` | Mood | Japanese × cyber, nightlife(和×サイバー・ナイトライフ) |
| `frost` / `-dark` | Mood | Clean sky-blue on white — engineering blogs / dev docs (Zenn-like)(技術ブログ・エンジニア向け情報共有・Zenn 風のクリーンな青) |
| `ember` / `-dark` | Mood | Warm ivory × clay, refined minimal AI-product UI (claude.ai-like)(AI プロダクト・上質で温かいミニマル UI・claude.ai 風) |
| `vapor` / `-dark` | Mood | Soft retro, Gen-Z products(ソフトなレトロ・Z世代向け) |
| `botanical` / `-dark` | Mood | Flowers, gardening, organic, cosmetics(花・園芸・オーガニック・コスメ) |
| `solar` / `-dark` | Mood | Editor-like / precise developer tooling (Solarized-derived)(エディタ系・開発者向け・精密なツール系) |
| `zen` / `-dark` | Mood | Wabi-sabi, craft, ryokan, minimalist taste(和風・工芸・旅館) |
| `pop` / `-dark` | Mood | Entertainment, youth, campaign LPs(エンタメ・若年層・キャンペーン LP) |
| `macaron` / `-dark` | Mood | Friendly diagnostics, quizzes, forms with soft clay illustration — pair with `plush`(やわらかい診断・クイズ・フォーム。tune は plush) |
| `verdigris` / `-dark` | Mood | Warm ivory × retro teal-green; calm, warm products(ウォームアイボリー×レトログリーン。旧ブランド既定) |
| `bigblue` / `-dark` | Mood | Buttoned-up enterprise / B2B(エンタープライズ・B2B 堅め) |
| `led` / `-dark` | Mood | Retro-future hardware: monochrome canvas, indicator-light amber / phosphor green / red(レトロフューチャーの機器。モノクロ地に表示灯のアンバー・緑・赤。ガジェット・IoT・ダッシュボード) |
| `synth` / `-dark` | Mood | Retro-future neon: monochrome canvas, CMY magenta / cyan / yellow(レトロフューチャーのネオン。モノクロ地に CMY。音楽・イベント・クリエイティブ) |
| `phosphor` / `-dark` | Mood | Retro-future CRT: monochrome canvas, phosphor green / cyan / orange afterglow(レトロフューチャーの CRT。モノクロ地に蛍光体の緑・シアン・橙。ターミナル・監視・SF) |
| `nixie` / `-dark` | Mood | Retro-future vacuum tube: charcoal canvas, nixie orange / cold blue-white / violet(レトロフューチャーの真空管。炭の地にニキシー橙・青白・菫。計器・時計・オーディオ) |
| `payments` / `-dark` | SaaS | Payments, fintech(決済・フィンテック) |
| `docs` / `-dark` | SaaS | Documentation, knowledge base(ドキュメント・ナレッジベース) |
| `mono` / `-dark` | SaaS | Minimal tools, portfolios(ミニマルツール・ポートフォリオ) |
| `carbon` / `-dark` | Tonal | Steel-cool charcoal; primary/secondary/accent in the base hue, dark primary stays dark(鋼の冷灰・同系色・反転しない) |
| `glyph` / `-dark` | Tonal | Hardest pure black × pure white, Nothing-like(純黒×純白・Nothing 風) |
| `noir` / `-dark` | Tonal | Sepia warm black, tan accent(セピアの暖黒・タン) |
| `tide` / `-dark` | Tonal | `solar`'s teal × ivory, tone-on-tone(solar の色調を同系色で) |
| `nocturne` / `-dark` | Tonal | `neotokyo`'s indigo-violet night, tone-on-tone(neotokyo の藍紫を同系色で) |
| `clay` / `-dark` | Tonal | `ember` (claude.ai-like) ivory × clay, tone-on-tone(claude.ai 風を同系色で) |
| `moss` / `-dark` | Tonal | Olive-grey, tone-on-tone — calm hardware, outdoor, tools(オリーブ灰の同系色。落ち着いた機器・アウトドア・工具) |
| `quartz` / `-dark` | Tonal | Rose-grey, tone-on-tone — soft devices, beauty, daily goods(薔薇灰の同系色。やわらかい機器・美容・生活雑貨) |
| `midnight` / `-dark` | Tonal | Navy, tone-on-tone — night instrument panel(紺の同系色。深夜の計器盤。金融・セキュリティ・夜間 UI) |
| `glacier` / `-dark` | Tonal | Icy cyan-white, tone-on-tone — clean, clinical, Nordic(氷シアンの同系色。清潔・医療機器・冷蔵・北欧) |
| `signal` / `-dark` | Tonal | Achromatic paper × ink, red only as the status (`error`) lamp — dot-matrix, inspection / verdict tools(無彩の紙と墨。赤は状態表示の error だけ。ドット表現・検査や判定のツール) |
| `ops` / `-dark` | SaaS | PM, operations, internal tools(PM・運用・社内ツール) |
| `finance` / `-dark` | SaaS | Finance / legal enterprise(金融・法務エンタープライズ) |
| `people` / `-dark` | SaaS | HR, community(HR・コミュニティ) |
| `health` / `-dark` | SaaS | Medical / wellness SaaS(医療・ウェルネス SaaS) |
| `analytics` / `-dark` | SaaS | BI, data visualization(BI・データ可視化) |
| `devtool` / `-dark` | SaaS | Developer tools, API products(開発者ツール・API 系) |
| `comms` / `-dark` | SaaS | Chat, collaboration(チャット・コラボレーション) |
| `growth` / `-dark` | SaaS | Marketing, growth, landing pages(マーケ・グロース・LP 系) |
| `commerce` / `-dark` | Industry | EC, retail, marketplaces(EC・リテール・マーケットプレイス) |
| `education` / `-dark` | Industry | EdTech, learning services(EdTech・学習サービス) |
| `legal` / `-dark` | Industry | Legal, professional services, contracts(法務・士業・契約管理) |
| `logistics` / `-dark` | Industry | Logistics, delivery, mobility(物流・配送・モビリティ) |
| `media` / `-dark` | Industry | Media, publishing, news(メディア・出版・ニュース) |
| `security` / `-dark` | Industry | Security, audit, auth(セキュリティ・監査・認証) |
| `kids` / `-dark` | Industry | Kids / family products(子ども・ファミリー向け) |
| `wellness` / `-dark` | Industry | Yoga, meditation, mental care(ヨガ・瞑想・メンタルケア) |
| `civic` / `-dark` | Industry | Government, public sector(行政・公共・自治体) |
| `atelier` / `-dark` | Industry | Luxury brands, D2C, jewelry(高級ブランド・D2C・ジュエリー) |
| `estate` / `-dark` | Industry | Real estate, architecture(不動産・建築・設計) |
| `food` / `-dark` | Industry | Food, delivery(飲食・フードデリバリー) |
| `travel` / `-dark` | Industry | Travel, hotels, resorts(旅行・ホテル・リゾート) |
| `creative` / `-dark` | Industry | Creative / design tools(クリエイティブツール・デザイン) |
| `agri` / `-dark` | Industry | Agriculture, greentech(農業・植物工場・グリーンテック) |
| `factory` / `-dark` | Industry | Manufacturing, heavy industry(製造・重工業・設備) |

Canonical machine-readable source: `src/resources/themes/lineup.json` (name, category, trigger, full palettes) — the same file that generates the theme CSS, so it can never drift. In PHP, `pn_theme_groups()` returns the grouped light/dark ids.

## daisyUI v5 gotchas (verified — do not "fix")

- **daisyUI component classes AND built-in themes don't exist in your build.** The pinion-ui preset loads daisyUI with `themes: false` + a full component exclude list: you get the color utility layer (`bg-primary`, `text-base-content`, `data-theme` switching) but `.btn`, `.card`, `.alert`, `.input`, `.menu`, `.modal`, etc. produce **no styling**, and daisyUI stock theme names (`light`, `dark`, `dracula`, …) set as `data-theme` render **unthemed** — only the pinion-ui lineup (previous section) exists. Never write daisyUI component markup (`<button class="btn btn-primary">`) — use the pinion-ui component (`<x-button color="primary">`). Do not "fix" this by adding `@plugin "daisyui"` to app.css; that re-enables everything and breaks the design boundary.
- `divider-horizontal` renders a **vertical** line inside a flex row (daisyUI naming is inverted). The `<x-divider>` wrapper normalizes: `direction="vertical"` does what you expect.
- `rating-half` requires the explicit `rating-{size}` class even at default size, or half-star widths collapse. `RatingComposer` always emits it.
- `<x-collapse>` defaults to **no icon** since v0.2.1 — opt in with `icon="arrow"` or `icon="plus"`.
- `<x-tooltip>` no longer uses daisyUI's CSS `tooltip` / `data-tip` system (dropped in v0.3.11 for an Alpine + custom-arrow approach; daisyUI's `tooltip` CSS is excluded from the build). The `text` / `position` / `color` / `open` props are unchanged.
- **Dropdown / menu panels take no horizontal padding.** `<x-dropdown>`'s panel is `py-1` only; the spacing belongs to the rows (`<x-menu-item>` carries its own `px`), and separators are `<x-menu-divider>` (full-bleed `border-t`). Don't add `px-*` to the panel or wrap rows in a padded `<div>` — a divider inside it stops short of the panel edges. Need the panel to span its parent (sidebar footer)? `<x-dropdown block>`.
- Several components ship **locale-aware aria/label defaults** resolved through `pn_trans()` → `config('pinion-ui.locale')` (default `ja`; an `en` bucket also ships). Components that pull defaults this way:
  - `<x-notification-system>` — toast dismiss button (`notification.close`)
  - `<x-rating>` — clear-radio aria-label (`rating.none`)
  - `<x-pagination>` / `<x-pagination-simple>` — `prev` / `next` / info template / aria (`pagination.*`)
  - `<x-table-scroll>` — scroll buttons aria-label (`table_scroll.prev` / `.next`)
  - `<x-select>` — placeholder (`select.placeholder`)

**To switch the whole app's component strings to English**: set `PINION_UI_LOCALE=en` (or `config('pinion-ui.locale')` directly). This is intentionally independent of Laravel's `config('app.locale')`. **Per-call override still wins**: pass the prop explicitly (`<x-pagination prevLabel="Older" nextLabel="Newer" />`). **Add a locale**: extend the `translations` array in the published `config/pinion-ui.php`. Missing locale/key falls back to the literal Japanese string baked into the component as `pn_trans()`'s second argument.

For class names not covered above, always grep `docs/{daisyui,preline,penguinui}/` (local third-party docs, gitignored) before claiming a class behaviour.

## Lookup workflow

For any component, read `reference/components/{name}.md`:
- Props (table with types and defaults)
- Slots
- Examples (basic + variants + edge cases)
- Class composition (link to the composer source)
- Related components and gotchas

`reference/components/index.md` is the category-grouped table of contents.

## Required peer dependency

`sparrowhawk-labs/pinion-icons` — every component that renders icons (`icon=`, `iconRight=`, internal `<x-i>`) needs it installed. The `pinion-ui` package hard-requires it.

## Alpine inside `<x-...>` components — use the full prefix

Blade's anonymous-component compiler treats `:prop="..."` and `@event="..."` as **PHP expressions** ("pass this PHP value to the prop / event"). Alpine's shorthand `:class` / `:value` / `@click` therefore breaks at runtime when used on `<x-button>`, `<x-input>`, or any other `<x-...>` element — typically with `Undefined constant "..."`.

Use the full Alpine prefix on `<x-...>` elements:

```blade
{{-- ✗ breaks: Blade evaluates `active === 'left'` as PHP --}}
<x-button :class="active === 'left' && '!bg-primary'" @click="active = 'left'">Left</x-button>

{{-- ✓ works: full Alpine prefix bypasses Blade --}}
<x-button x-bind:class="active === 'left' && '!bg-primary'" x-on:click="active = 'left'">Left</x-button>
```

Shorthand `:class` / `@click` is fine on plain HTML elements (`<button>`, `<div>`, `<input>`) — Blade only intercepts when the tag starts with `<x-`.

## Livewire integration

pinion-ui is a **Blade-only library** (no Livewire components inside). Components work inside Livewire component Blade trees without any special setup. The notes below document what works, what doesn't, and why.

### wire:model compatibility matrix

| Component(s) | wire:model support | Notes |
|---|---|---|
| `input`, `textarea`, `select`, `checkbox`, `radio`, `toggle`, `range-slider`, `input-number`, `file-upload` | ✅ Full — direct passthrough | `wire:` attrs forwarded via `$attributes->whereStartsWith('wire:')` to the native form element |
| `rating` | ✅ Full — native radio | `wire:model` is forwarded to each `<input type="radio">` with the correct `value=` attr. `.live` works. |
| `pin-input` | ✅ Supported (dispatch pattern) | `wire:model` goes on a dedicated hidden `<input>`. An Alpine `$watch` on `combined` dispatches a native `input` event → Livewire is notified on every digit change. |
| `radio-group` | ❌ Not supported on wrapper | Do **not** put `wire:model` on `<x-radio-group>`. Apply it to each child `<x-radio wire:model="field" value="x">` directly. |
| `input-number` | ⚠️ Partial | User-typed changes notify Livewire (native `input` event). Clicking the **+/−** buttons changes Alpine's `v` programmatically — no native `input` event fires, so Livewire is **not** notified. Add an `x-effect` / `$watch` at the call site if you need button-click sync. |

### Detecting wire:model in Blade (UI library pattern)

Always use `$attributes->whereStartsWith('wire:model')` — **not** `$attributes->wire('model')`. The `wire()` macro is registered by Livewire at runtime; pinion-ui must work in apps without Livewire installed.

### Loops: always add wire:key

Morphdom needs a stable key on every repeated Livewire-rendered component:

```blade
@foreach($items as $item)
    <x-card wire:key="item-{{ $item->id }}">…</x-card>
@endforeach
```

### wire:loading on buttons

```blade
{{-- spinner while any Livewire request is in-flight --}}
<x-button wire:loading.attr="disabled" wire:target="save">Save</x-button>
```

`$attributes` on `button` is merged at the root `<button>` element, so `wire:loading` / `wire:target` / `wire:click` all work as expected.

## When in doubt

1. Read the component's reference doc under `reference/components/`.
2. Read the composer source (if any) — class strings there are authoritative.
3. Grep `docs/{daisyui,preline,penguinui}/` for upstream behaviour.
4. Add a fixture case to capture new behaviour; do not just edit code.

## Reporting changes back

When you add a prop or change behaviour:
- Update the component's `reference/components/{name}.md`.
- Update `tests/fixtures/compose/{name}.json` with new cases.
- Update `reference/components/index.md` if the one-line summary still fits; replace it if it no longer does.
- Update this file only when an architecture rule or gotcha changes.

AGENTS.md — pinion-ui (Laravel Blade adapter)

Stack: Laravel Blade + Tailwind v4 + daisyUI v5 + Alpine.js. This is the Blade stack adapter for Pinion UI. Vanilla / React / Vue / Web Components adapters will each ship as their own NPM package with their own AGENTS.md (planned v0.5+). One package = one stack adapter, self-contained.

Read this file before writing code that uses sparrowhawk-labs/pinion-ui. It captures the rules, gotchas, and lookup paths an AI agent needs to use Pinion UI correctly.

What this package is

A Laravel Blade component library (Tailwind v4 + daisyUI v5 + Alpine.js). 47 components — see reference/components/index.md.

The package also ships the mermaid/SVG color glue as build artifacts in dist/: dist/mermaid-theme.js (runtime JS — inline it inside a type="module" mermaid block so figures follow data-theme/data-tune) and dist/mermaid-theme.json (expanded color/theme data for server-side SVG rendering). Canon + regen live in scripts/mermaid-theme/ (node scripts/mermaid-theme/build.mjs).

Calling convention

  • Anonymous (default): <x-button>, <x-modal>, <x-tabs>, etc. Use this in app code.
  • Namespaced (disambiguation): <x-pn::button> — use only when a consumer app has its own <x-button> that conflicts.
  • Never use the old <x-pinion-ui::xxx> prefix — it was renamed to pn:: in v0.2.1.

Nested parent + children (v0.4.0+)

Two components compose as a parent with nested children rather than driven by a single array prop:

Parent Child What the child carries
<x-tabs> <x-tab name label :icon>{{ slot }}</x-tab> One tab button + one panel.
<x-accordion> <x-accordion-item title :name>{{ slot }}</x-accordion-item> One header + one disclosure region.
<x-table> <x-table.th> / <x-table.td> / <x-table.empty> (body rows stay plain <tr>) One header/body cell / the empty-state row.

Never hand-roll a <table> for data listings — use <x-table> (reference/components/table.md). It owns the whole "table in a card" recipe (card face, header row, cell rhythm, hover, last-row border, tabular-nums numeric columns, edge padding aligned to x-card's p-lg, empty state) that hand-rolled tables re-invent and get subtly wrong. Editable grids are the exception (<x-data-grid> / <x-sheet>, in the private sparrowhawk-labs/pinion-sections since v0.21.0).

<x-tabs variant="boxed">
    <x-tab name="overview" label="Overview"><p>…</p></x-tab>
    <x-tab name="specs"    label="Specs"><p>…</p></x-tab>
</x-tabs>

How it works under the hood (so the pattern doesn't surprise you):

  • Shared props via @aware: parent props (variant, size, multiple) flow into children through Blade @aware. Don't declare them again on the child call site — they're inherited.
  • Shared state via Alpine scope: the parent owns the only x-data (activeTab / open); children read and write it via Alpine's normal scope chain. Don't add a new x-data on a child.
  • Composer is called from both Blades: parent and child each call the same Composer::compose() so the class strings line up. This stays consistent with the rest of the architecture rules below.

Per-component docs cover the full prop tables and slot contracts: reference/components/tabs.md, reference/components/accordion.md. The previous array-driven shape (:tabs="[…]" / :items="[…]") was removed in v0.4.0 — see SEMVER.md.

Architecture rules (do not violate)

  1. Compose pattern: For most components, class strings live in src/Compose/{Name}Composer.php, not the Blade. Each composer has a static compose(array $props): array that returns a flat dict of class strings. The Blade reads $c['root'], $c['title'], etc. — it is render-only.
    • Components without a composer (classes embedded in the Blade): button, alert, card, badge, avatar, menu-item, section.hero, theme-switcher. These predate the pattern.
  2. Composer returns class strings only — no markup, no array values. If you need conditional markup, branch in the Blade based on a prop.
  3. Fixture tests: Each composer has tests/fixtures/compose/{name}.json covering its variant matrix. Run with php tests/Compose/run.php. Comparison is subset (only listed expected keys are checked). Add cases when you add props.
  4. Backwards compatibility: Never rename props or silently change their defaults. New props are opt-in (default preserves previous behaviour).

Three style layers (orthogonal)

Layer Where it lives Examples
Theme (color palette) <html data-theme="..."> pinion, pinion-dark, monokai, payments-dark (54 original light/dark pairs — see the theme lineup section below; daisyUI's built-in themes do not exist in the build)
Tune (shape / space / font) <html data-tune="..."> default, minimal, tech, editorial, soft, plush, proto (13 presets)
Component (variant / size / state) Blade props color="primary", size="lg", dismissible
Responsive root text (opt-in) <html data-text-scale="a|b"> drops the ROOT font-size on narrow screens, so everything authored in rem — glyphs, t-shirt spacing, field heights — shrinks together. Omit for no change.

Responsive root text — data-text-scale (v0.18.0). Opt-in, orthogonal to theme × tune, and a no-op unless you set it. Narrow screens fit fewer characters per line; lowering the root font-size is the one lever that shrinks a page proportionally, because every rem-authored value follows at once (per-element shrinking always drifts — some paddings follow, others don't). Two ladders, both written as a subtraction from the tune's own --font-size-base so they compose with minimal (.9375rem) and pixel (1.125rem) instead of replacing them:

value ≤768px ≤425px ≤375px ≤320px at a 16px base
a −0.8px −1.2px −1.6px −2.0px 15.2 / 14.8 / 14.4 / 14.0
b −1.0px −1.0px −2.0px −3.0px 15.0 / 15.0 / 14.0 / 13.0

The mechanism is one custom property, --pn-text-trim (0 by default), subtracted in the single root font-size declaration — so an app can also set it directly for a bespoke ladder. Subtracting px rather than setting px preserves the reader's own browser font-size setting: on the root element 1rem is that setting, so the ladder shifts with it.

Themes and Tunes mix freely. Both are activated by the pinion-ui.css preset (imported into app.css by ui:install), which bundles the data-tune token system from tune.css (since v0.11.0 a shim over tune-fonts.css = self-hosted PixelMplus @font-face + tune-core.css = tokens only). Both attributes have working defaults when omitted — a root without data-tune renders exactly as data-tune="default" (since v0.10.11) — but always declare them explicitly on <html> so the choice is visible (ui:lint flags omission). Webfonts are NOT auto-loaded: build per-tune Google Fonts links from src/resources/tune-fonts.json (recipe in README "Loading the fonts") instead of an all-tunes link — CJK families are ~330KB of font CSS each, so load only what your tunes use and lazy-load the CJK <link>.

Class vocabulary — what you actually write

Because the layers are orthogonal, every class string you author — a composer, a Blade, raw app markup, or a non-Blade adapter — is plain Tailwind v4 by default, with exactly two exceptions:

Concern Use Not
Color daisyUI semantic color classes — bg-primary text-primary-content bg-base-200 text-base-content border-base-300 text-error bg-success/10 (these track data-theme) a fixed palette/hex (bg-blue-500, #1d4ed8) — ignores the theme
Shape · border · size · rhythmic spacing (must follow the tune) pinion-ui tune classes & tune tokens — tune-border; tune-btn-{xs,sm,md,lg} / tune-input-* / tune-textarea-* / tune-card-pad; t-shirt spacing utilities — every Tailwind spacing-namespace utility with a t-shirt suffix is tune-reactive: p-md px-sm py-4xl gap-lg gap-2xs mt-2xl mb-md space-y-lg space-x-xs … sizes 3xs 2xs xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl (ramp: 3xs=2px 2xs=4px xs=8px sm=12px md=16px lg=24px xl=32px 2xl=48px 3xl=64px 4xl=80px, then 96/128/160px — bases the tune then scales); raw tokens rounded-[var(--radius-box)] [var(--spacing-*)] [var(--h-field-md)] (all track data-tune) a fixed Tailwind value (rounded-lg, h-10, border, gap-12, mb-10, space-y-4) where the value is rhythm and should follow data-tune
Everything else plain Tailwind v4 — layout (flex grid items-center), purely-optical/fixed spacing (a mt-0.5 baseline nudge, a fixed gap-2 between an icon and its label when you do not want it to breathe), typography (text-sm font-semibold), state (hover:… disabled:…) —

Ink — semantic colors as a foreground (v0.17.0 primary, v0.23.0 all eight).

  • Every theme block carries --pn-ink (primary; read by pinion-sections) and --pn-ink-<name> for secondary accent neutral info success warning error: the color itself at ≥ 4.5:1 on base-100 and base-200, else pushed toward base-content to 5.0:1, hue kept.
  • The preset re-routes these foreground utilities to ink at build time: text-<name>[/N], border-{,t,b,l,r,x,y}-<name>[/N], ring- outline- decoration- fill- stroke- caret-<name>, bg-<name>/N with N ≤ 50. Untouched: bg-<name>, bg-<name>/N N > 50, text-<name>-content.
  • Write text-warning / border-neutral / bg-success/10 as before; never reach for another color or a hex to "fix" one that looks invisible. Hand-written CSS: var(--pn-ink-<name>, var(--color-<name>)) for text, lines, rings, thin bands.
  • text-ink-<name> / bg-ink-<name> are explicit aliases; use bg-ink-<name> for an opaque fill that reads as a line or indicator (progress / upload fills, stepper / timeline connectors, range track, the checked toggle fill — solid / wire rail and soft thumb, v0.25.2: a toggle is a state lamp with no glyph, its stateLabel ON text is base-100). Checked checkbox / radio fills, tooltip arrow, theme-swatch dots stay bg-<name> (surfaces with a -content mark).
  • A filled face has no rim (border-<name> is ink: a 1px rim wherever ink ≠ color). solid-<name> variants, checked solid checkbox / radio / toggle (the toggle's fill is ink since v0.25.2), active pagination item, tooltip bubble + arrow, stepper done circle: bg-<name> border-transparent. Hovered outline / wire button (it fills): hover:border-transparent. outline / soft / wire keep border-<name>[/N] at rest (the border is the signal). Exception: checked wire checkbox / radio / toggle keep peer-checked:border-<name> — line-drawn, its outline stays when filled. neutral tone: checkbox / radio route neutral → base-content in every appearance except solid (the -content glyph still reads); the toggle routes it in solid / soft / wire alike (v0.25.1 — no glyph, and on dark themes neutral ≈ the rail).
  • ui:eject freezes the same routing (ink + ink-<name> columns in eject-table.json, bg-ink-<name> included).

Never daisyUI component classes (.btn .card .badge .input .menu …): excluded from the build (see gotchas) — they produce no styling. Compose the look from the three vocabularies above, or use the <x-…> component. The rule in one line: plain Tailwind, except daisyUI color classes (color) and pinion-ui tune classes/tokens (shape · size · rhythmic space).

Rhythmic vs optical spacing. The dividing line for spacing is not size — it is purpose, and the two scales make it self-documenting: t-shirt = rhythmic (tune-reactive), numeric = optical (fixed). Rhythmic spacing is the page's breathing: section padding (py-4xl), gaps between cards/sections (gap-2xl, gap-lg), heading→body margins (mb-md), list vertical rhythm (space-y-md), down to the smallest gap-2xs (icon↔label, chip gaps). Use t-shirt sizes so a tight tune (corporate, tech) reads denser and an airy one (minimal, soft) reads roomier — otherwise the page only morphs its shape/font and the spacing stays frozen. Optical spacing is a fixed nudge that aligns one element (a mt-0.5 shift to sit an icon on the text baseline, a deliberate gap-2 that must never breathe); a t-shirt value would morph it and misalign, so keep it numeric/arbitrary. This convention is deliberately not machine-enforced — rhythmic-vs-optical is designer intent, which no token-level check can recover without false-positive noise; it stays an authoring guideline. ui:lint --spacing prints a non-gating census (rhythmic vs optical counts + optical locations) so drift stays visible: when a page barely reacts to data-tune spacing, a high optical share is the tell. To fix it in bulk, php artisan ui:spacing-migrate [paths…] converts numeric spacing to the nearest t-shirt size (p-4→p-md, py-10→py-2xl; log-space nearest, so p-5→p-lg) — dry-run by default, --write applies, --json for automation. It leaves *-px / *-0 / arbitrary values / pinion-lint-ignore-marked lines untouched and skips values with no close t-shirt (>×1.5 off, e.g. p-64); after a bulk --write, review the diff and re-opticalize the few genuine fixed nudges. Every --write auto-snapshots to storage/pinion-ui/rewrites/ — --runs lists them, --undo (or --undo --run=<id>) reverts, hash-guarded so files hand-edited since the rewrite are reported and left alone.

Ejecting — freeze theme × tune to vanilla Tailwind. The reverse direction also exists: php artisan ui:eject [paths…] [--tune=default] [--strength=md] [--theme=pinion] rewrites pinion-ui classes to the plain Tailwind that reproduces the chosen rendering — t-shirt spacing → numeric (p-md→p-4 under default/md), tune utilities expanded (tune-btn-md→h-9 px-3.5 text-[14px]), [var(--token)] → measured values, semantic colors → hex (bg-primary→bg-[#131110], opacity modifiers preserved). Migrate to adopt, eject to leave — no lock-in. Dry-run by default; --write applies (snapshot + --undo as above); rebuild your CSS afterwards so Tailwind generates the new classes. Values are browser-measured per tune×strength (src/resources/eject-table.json). Not ejected (reported): per-tune fonts (font-heading …, load families manually), unknown var(--…) tokens, and <x-…> component tags — those are structural Blade, replace them by hand if you are fully leaving.

Which size at which structural level. The t-shirt sizes form a ladder (3xs 2px < 2xs 4px < xs 8px < sm 12px < md 16px < lg 24px < xl 32px < 2xl 48px < 3xl 64px < 4xl 80px < 5xl–7xl 96–160px, base values — all scale with tune strength). Pick the size by what two things you're spacing apart, not by eyeballing a pixel value:

Structural level What it separates Size to use
Between page sections One <section>/major page block to the next (hero → features → footer) 4xl (py-4xl / mt-4xl)
Inside a section, between its sub-blocks A section's heading block → its content grid; a card's header → its body 2xl (p-2xl / gap-2xl / mt-2xl)
Between sibling elements/components Cards in a grid, stacked form fields, list items, rows in a stack lg (gap-lg / mt-lg / mb-lg / p-lg / space-y-lg)
Within one component, between paragraphs/lines of running text Paragraph-to-paragraph inside a card body, label → helper text md (gap-md / mt-md / mb-md / space-y-md)
Within one component, compact internal padding/gaps Dense table cells, a compact card's own padding, tightly-packed toolbar groups sm (gap-sm / p-sm / px-sm / py-sm / space-y-sm)
Between small inline items on one line/row Icon + label, badge + adjacent text, breadcrumb segments xs (gap-xs / space-x-xs)
Tightest — within a tiny cluster Icon↔label inside a button, chip-internal gaps, dense list bullets 2xs (gap-2xs / space-x-2xs / space-y-2xs)

When in doubt, pick the tier one level tighter than you'd guess — the tune's own strength setting (data-tune-strength) already amplifies the gap on airier tunes, so authoring at the "natural" density and letting the tune stretch it reads better than pre-inflating the choice.

Edge dissolve — pn-feather-{t,b,l,r,x,y}

Mask utilities that melt an element's edge into the page background with no visible boundary line (the alpha curve is (1 − smoothstep)², sampled so the fade's onset has zero slope — a plain linear fade always shows a Mach-band "line" where it starts). Use them wherever content should trail off instead of being cut: horizontal scroller / carousel / logo-marquee edges, a long-text bottom fade, an image blending into a section.

<div class="overflow-x-auto pn-feather-x">…</div>          <!-- both horizontal edges -->
<div class="pn-feather-b" style="--pn-feather: 8rem">…</div> <!-- bottom, custom depth -->
  • Depth knob: --pn-feather (default 4rem; % of the element also works).
  • One mask-image per element — -x and -y don't combine (later wins).
  • The mask clips everything inside the feather zone, including children's box-shadows.
  • Cost is a static compositor mask — zero per-frame work, safe on animated content.

Enforcing the rule — ui:lint (universal) + automation adapters

The rule is machine-checkable. php artisan ui:lint [paths…] [--json] is the universal interface — pure PHP (no Laravel needed at the core), exits non-zero on violations. It flags excluded daisyUI component classes, fixed/hex colors (ignore data-theme), and a root <html> missing data-theme / data-tune (the theme × tune cascade root). Any CLI agent (Claude Code, Cursor, aider, …), CI, or human runs the same command — so there is no per-agent adapter to maintain; agents that want feedback simply call ui:lint --json and read the result. Add --spacing for the informational rhythmic-vs-optical spacing census (never affects the exit code; in --json it appears as a spacing key).

Three ways to automate it (pick any; all call the one command):

Automation Scope Install
CI / manual any php artisan ui:lint (non-zero exit fails the job)
git pre-commit agent-agnostic — human, CI, any agent php artisan ui:install --git-hook (blocks a commit whose staged Blade violates; never clobbers an existing hook)
Claude Code PostToolUse Claude Code (smooth in-edit feedback) ui:install installs it by default (--skip-hooks to opt out) — runs ui:lint on each edited Blade and feeds violations back into the model's context

Suppress an intentional exception with a pinion-lint-ignore comment on the line (or the line above).

Theme lineup & selection guide (v0.7.0)

pinion-ui ships only original themes. daisyUI's built-in themes are disabled (themes: false) — setting data-theme="dracula" or "light" does nothing. The lineup is 54 themes, each a light/dark pair (Tonal = tone-on-tone sets since v0.15.0: primary / secondary / accent share the base hue; primary is a surface tone one step off the canvas in both modes, so bg-primary surfaces flip with the mode and never invert. Such a primary is unreadable as text — which is why every theme block carries an ink per semantic color (--pn-ink / --pn-ink-<name>) and the preset re-routes the foreground uses of every color to it; see Ink under the color vocabulary. Nothing to do on your side: keep writing text-primary / text-warning):

  • Naming: <name> = light, <name>-dark = dark (payments / payments-dark), and all names are bare — the v0.6 mood- prefix was dropped in v0.7.0 (mood-zen → zen, …; the whole prefix is gone). A theme's category (Brand / Mood / SaaS / Industry / Tonal) is metadata: it lives in lineup.json, in the Group column below, and as the grouped headings + category chip in <x-theme-tune-switcher> — it is no longer encoded in the name.
  • Default: pinion applies at :root automatically when no data-theme is set; if the OS prefers dark (prefers-color-scheme: dark), pinion-dark applies instead (it carries daisyUI's prefersdark flag). An explicit data-theme on any element always wins over both. To pin light regardless of OS setting, set data-theme="pinion" explicitly.
  • Page canvas vs component face: every theme paints the page background (:root) with its tinted canvas color and puts components on base-100 (white in light themes) — do not hardcode a body background. bg-base-200 equals the canvas color (recessed wells, hover); border-base-300 is the matching border tone.
  • reactive — one extra opt-in, light-only theme (GitHub-Light-adjacent, for report tooling). Not part of the pairs. As of v0.6.1 the brand default pinion shares this palette family, so reactive mostly matters for the report tooling that hardcodes its name.
  • App-wide look (v0.20.0): config('pinion-ui.look') holds the default theme × tune, the allowlist and the ?theme=/?tune=/?bg= policy. Put pn_look() on <html>, <x-look-head /> in <head> before @vite (applies the remembered choice before first paint), and <x-backdrop /> after <body> for the page background (flow is animated). The switchers default to the same config, so do not hand-write data-theme per page. See reference/components/look-head.md / backdrop.md.
  • Switchers: <x-theme-switcher> cycles pinion ↔ pinion-dark by default; <x-theme-tune-switcher> shows the whole grouped lineup with a light/dark mode toggle; <x-settings-switcher> consolidates theme × tune × lang into one panel for tight (mobile) chrome; <x-lang-switcher> is the matching standalone locale picker. The theme/tune ones carry a faint "pinion-ui" attribution link pinned at the dropdown's top-right (default → the GitHub repo; link="site" → pinion-ui.dev; :attribution="false" removes it). Switcher chrome is tune-neutral (v0.8.2): the picker renders identically under every tune — only the per-option previews react. The mechanism is the .tune-exempt class (tune.css), which you can put on your own site chrome too (navbar, admin bars) to pin the base font and lift effect-level tune styling like draft's rough filter.

Picking a theme (for AI agents scaffolding an app)

Match the app's domain/vibe against the trigger column; when nothing clearly matches, use pinion. Wire it as <html data-theme="{theme}" data-tune="{tune}"> and offer dark mode with {theme}-dark.

Theme (light / dark) Group Use when the app is…
pinion / pinion-dark Brand The default — anything without a clearer match. The pinion-ui face: clean white technical-document palette (GitHub-Primer-adjacent blue / green / purple; near-black ink).
monokai / -dark Mood Editor-like or developer-facing UI(エディタ系・開発者向け)
neotokyo / -dark Mood Japanese × cyber, nightlife(和×サイバー・ナイトライフ)
frost / -dark Mood Clean sky-blue on white — engineering blogs / dev docs (Zenn-like)(技術ブログ・エンジニア向け情報共有・Zenn 風のクリーンな青)
ember / -dark Mood Warm ivory × clay, refined minimal AI-product UI (claude.ai-like)(AI プロダクト・上質で温かいミニマル UI・claude.ai 風)
vapor / -dark Mood Soft retro, Gen-Z products(ソフトなレトロ・Z世代向け)
botanical / -dark Mood Flowers, gardening, organic, cosmetics(花・園芸・オーガニック・コスメ)
solar / -dark Mood Editor-like / precise developer tooling (Solarized-derived)(エディタ系・開発者向け・精密なツール系)
zen / -dark Mood Wabi-sabi, craft, ryokan, minimalist taste(和風・工芸・旅館)
pop / -dark Mood Entertainment, youth, campaign LPs(エンタメ・若年層・キャンペーン LP)
macaron / -dark Mood Friendly diagnostics, quizzes, forms with soft clay illustration — pair with plush(やわらかい診断・クイズ・フォーム。tune は plush)
verdigris / -dark Mood Warm ivory × retro teal-green; calm, warm products(ウォームアイボリー×レトログリーン。旧ブランド既定)
bigblue / -dark Mood Buttoned-up enterprise / B2B(エンタープライズ・B2B 堅め)
led / -dark Mood Retro-future hardware: monochrome canvas, indicator-light amber / phosphor green / red(レトロフューチャーの機器。モノクロ地に表示灯のアンバー・緑・赤。ガジェット・IoT・ダッシュボード)
synth / -dark Mood Retro-future neon: monochrome canvas, CMY magenta / cyan / yellow(レトロフューチャーのネオン。モノクロ地に CMY。音楽・イベント・クリエイティブ)
phosphor / -dark Mood Retro-future CRT: monochrome canvas, phosphor green / cyan / orange afterglow(レトロフューチャーの CRT。モノクロ地に蛍光体の緑・シアン・橙。ターミナル・監視・SF)
nixie / -dark Mood Retro-future vacuum tube: charcoal canvas, nixie orange / cold blue-white / violet(レトロフューチャーの真空管。炭の地にニキシー橙・青白・菫。計器・時計・オーディオ)
payments / -dark SaaS Payments, fintech(決済・フィンテック)
docs / -dark SaaS Documentation, knowledge base(ドキュメント・ナレッジベース)
mono / -dark SaaS Minimal tools, portfolios(ミニマルツール・ポートフォリオ)
carbon / -dark Tonal Steel-cool charcoal; primary/secondary/accent in the base hue, dark primary stays dark(鋼の冷灰・同系色・反転しない)
glyph / -dark Tonal Hardest pure black × pure white, Nothing-like(純黒×純白・Nothing 風)
noir / -dark Tonal Sepia warm black, tan accent(セピアの暖黒・タン)
tide / -dark Tonal solar's teal × ivory, tone-on-tone(solar の色調を同系色で)
nocturne / -dark Tonal neotokyo's indigo-violet night, tone-on-tone(neotokyo の藍紫を同系色で)
clay / -dark Tonal ember (claude.ai-like) ivory × clay, tone-on-tone(claude.ai 風を同系色で)
moss / -dark Tonal Olive-grey, tone-on-tone — calm hardware, outdoor, tools(オリーブ灰の同系色。落ち着いた機器・アウトドア・工具)
quartz / -dark Tonal Rose-grey, tone-on-tone — soft devices, beauty, daily goods(薔薇灰の同系色。やわらかい機器・美容・生活雑貨)
midnight / -dark Tonal Navy, tone-on-tone — night instrument panel(紺の同系色。深夜の計器盤。金融・セキュリティ・夜間 UI)
glacier / -dark Tonal Icy cyan-white, tone-on-tone — clean, clinical, Nordic(氷シアンの同系色。清潔・医療機器・冷蔵・北欧)
signal / -dark Tonal Achromatic paper × ink, red only as the status (error) lamp — dot-matrix, inspection / verdict tools(無彩の紙と墨。赤は状態表示の error だけ。ドット表現・検査や判定のツール)
ops / -dark SaaS PM, operations, internal tools(PM・運用・社内ツール)
finance / -dark SaaS Finance / legal enterprise(金融・法務エンタープライズ)
people / -dark SaaS HR, community(HR・コミュニティ)
health / -dark SaaS Medical / wellness SaaS(医療・ウェルネス SaaS)
analytics / -dark SaaS BI, data visualization(BI・データ可視化)
devtool / -dark SaaS Developer tools, API products(開発者ツール・API 系)
comms / -dark SaaS Chat, collaboration(チャット・コラボレーション)
growth / -dark SaaS Marketing, growth, landing pages(マーケ・グロース・LP 系)
commerce / -dark Industry EC, retail, marketplaces(EC・リテール・マーケットプレイス)
education / -dark Industry EdTech, learning services(EdTech・学習サービス)
legal / -dark Industry Legal, professional services, contracts(法務・士業・契約管理)
logistics / -dark Industry Logistics, delivery, mobility(物流・配送・モビリティ)
media / -dark Industry Media, publishing, news(メディア・出版・ニュース)
security / -dark Industry Security, audit, auth(セキュリティ・監査・認証)
kids / -dark Industry Kids / family products(子ども・ファミリー向け)
wellness / -dark Industry Yoga, meditation, mental care(ヨガ・瞑想・メンタルケア)
civic / -dark Industry Government, public sector(行政・公共・自治体)
atelier / -dark Industry Luxury brands, D2C, jewelry(高級ブランド・D2C・ジュエリー)
estate / -dark Industry Real estate, architecture(不動産・建築・設計)
food / -dark Industry Food, delivery(飲食・フードデリバリー)
travel / -dark Industry Travel, hotels, resorts(旅行・ホテル・リゾート)
creative / -dark Industry Creative / design tools(クリエイティブツール・デザイン)
agri / -dark Industry Agriculture, greentech(農業・植物工場・グリーンテック)
factory / -dark Industry Manufacturing, heavy industry(製造・重工業・設備)

Canonical machine-readable source: src/resources/themes/lineup.json (name, category, trigger, full palettes) — the same file that generates the theme CSS, so it can never drift. In PHP, pn_theme_groups() returns the grouped light/dark ids.

daisyUI v5 gotchas (verified — do not "fix")

  • daisyUI component classes AND built-in themes don't exist in your build. The pinion-ui preset loads daisyUI with themes: false + a full component exclude list: you get the color utility layer (bg-primary, text-base-content, data-theme switching) but .btn, .card, .alert, .input, .menu, .modal, etc. produce no styling, and daisyUI stock theme names (light, dark, dracula, …) set as data-theme render unthemed — only the pinion-ui lineup (previous section) exists. Never write daisyUI component markup (<button class="btn btn-primary">) — use the pinion-ui component (<x-button color="primary">). Do not "fix" this by adding @plugin "daisyui" to app.css; that re-enables everything and breaks the design boundary.
  • divider-horizontal renders a vertical line inside a flex row (daisyUI naming is inverted). The <x-divider> wrapper normalizes: direction="vertical" does what you expect.
  • rating-half requires the explicit rating-{size} class even at default size, or half-star widths collapse. RatingComposer always emits it.
  • <x-collapse> defaults to no icon since v0.2.1 — opt in with icon="arrow" or icon="plus".
  • <x-tooltip> no longer uses daisyUI's CSS tooltip / data-tip system (dropped in v0.3.11 for an Alpine + custom-arrow approach; daisyUI's tooltip CSS is excluded from the build). The text / position / color / open props are unchanged.
  • Dropdown / menu panels take no horizontal padding. <x-dropdown>'s panel is py-1 only; the spacing belongs to the rows (<x-menu-item> carries its own px), and separators are <x-menu-divider> (full-bleed border-t). Don't add px-* to the panel or wrap rows in a padded <div> — a divider inside it stops short of the panel edges. Need the panel to span its parent (sidebar footer)? <x-dropdown block>.
  • Several components ship locale-aware aria/label defaults resolved through pn_trans() → config('pinion-ui.locale') (default ja; an en bucket also ships). Components that pull defaults this way:
    • <x-notification-system> — toast dismiss button (notification.close)
    • <x-rating> — clear-radio aria-label (rating.none)
    • <x-pagination> / <x-pagination-simple> — prev / next / info template / aria (pagination.*)
    • <x-table-scroll> — scroll buttons aria-label (table_scroll.prev / .next)
    • <x-select> — placeholder (select.placeholder)

To switch the whole app's component strings to English: set PINION_UI_LOCALE=en (or config('pinion-ui.locale') directly). This is intentionally independent of Laravel's config('app.locale'). Per-call override still wins: pass the prop explicitly (<x-pagination prevLabel="Older" nextLabel="Newer" />). Add a locale: extend the translations array in the published config/pinion-ui.php. Missing locale/key falls back to the literal Japanese string baked into the component as pn_trans()'s second argument.

For class names not covered above, always grep docs/{daisyui,preline,penguinui}/ (local third-party docs, gitignored) before claiming a class behaviour.

Lookup workflow

For any component, read reference/components/{name}.md:

  • Props (table with types and defaults)
  • Slots
  • Examples (basic + variants + edge cases)
  • Class composition (link to the composer source)
  • Related components and gotchas

reference/components/index.md is the category-grouped table of contents.

Required peer dependency

sparrowhawk-labs/pinion-icons — every component that renders icons (icon=, iconRight=, internal <x-i>) needs it installed. The pinion-ui package hard-requires it.

Alpine inside <x-...> components — use the full prefix

Blade's anonymous-component compiler treats :prop="..." and @event="..." as PHP expressions ("pass this PHP value to the prop / event"). Alpine's shorthand :class / :value / @click therefore breaks at runtime when used on <x-button>, <x-input>, or any other <x-...> element — typically with Undefined constant "...".

Use the full Alpine prefix on <x-...> elements:

{{-- ✗ breaks: Blade evaluates `active === 'left'` as PHP --}}
<x-button :class="active === 'left' && '!bg-primary'" @click="active = 'left'">Left</x-button>

{{-- ✓ works: full Alpine prefix bypasses Blade --}}
<x-button x-bind:class="active === 'left' && '!bg-primary'" x-on:click="active = 'left'">Left</x-button>

Shorthand :class / @click is fine on plain HTML elements (<button>, <div>, <input>) — Blade only intercepts when the tag starts with <x-.

Livewire integration

pinion-ui is a Blade-only library (no Livewire components inside). Components work inside Livewire component Blade trees without any special setup. The notes below document what works, what doesn't, and why.

wire:model compatibility matrix

Component(s) wire:model support Notes
input, textarea, select, checkbox, radio, toggle, range-slider, input-number, file-upload ✅ Full — direct passthrough wire: attrs forwarded via $attributes->whereStartsWith('wire:') to the native form element
rating ✅ Full — native radio wire:model is forwarded to each <input type="radio"> with the correct value= attr. .live works.
pin-input ✅ Supported (dispatch pattern) wire:model goes on a dedicated hidden <input>. An Alpine $watch on combined dispatches a native input event → Livewire is notified on every digit change.
radio-group ❌ Not supported on wrapper Do not put wire:model on <x-radio-group>. Apply it to each child <x-radio wire:model="field" value="x"> directly.
input-number ⚠️ Partial User-typed changes notify Livewire (native input event). Clicking the +/− buttons changes Alpine's v programmatically — no native input event fires, so Livewire is not notified. Add an x-effect / $watch at the call site if you need button-click sync.

Detecting wire:model in Blade (UI library pattern)

Always use $attributes->whereStartsWith('wire:model') — not $attributes->wire('model'). The wire() macro is registered by Livewire at runtime; pinion-ui must work in apps without Livewire installed.

Loops: always add wire:key

Morphdom needs a stable key on every repeated Livewire-rendered component:

@foreach($items as $item)
    <x-card wire:key="item-{{ $item->id }}">…</x-card>
@endforeach

wire:loading on buttons

{{-- spinner while any Livewire request is in-flight --}}
<x-button wire:loading.attr="disabled" wire:target="save">Save</x-button>

$attributes on button is merged at the root <button> element, so wire:loading / wire:target / wire:click all work as expected.

When in doubt

  1. Read the component's reference doc under reference/components/.
  2. Read the composer source (if any) — class strings there are authoritative.
  3. Grep docs/{daisyui,preline,penguinui}/ for upstream behaviour.
  4. Add a fixture case to capture new behaviour; do not just edit code.

Reporting changes back

When you add a prop or change behaviour:

  • Update the component's reference/components/{name}.md.
  • Update tests/fixtures/compose/{name}.json with new cases.
  • Update reference/components/index.md if the one-line summary still fits; replace it if it no longer does.
  • Update this file only when an architecture rule or gotcha changes.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource