Imported from sparrowhawk-labs/pinion-ui (
AGENTS.md). Install upstream withnpx skills add sparrowhawk-labs/pinion-ui. Copyright stays with the author.
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 topn::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 newx-dataon 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)
- Compose pattern: For most components, class strings live in
src/Compose/{Name}Composer.php, not the Blade. Each composer has a staticcompose(array $props): arraythat 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.
- Components without a composer (classes embedded in the Blade):
- Composer returns class strings only — no markup, no array values. If you need conditional markup, branch in the Blade based on a prop.
- Fixture tests: Each composer has
tests/fixtures/compose/{name}.jsoncovering its variant matrix. Run withphp tests/Compose/run.php. Comparison is subset (only listedexpectedkeys are checked). Add cases when you add props. - 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>forsecondaryaccentneutralinfosuccesswarningerror: the color itself at ≥ 4.5:1 onbase-100andbase-200, else pushed towardbase-contentto 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>/Nwith N ≤ 50. Untouched:bg-<name>,bg-<name>/NN > 50,text-<name>-content. - Write
text-warning/border-neutral/bg-success/10as 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; usebg-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/wirerail andsoftthumb, v0.25.2: a toggle is a state lamp with no glyph, itsstateLabelON text isbase-100). Checked checkbox / radio fills, tooltip arrow, theme-swatch dots staybg-<name>(surfaces with a-contentmark).- 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, stepperdonecircle:bg-<name> border-transparent. Hoveredoutline/wirebutton (it fills):hover:border-transparent.outline/soft/wirekeepborder-<name>[/N]at rest (the border is the signal). Exception: checkedwirecheckbox / radio / toggle keeppeer-checked:border-<name>— line-drawn, its outline stays when filled.neutraltone: checkbox / radio routeneutral→base-contentin every appearance exceptsolid(the-contentglyph still reads); the toggle routes it insolid/soft/wirealike (v0.25.1 — no glyph, and on dark themesneutral≈ the rail). ui:ejectfreezes the same routing (ink+ink-<name>columns ineject-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(default4rem;%of the element also works). - One
mask-imageper element —-xand-ydon'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.6mood-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 inlineup.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:
pinionapplies at:rootautomatically when nodata-themeis set; if the OS prefers dark (prefers-color-scheme: dark),pinion-darkapplies instead (it carries daisyUI'sprefersdarkflag). An explicitdata-themeon any element always wins over both. To pin light regardless of OS setting, setdata-theme="pinion"explicitly. - Page canvas vs component face: every theme paints the page background (
:root) with its tinted canvas color and puts components onbase-100(white in light themes) — do not hardcode a body background.bg-base-200equals the canvas color (recessed wells, hover);border-base-300is 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 defaultpinionshares this palette family, soreactivemostly 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. Putpn_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 (flowis animated). The switchers default to the same config, so do not hand-writedata-themeper page. Seereference/components/look-head.md/backdrop.md. - Switchers:
<x-theme-switcher>cyclespinion↔pinion-darkby 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-exemptclass (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 likedraft'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-themeswitching) but.btn,.card,.alert,.input,.menu,.modal, etc. produce no styling, and daisyUI stock theme names (light,dark,dracula, …) set asdata-themerender 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-horizontalrenders a vertical line inside a flex row (daisyUI naming is inverted). The<x-divider>wrapper normalizes:direction="vertical"does what you expect. -
rating-halfrequires the explicitrating-{size}class even at default size, or half-star widths collapse.RatingComposeralways emits it. -
<x-collapse>defaults to no icon since v0.2.1 — opt in withicon="arrow"oricon="plus". -
<x-tooltip>no longer uses daisyUI's CSStooltip/data-tipsystem (dropped in v0.3.11 for an Alpine + custom-arrow approach; daisyUI'stooltipCSS is excluded from the build). Thetext/position/color/openprops are unchanged. -
Dropdown / menu panels take no horizontal padding.
<x-dropdown>'s panel ispy-1only; the spacing belongs to the rows (<x-menu-item>carries its ownpx), and separators are<x-menu-divider>(full-bleedborder-t). Don't addpx-*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')(defaultja; anenbucket 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(orconfig('pinion-ui.locale')directly). This is intentionally independent of Laravel'sconfig('app.locale'). Per-call override still wins: pass the prop explicitly (<x-pagination prevLabel="Older" nextLabel="Newer" />). Add a locale: extend thetranslationsarray in the publishedconfig/pinion-ui.php. Missing locale/key falls back to the literal Japanese string baked into the component aspn_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
- Read the component's reference doc under
reference/components/. - Read the composer source (if any) — class strings there are authoritative.
- Grep
docs/{daisyui,preline,penguinui}/for upstream behaviour. - 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}.jsonwith new cases. - Update
reference/components/index.mdif the one-line summary still fits; replace it if it no longer does. - Update this file only when an architecture rule or gotcha changes.
