# Aumni AMS — Design System

The visual language for the whole application, and the components every module
composes from.

The benchmark is the OptiSet UI; the register is Linear / Stripe / Vercel /
GitHub — quiet neutrals, one accent, generous space, and type doing the work
that borders used to do.

**Bootstrap 5 only.** No Tailwind, no second CSS framework, no component
library. Bootstrap 5.3 exposes every component through CSS custom properties
(`--bs-btn-bg`, `--bs-card-bg`, …), so the system is built by overriding those
rather than by re-declaring rules — which keeps every variant, size and state
Bootstrap already ships, including the ones the stylesheet never mentions.

---

## The one rule

> A new module composes from what is here. It does not invent a card, a table,
> a badge or a button of its own.

If a module needs a pattern this system does not have, the pattern is added to
`resources/scss/_components.scss` and, if it has markup, to
`resources/views/components/` — so that every later module gets it too. That is
the whole point of the layer. A one-off style in a module view is a bug.

---

## Stylesheet layout

`resources/scss/`, imported in this order by `app.scss`. The order is not
negotiable — tokens must precede Bootstrap so every `!default` resolves to ours.

| File | Holds |
|---|---|
| `_tokens.scss` | Colour ramps, type scale, radii, elevation, control metrics, and every Bootstrap variable override |
| `_base.scss` | Element defaults, CSS custom properties, focus ring, scrollbars, small shared utilities |
| `_shell.scss` | Sidebar, topbar, page frame, page header, print rules |
| `_components.scss` | Every reusable pattern |

---

## Tokens

### Colour

A cool neutral ramp plus **one** accent. Nothing else. A screen that needs a
colour it cannot find here is a screen that is saying too much.

| Role | Value | Used for |
|---|---|---|
| Canvas | `#f9fafb` | The page background cards sit on |
| Surface | `#ffffff` | Cards, panels, inputs, topbar |
| Border | `#e5e7eb` | Card and control borders |
| Border subtle | `#f3f4f6` | Hairlines *inside* a surface — table rows, header rules |
| Ink | `#111827` | Primary text; also the sidebar surface |
| Ink muted | `#6b7280` | Secondary text, table headers |
| Ink subtle | `#9ca3af` | Placeholders, decorative icons, resting row actions |
| **Accent** | `#0d9488` | Primary button, current nav item, interactive text |
| Accent hover | `#0f766e` | |
| Accent soft | `#ccfbf1` | Badge fill, icon tile |

The accent appears on **the primary action, the current location, and
interactive text**. Nowhere else. That restraint is what makes it mean
something when it does appear.

Semantic ramps (`green`, `amber`, `red`, `blue`) each carry a `-50` fill, a
`-200` border and an `-800` text tone, so soft badges, soft alerts and icon
tiles are all derivable from the same three steps.

#### The enum colour contract

`App\Enums\*::color()` returns **Bootstrap theme names** — `primary`,
`secondary`, `success`, `info`, `warning`, `danger`, `light`, `dark`. Those
names are the contract between PHP and CSS. Every one of them is defined and
restyled in `$ams-soft-bg` / `$ams-soft-fg`. **Restyle them; never rename
them** — an enum returning a name the stylesheet does not define renders a
colourless badge.

### Typography

**Inter**, self-hosted through `@fontsource-variable/inter` and bundled by Vite.
It is never fetched from a CDN: the production host sits behind a corporate
network where that request may not resolve, and a missing font would drop the
whole UI into the fallback stack.

Base size is **14px**. Enterprise screens are dense by nature; the breathing
room comes from spacing and hierarchy, not from setting body copy larger than it
needs to be.

| Use | Size | Weight | Tracking |
|---|---|---|---|
| Page title (`.ams-page-header h1`) | 20px | 600 | −0.018em |
| Section title | 14px | 600 | −0.008em |
| Body | 14px | 400 | −0.006em |
| Secondary / help | 13px | 400 | |
| Table cell — secondary | 12px | 400 | |
| Micro-label (`.ams-overline`, table headers, fieldset legends) | 11px | 600 | +0.04em, uppercase |
| Stat value | 26px | 600 | −0.028em |

Headings tighten as they grow. Uniform tracking makes large type look loose and
small type look cramped; this scale corrects both ends.

Numbers meant to be compared down a column get `.ams-numeric`
(`font-variant-numeric: tabular-nums`).

### Radii

Three steps. **A card and the button inside it must never share a radius** — the
nesting is what makes the card read as a surface rather than as a big button.

| Step | Value | Applied to |
|---|---|---|
| `$border-radius-sm` | 8px | Chips, nav items, icon buttons, pagination |
| `$border-radius` | 10px | Buttons, inputs, selects |
| `$border-radius-lg` | 14px | **Cards, panels, modals** |
| `$border-radius-xl` | 16px | Auth card |
| 6px | | Badges — a rounded rectangle, not a pill: pills all read as the same object at a glance |

### Elevation

Soft and low. Shadows separate layers; they never simulate height. Tinted with
the ink colour rather than pure black, so they stay neutral on the gray canvas
instead of turning it muddy.

| Token | Use |
|---|---|
| `--ams-shadow-card` | Resting cards, inputs, buttons |
| `--ams-shadow-raised` | Hovered stat card, auth card |
| `--ams-shadow-overlay` | Dropdowns, modals |

### Controls

**One height — 40px** — for every control on a row, so a search box, a filter
select and a button line up on both edges without per-page correction.
`btn-sm` / `form-control-sm` are 34px and line up with each other likewise.

Controls inside a **table toolbar** drop to 32px. They are the frame around the
data, not the subject of the page, and sizing them like a page-level primary
action makes the toolbar shout over the table it belongs to.

---

## Shell

```
┌────────┬──────────────────────────────────┐
│ ▓ rail │ topbar 48px — location · search  │
│ ▓ 216px├──────────────────────────────────┤
│ ▓ fixed│                                  │
│ ▓#111827│  .ams-container (max 1680)      │
│ ▓      │     canvas #f9fafb               │
└────────┴──────────────────────────────────┘
```

The chrome is deliberately thin — 216px of rail and 48px of bar. Every pixel
spent on chrome is a pixel a table does not get, and this is software people
keep open all day.

**Listings run to the full 1680px; forms do not.** A table earns every pixel of
a wide canvas; a "Full Name" field stretched to 900px stops looking like a form
and starts looking like a layout bug. Form and detail screens opt into a
narrower column:

```blade
@section('container-class', 'ams-container--narrow')
```

- **The rail** carries the brand and the current location. Dark, full height,
  `position: fixed` above `lg`; below `lg` the same element is Bootstrap's
  offcanvas drawer, via `offcanvas-lg`. **No custom JavaScript.**
- **The topbar** carries location, search and identity — the rail already owns
  the brand.
- The active nav item is a **solid accent pill**, not a border or a tint: at a
  glance across a narrow rail, fill is the only treatment that survives
  peripheral vision.
- The topbar carries the **current location** on the left. It is sticky and the
  page header is not, so once a long listing is scrolled that label is the only
  thing still saying which screen this is — and 48px of nothing across the top
  of every page is the most template-looking thing an interface can do.
- The brand block's height matches the topbar exactly, so the rail's dividing
  rule and the topbar's meet in one unbroken line. That detail is what makes two
  panels read as a single frame.

> **Gotcha.** Bootstrap forces `background-color: transparent !important` on
> both `.offcanvas-lg` and `.offcanvas-body` above the breakpoint. The dark
> surface is therefore painted on `.ams-sidebar__inner`, which is unaffected at
> every breakpoint. Do not try to fight it with a counter-`!important`.

The sidebar builds itself from `config('ams.navigation')` — an entry whose route
is not registered is skipped, so the menu fills in as modules land. **Adding a
module means registering its routes; the menu follows.**

---

## Components

All in `resources/views/components/`. Every one carries usage docs in its own
file header — read those before using it.

### Page structure

| Component | Purpose |
|---|---|
| `<x-page-header>` | Title, subtitle, breadcrumbs, actions. Actions render right-aligned in source order, so **the primary action goes last in the slot**. |
| `<x-panel>` | A titled card — the default surface for anything that is not a table. `flush` drops body padding; `tone="danger"`/`"warning"` tints the header for a panel carrying consequence. |
| `<x-detail-list>` + `<x-detail-row>` | Label/value pairs for a detail panel. Use instead of a two-column `<table>`: a table claims its content is a grid of comparable records, this is one record's attributes — and it reflows on a narrow screen instead of scrolling. |

### Listings

| Component | Purpose |
|---|---|
| `<x-table.wrapper>` | Card-wrapped, horizontally scrollable table with an optional footer strip |
| `<x-table.empty-state>` | What a listing shows when it has nothing to show |
| `<x-table.actions>` | The icon cluster at the end of a row |
| `<x-table.count>` | "Showing 1–15 of 42" |
| `<x-table.sort-link>` | A column header that sorts the listing, preserving every other filter in the URL. Clicking an inactive column sorts ascending; clicking the active one flips direction. A report never declares a sortable header for a column its query can't actually order by — the header simply renders as plain text instead. |

Cell classes — use these rather than ad-hoc utilities, so every listing in every
module weights its columns identically:

| Class | For |
|---|---|
| `.ams-cell-primary` | The record's name. **One per row.** |
| `.ams-cell-secondary` | Supporting detail |
| `.ams-cell-code` | An identifier — asset tag, code, serial |

**The toolbar lives inside the table card**, separated by a hairline rather than
a gap. A search bar floating in its own card above a table is the single
clearest tell of an admin template: the controls belong to the data they filter,
so they sit in its frame. Pass the filter bar in the wrapper's `toolbar` slot.

**Column sizing.** The first column absorbs all the slack; every other column
shrinks to its content. Without this, widening the page to 1680px scatters five
columns across it with lakes of nothing between them — the table gets bigger
without getting more useful. When the slack belongs elsewhere (a long address,
a description), the columns opt in and out explicitly:

| Class | Effect |
|---|---|
| `.ams-col-fill` | This column takes the slack |
| `.ams-col-fit` | This column shrinks to its content |

Use them **in pairs**. Two columns both claiming the slack is not a fix — the
browser hands it all to one of them — so the first column must opt out with
`.ams-col-fit` when another opts in with `.ams-col-fill`.

The fill column has a `min-width` floor. Without it, a viewport narrower than
the other columns' combined content gives it zero width, and on a phone the
name column disappears entirely, leaving every row an avatar next to nothing.

**Row actions** rest at a low-contrast colour and come up on hover or focus —
a real colour, never opacity, which muddies the glyph against the row fill and
fades the focus ring with it. The destructive control shows red only on hover:
a row of permanently red buttons trains people to stop seeing red. Every
icon-only control needs a `title` **and** a `.visually-hidden` label naming the
record — the icon alone says "delete", never "delete *which*".

Wide tables scroll **inside their own card**. The page body never scrolls
sideways.

### Search and filters

| Component | Purpose |
|---|---|
| `<x-filter-bar>` | The inline GET toolbar above a listing |
| `<x-filter.search>` | Search field with leading icon; grows to fill the row |
| `<x-filter.select>` | A dropdown filter; the placeholder is the unfiltered state ("Any status") |
| `<x-filter.date-range>` | A "from / to" date pair. Always two fixed query keys, `date_from`/`date_to`, regardless of which column a given screen actually filters by — only that screen's own query knows which date that is. |

Neither `<x-filter.search>` nor `<x-filter.date-range>` auto-submits on
change — a half-typed search term or a half-picked date should never fire a
request. `<x-filter.select>` does, because a selection is atomic. This is why
the Apply button stays in the DOM whenever a search or date field is present,
even though selects alone would make it redundant.

Always visible, always inline. The earlier collapsed panel hid the fact that a
listing was filtered — the one thing a filtered listing must never hide.
"Clear" appears only when there is something to clear.

Filters submit with **GET**, so a filtered listing is a shareable, bookmarkable
URL — which is also what makes "export the current filter" possible later
without duplicating filter state anywhere. Selects auto-submit where JavaScript
is available; the Apply button is the no-JS path and stays in the DOM for it.

### Forms

| Component | Purpose |
|---|---|
| `<x-form.fieldset>` | A titled group of fields. Consecutive fieldsets separate themselves. |
| `<x-form.input>` `<x-form.select>` `<x-form.textarea>` `<x-form.date>` `<x-form.datalist>` | Field + label + help + validation message |
| `<x-form.checkbox>` | A boolean rendered as a bordered row |

Use `<x-form.fieldset>` rather than `<hr>` + `<h2>`: a rule says "something
changes here" without saying what. A long form is easier to complete when it is
three short forms with headings.

Close a form with `.ams-form-actions` — primary button first, `btn-ghost`
Cancel second.

`<x-form.checkbox>` renders a surface deliberately: a boolean that changes what
a record *does* — active, master, notify — is a decision, not a field, and a
bare checkbox floating in a column of inputs reads as an afterthought.

Its hidden companion input is what makes unchecking work; that is solved once,
inside the component, and no caller should reimplement it.

### Status and feedback

| Component | Purpose |
|---|---|
| `<x-status-badge>` | Any enum-backed status. Label, colour and icon come from the enum. |
| `<x-badge>` | Any label **not** backed by an enum — access level, "You", a count |
| `<x-stat-card>` | A dashboard KPI tile |
| `<x-confirm-modal>` | One dialog in the layout serves every destructive form on the page |
| `<x-error-page>` | The shared body of every error screen |

Badges carry a **soft fill and a matching border**, both drawn from the same
semantic ramp. A filled, saturated badge in every row of a long table turns the
listing into a colour chart; but soft fills alone are all roughly the same
lightness, so a column of them reads as one texture and has to be *read* rather
than scanned. The border gives each status its own edge.

Pass `dot` instead of `icon` for the live/not-live statuses that repeat down a
column — Active / Inactive / Disabled. The dot is what the eye finds when
scanning; an icon on every row is noise. Reserve `icon` for badges that appear
once or twice on a screen.

`<x-stat-card>` should be given an `href` wherever possible: every number on a
dashboard should answer "show me those". `color` drives only the icon tile —
the value itself is always ink, because a large red "0" reads as an error
rather than as a count.

### Dashboard and category cards

| Component | Purpose |
|---|---|
| `<x-dashboard.chart-panel>` | One Chart.js chart, fed by a server-rendered JSON config. Falls back to a plain `<table>` inside `<noscript>` when JavaScript is off, and to the shared empty state when every value is zero — a chart with nothing on it looks broken, not empty. |
| `<x-dashboard.activity-list>` | The compact href/icon/title/subtitle/meta/badge record list every "recent / upcoming / top N" dashboard widget turns out to share once stripped to its essentials. |
| `.ams-report-card` | A landing-page card grouping reports (or any future browsable catalogue) by category with a title and a one-line description — used instead of a flat list of links so a first-time visitor has a clue what a report shows before opening it. |

Charts are the **one place in the application that renders client-side**
rather than server-side — architecture's stated rule everywhere else is "no
AJAX, no client rendering". Chart.js is a local dependency bundled by Vite,
never a CDN script, for the same reason the fonts are self-hosted. Its
config travels through the page as a `<script type="application/json">` data
island rather than an inline `<script>` block, so it needs no `'unsafe-inline'`
exception in `script-src`.

### Tabs

Bootstrap's Tab plugin (already bundled, initialised from `data-bs-toggle="tab"`
with no extra JS) — used the first time in Settings, for a screen with more
grouped fields than fit one scroll comfortably. Every field from every tab
lives in the same `<form>` and posts together: switching tabs must never be
allowed to feel like it risks losing an edit made on another one. With
JavaScript off, all five panels simply stack into one long page — nothing is
hidden, only the tab affordance is unavailable.

### Buttons

| Class | Rank |
|---|---|
| `btn-primary` | **One per view** — the thing the user came to do |
| `btn-outline-secondary` | The workhorse neutral. Solid white, because a transparent outline button on the gray canvas reads as disabled. |
| `btn-ghost` | Tertiary — Cancel, menu actions |
| `btn-outline-danger` / `btn-danger` | Destructive |

Rank is communicated by **weight, never by colouring two actions differently**.

Icons inside buttons are spaced by `gap`; any margin utility on them is
neutralised, so `<i class="bi bi-plus me-1">` and a bare `<i>` render
identically and no caller has to remember which to write.

---

## Micro-interactions

Movement is limited to state feedback. There are no entrance animations, no
parallax, no easing curves longer than 150ms.

| Interaction | Response |
|---|---|
| Button press | `translateY(0.5px)` — below the threshold of "animation", above the threshold of "did that register?" |
| Row hover | Flat `#f9fafb` fill, 100ms |
| Row action hover | Fill + colour step; destructive turns red only here |
| Stat card hover | Fill + border step — **no lift, no shadow**: a tile that rises off the page is a consumer-dashboard gesture |
| Nav item hover | Background step, 120ms |
| Search focus | Leading icon takes the accent colour |
| Filter select | Auto-submits on change; the Apply button remains for the no-JS path |

Anything that moves must survive `prefers-reduced-motion: reduce`, which
collapses every transition in the system to 0.01ms.

---

## Accessibility

- One focus ring for the whole application, on the keyboard path only
  (`:focus-visible`). Pointer users never see it; keyboard users always do.
- Row actions reveal on `:focus-within` as well as `:hover`, so the cluster is
  never unreachable without a pointer.
- Icon-only controls always carry a `.visually-hidden` label naming the record.
- `prefers-reduced-motion` collapses every transition.
- Colour is never the only carrier of meaning — every status badge pairs its
  fill with a word, and most with an icon.

---

## Working on the UI

```bash
npm run dev      # Vite with hot reload
npm run build    # compile to public/build — run before every deploy
```

`public/build` is committed on purpose: the cPanel host has no Node.js, so
deploying is a plain `git pull` with no build step on the server. **A UI change
that is not rebuilt and committed does not ship.**
