# PetalAir — the design system

Read this before adding a page. The system only holds if the next person knows what it is doing.

## The problem

PetalAir sells something invisible. There is no product photograph that shows what a
room smells like. Most scent-brand sites solve this by falling back on the same two
moves: soft-focus lavender fields, or a candle on a linen tablecloth. Both are lying
about what the company actually does — this is a machine that atomises oil into an
air volume on a schedule, sold to hotel operations managers.

So the site is built around a different premise: **PetalAir is an instrument company
that happens to sell scent.** Everything else follows from that.

## Palette — black and gold

The client's brief. Two things make the difference between this and every other
black-and-gold page on the internet:

**The neutrals are neutral.** The first attempt warmed them toward beige on the
theory that warm greys flatter gold. They don't — brown-grey text on brown-black
is mud, and the gold has to fight the ground for the same corner of the spectrum.
Gold only reads as metal when nothing around it has a colour of its own.

**The gold has three stops, not one.** A flat fill of #d4af37 is mustard. Metal
needs a light edge and a dark one, which is why the primary button is a gradient
and not a colour.

```
--color-void         #000000   deepest — dark bands
--color-vapor        #080808   PAGE BACKGROUND
--color-glass        #131312   raised surface, cards
--color-line         #272624   borders
--color-steel        #3b3936   heavier border
--color-mist         #8e8b86   tertiary text, readouts
--color-slate        #bcbab6   secondary text
--color-ink          #f7f6f3   PRIMARY TEXT

--color-gold         #d4af37   gold leaf — the real one
--color-gold-bright  #f4dc8b   light edge of the gradient
--color-gold-deep    #9a7a1d   dark edge

--color-alert        #e8705c   "wrong" — not a brand colour
--color-ok           #7fc79a   "done"
```

Note the token names still describe the *old* light theme (`vapor` used to be the
palest thing). The names were kept because ~70 templates reference them; only the
values flipped. `vapor` now means "the page", `ink` means "the text".

## The chroma rule

**Colour appears only where a scent is being described, and never anywhere else.**

The six perfumery families each have one hex value:

| Family | Hex | |
|---|---|---|
| Citrus | `#F0D24A` | |
| Floral | `#E79DBA` | |
| Woody | `#B08862` | |
| Amber | `#E08744` | |
| Fresh | `#6FC4BB` | |
| Green | `#93B96A` | |

These live in **two places**: `ScentFamily::hex()` (which the dots read, via inline
style) and the `@theme` block. Change both or neither — Tailwind tree-shakes the
CSS variables because nothing references them as utility classes.

These are the *only* colours in the system. No brand accent, no blue links, no green
success buttons. A button is never coloured. A badge is never coloured. If you find
yourself reaching for a colour to indicate a state, use weight or a border instead.

The payoff: because everything else is monochrome, a single 8px dot of Amber next to
an oil name carries real information. The moment a "Subscribe" button turns teal, that
dot means nothing.

**Gold is the one negotiated exception, and it costs something.** Measured against the
families, gold sits at 1.15 luminance ratio to Amber and 1.58 to Citrus — close. The
rule survives on a technicality: gold only ever appears as a *line, a label, or a
button*, never as a filled swatch. The families only ever appear as filled dots and
bars. Different shapes, so they stay legible as different systems. Fill a badge with
gold and that distinction is gone.

`ScentFamily::hex()` is the only source. Never hardcode one.

## Typography — width, not serif

The lazy contrast is serif display over sans body. We used **width** instead.

- **Archivo** — display. Variable width axis, run at `font-stretch: 112–125%`. Wide
  and heavy, like machine-stamped lettering.
- **Instrument Sans** — body. Narrow, quiet, gets out of the way.
- **IBM Plex Mono** — readouts only. SKUs, millilitres, coverage figures, order
  numbers, dates in tables.

The mono is doing real work: anything that is a *measurement or an identifier* is set
in it. This is why `PA-OIL-HARB`, `200 ml`, `5,000 ft²` and `ORD-2026-00042` all look
related — they are the same category of thing. Don't set prose in it, and don't set
identifiers in anything else.

Utility classes: `.display`, `.display-md`, `.readout`, `.rule-label`.

## The diffusion field

The signature element. Three large, heavily blurred plumes drifting slowly behind hero
headlines. It is the one place the site draws the invisible thing it sells.

```html
<div class="plume">
  <div class="plume__cloud plume__cloud--a"></div>
  <div class="plume__cloud plume__cloud--b"></div>
  <div class="plume__cloud plume__cloud--c"></div>
</div>
```

Used on: the homepage hero, the auth split-screen, the admin login. **Three places, and
that is the budget.** It is a signature because it is rare. Put it on every section and
it becomes wallpaper.

The drift is 40–60s per cycle — slow enough that you are not sure it is moving. Fully
disabled under `prefers-reduced-motion`.

## The note strip

The second signature, and the one that carries actual information.

Every oil has a real perfumery pyramid — top, heart, base. The strip renders them as
three horizontal bars where **bar length maps to how long that stage lingers in the
room**. Top notes get a short bar. Base notes get a long one.

This is why the stages are numbered 1/2/3: the order is not decorative, it is the order
you will actually smell them in. Numbering is earned here. Don't number things elsewhere
just because it looks tidy.

```blade
<x-site.scent-strip :product="$product" />
```

It reads `notes_pyramid` off the product. An oil seeded without one renders empty — that
is intentional, not a bug to paper over with placeholder notes.

## Motion

Scroll reveal is a 12px rise and a fade, 400ms, staggered. Nothing slides in from the
side. Nothing bounces. The whole thing is suppressed under `prefers-reduced-motion`,
including the plumes.

## Admin

Same tokens, denser. Sidebar is `--color-void`. Nav is grouped by **what you are trying
to do**, not by database table:

- **Run the day** — dashboard, orders, subscriptions, invoices
- **People** — leads, customers
- **Catalogue** — products, categories, plans
- **Look back** — reports

The dashboard leads with an "action needed" block — unpaid orders, pending installs,
overdue invoices, low stock, refills due. Everything in it is a thing that stalls if
ignored. KPIs come second. A dashboard that opens with a revenue number is telling you
how you did; this one tells you what to do.

## The button variants are a fossil

`.btn--light` and `.btn--outline-light` exist because the original theme was a pale
page with dark bands, so a CTA needed two forms depending on what it sat on. Every
surface is black now, so `--light` is just the primary button and `--outline-light`
is just the ghost. They resolve to identical CSS and the names only survive because
eight templates reference them.

This bit once. The hero uses `btn--light`, so when gold was wired only into
`btn--solid`, the first button anyone saw on the site was still cream. If you add a
gold treatment, add it to both.

## Product cards swap image on hover

A card shows its second uploaded image on hover, cross-fading from the first.
`Product::secondImageUrl()` returns null when only one photo exists, and the card
then keeps the old zoom instead — so a half-photographed catalogue degrades
quietly rather than flickering to blank.

Order is: the primary image first, then by `sort_order`. Upload a second angle to
each product and the grid comes alive with no template changes.

## Things not to do

- Don't widen gold past lines, labels and buttons. Filled gold collides with Amber.
- Don't add a *second* accent colour. Gold is the exception; there isn't room for two.
- Don't put the plume on a third kind of page.
- Don't use mono for prose or sans for identifiers.
- Don't number a list unless the order carries information.
- Don't add a shadow. There are none. Depth is borders and fills. (The button
  gradient is not depth — it's the material.)
- Don't warm the neutrals. That's how this went wrong the first time.
- Don't reach for an icon where a word fits.

## The menu is data, not markup

`components/site/nav.blade.php` renders whatever `menu_items` holds. Nothing in
the header is hard-coded any more, which means:

- A typo'd route name would 500 **every page on the site**, since the header
  renders in the layout. So `MenuItem::linkableRoutes()` is a whitelist and the
  form is a `<select>`, not a text box. `resolvedUrl()` also falls back to `'#'`
  rather than throwing when a target has been deleted.
- The menus are cached (`menu.header`, `menu.footer`). `clearSiteCache()` drops
  them, and every admin write calls it. Forget that call and edits look like
  they silently failed.
- `auto_categories` on an item fills its dropdown from live categories instead
  of from child rows. That's what "Shop" uses, so adding a category to the
  catalogue puts it in the menu with no second step.

Dropdowns are pure CSS on desktop — `group-hover` plus `group-focus-within`, so
they open for keyboard users too. Only the phone menu needs JS, because there is
no hover to work with.

## Page bodies are trusted HTML

`frontend/page.blade.php` prints `{!! $page->body !!}` unescaped. That is the
point of a page builder — formatting has to survive. It is safe **only** because
the editor sits behind `auth` + `admin`. Never wire customer input into that
column, and never relax the middleware on those routes.
