# Highlights · Martis docs

> A tour of the surfaces Martis ships beyond the bare-essential admin-panel contract, each a first-class API of the package. They come with their own configuration, generators, or escape hatches, and each entry below links into the dedicated reference for the full surface.

Source: https://getmartis.com/docs/reference/differentials

# Highlights

A tour of the surfaces Martis ships beyond the bare-essential admin-panel contract, each a first-class API of the package. They come with their own configuration, generators, or escape hatches, and each entry below links into the dedicated reference for the full surface.

This page is descriptive: it tells you **what is in the box**. It does not benchmark Martis against any other package — for ecosystem comparisons see the project README.

## Override System

### Four-Tier Component Resolution

Replace any React component at the right granularity level without forking the package.

Resolution order (highest priority first):

1. **Explicit key** — `->component('my-custom-field')` on a specific field instance.
2. **Per-resource** — `componentRegistry.registerResourceFieldDisplay('users', 'status', MyStatusBadge)`.
3. **Global type** — `componentRegistry.registerFieldDisplay('select', MyCustomSelect)`.
4. **Built-in default** — the package's default component.

```
// PHP: set an explicit component key on a field
Text::make('bio')->component('rich-bio-display')
```

```
// TypeScript: register a custom component under `resources/js/martis-extensions/`
import { componentRegistry } from '@martis/runtime'
import { RichBioDisplay } from './components/RichBioDisplay'
 
componentRegistry.register('rich-bio-display', RichBioDisplay)
```

Full guide: [Override System](https://getmartis.com/docs/customization/overrides).

### Drawer CRUD (slide-in panels)

Render Create, Update, and Detail in a slide-out drawer instead of a full page. Reduces context switching and keeps the index visible.

```
public function overrides(): array
{
    return [
        'create' => DrawerOverride::make()
            ->width('520px')
            ->expandedWidth('800px')
            ->allowExpand()
            ->allowFullscreen()
            ->position('right'),
    ];
}
```

Slide animation, expand/collapse, fullscreen toggle, ESC to close, backdrop click, configurable width and position.

### Unsaved Changes Guard

Uniform protection against discarding unsaved edits across **every** surface — drawers and full-page create/update routes. Intercepts the close button, ESC, backdrop click, the browser back button, and in-app router links.

```
use Martis\UnsavedChangesConfig;
use Martis\Contracts\UnsavedChangesConfigContract;
 
public static function confirmUnsavedChanges(): bool|UnsavedChangesConfigContract
{
    return UnsavedChangesConfig::make()
        ->title(__('projects.unsaved_title'))
        ->body(__('projects.unsaved_body'))
        ->icon('briefcase')
        ->iconColor('info')
        ->confirmLabel(__('projects.unsaved_discard'))
        ->confirmColor('danger')
        ->cancelLabel(__('projects.unsaved_keep'));
}
```

Return `false` (the default) to disable, `true` for the localised package defaults, or an `UnsavedChangesConfig` to override title, body, icon, colours and button labels. The same config applies whether the resource uses a drawer override or the page-based create/update flow.

**Implementation**

- **Browser back/forward** — a drawer pushes a history *sentinel* while it is open. A full-page form pushes one only once it has unsaved changes (v1.38.0+): a copy of the page's own history entry (same URL, same router `key` and `idx`) flagged as the guard's, so a clean form leaves the history alone and Back and Forward move between pages as anywhere else. Back from the sentinel lands on the page's entry, which a **capture-phase popstate listener** recognises and keeps from React Router with `stopImmediatePropagation()`, avoiding the v6 URL-flicker bug; every other pop belongs to the router, or to the modal or drawer on top. The sentinel is re-armed the moment the dialog opens, so repeated back presses while the dialog is visible stay trapped. On confirm the guard pops the re-armed sentinel (drawer: one `back()`; page: `go(-2)` for sentinel + real entry); changes saved since the sentinel was pushed let the back press carry on to the previous page. A page left with its sentinel on top (a save that redirects, a confirmed link) adopts that sentinel when Back returns to it, so the next Back walks past the page in one press. Before v1.38.0 the page pushed its sentinel on every mount, including a return by Back or Forward: that erased the Forward history, and a clean form took every pop for its own sentinel and went back once more, skipping the previous page or leaving the app while the address bar and the page on screen disagreed.
- **In-app navigation** — `useBlocker` (React Router v6.4 data-router API) intercepts `<Link>` clicks and imperative `navigate()` calls. Popstate-originated navigations are deliberately ignored (`historyAction === 'POP'`) because the browser-level listener already owns that path.
- **Modals on top of a guarded surface** — coordinated via `resources/js/lib/historyLock.ts`. See *Modal History Locks* below.
- **`beforeunload` is intentionally NOT wired up.** The browser's native "Are you sure?" prompt produced double prompts alongside the custom dialog and fired erratically when the previous history entry lived outside the SPA origin. Tab-close protection is an explicit trade-off Martis chose against for UX cleanliness.

### Modal History Locks

Two hooks in `resources/js/lib/historyLock.ts` coordinate modal back-button behaviour with `DrawerShell` and the page-level unsaved changes guard.

- **`useModalHistoryLock(open: boolean)` — hard lock.** Absorbs browser back indefinitely; the user must dismiss via UI (confirm, cancel, X, Esc, backdrop where the modal allows it). Use for destructive confirms and modals holding unsaved input.
- **`useModalHistoryBackToClose(open, onClose)` — soft lock.** First browser back closes the modal through `onClose`; a second back navigates normally. Use for non-destructive previews where "back" should mean "close the overlay".

Modal surface inventory:

| Modal | Lock | Reason |
| --- | --- | --- |
| `DeleteModal` | hard | destructive confirm |
| `ActionModal` | hard | mutates data, may have form inputs |
| `UnsavedChangesDialog` | hard | user has unsaved input |
| `InlineCreateModal` | hard | form data |
| `AttachModal` (BelongsToMany / MorphToMany) | hard | multi-select + pivot form |
| `DetachConfirmModal` (BelongsToMany / MorphToMany) | hard | destructive confirm |
| `EditPivotModal` (BelongsToMany) | hard | form data |
| `PivotActionModal` (BelongsToMany / MorphToMany) | hard | mutates pivot rows |
| `TwoFactorWizard` (Profile → Security) | hard | multi-step form with secret material |
| `SecuritySection` disable-2FA dialog | hard | destructive confirm (requires current password) |
| `SecuritySection` recovery-codes dialog | hard | surfaces one-time codes |
| `TrixField` `ImageModal` | soft | read-only preview |
| `MartisTooltip` / peek previews | none | hover-only |

### Layout Presets

Three configurable layout presets with runtime hot-swap:

| Preset | Description |
| --- | --- |
| `sidebar` | Traditional sidebar navigation (default) |
| `topnav` | Horizontal top navigation |
| `minimal` | Minimal layout with no chrome (no topbar, no sidebar) |

```
// config/martis.php
'layout' => [
    'preset' => 'sidebar', // or 'topnav', 'minimal'
],
```

The `minimal` preset is intended for embedded uses — a Martis surface mounted inside an outer SaaS shell that already provides its own chrome.

### Per-Context Field Overrides

Different components for different contexts (index vs. detail vs. create vs. update):

```
Text::make('status')
    ->overrideIndex('status-badge')      // Badge on index
    ->overrideDetail('status-detail')    // Rich view on detail
    ->overrideCreate('status-select')    // Dropdown on create
```

Per-context overrides compose on top of the global tier-resolution order described above.

## Action System

### Dry-Run Preview

Preview what an action would do before executing it:

```
Action::make('Bulk Update Prices')
    ->withDryRun()
```

The action runs in a "dry" mode that returns a structured preview (rows that would be touched, fields that would change, errors) without committing. The confirm dialog renders the preview before the user hits the actual run button.

### Post-Processing with then()

Execute a callback after action completion:

```
Action::make('Import Data')
    ->then(fn () => cache()->forget('dashboard-stats'))
```

Useful for cache busting, queue dispatching, side-effects that should not block the action's main success response.

### Closure-first Authorization

Inline `canSee()` / `canRun()` on the Action class still honour the standard policy callbacks as a fallback:

```
class Publish extends Action
{
    public function canSee(Request $request): bool
    {
        return $request->user()->hasRole('editor');
    }
 
    public function canRun(Request $request, Model $model): bool
    {
        return $model->state === 'draft';
    }
}
```

⭐ **Security differential: `canRun()` and the policy must both allow a run.** When an action runs on a record, Martis checks the action's `canRun()` **and** the resource's policy: `runAction` (or `runDestructiveAction` for a destructive action), falling back to `update` (or `delete`). In Nova 5 a `canRun()`, when defined, replaces the policy ("Authorization via Resource Policy"). Coming from Nova, a `canRun()` alone grants nothing: the user also needs `runAction` / `update` (or `runDestructiveAction` / `delete`). A `standalone()` action runs on no record, so only its `canRun()` / `canSee()` apply. Teams that prefer a pure-policy story can omit the closures entirely.

⭐ **An action that cannot run on a row stays visible, disabled.** The row buttons and the row menu of the index and of the relationship panels show every inline action and disable the ones the record may not run (the same check the run applies). Nova hides them on the row and disables them only in the bulk selector and on the detail page.

### updatePivot{Model} policy ability

BelongsToMany and MorphToMany pivot edits consult a dedicated `updatePivot{Model}` policy ability, falling back to `update` on the parent.

```
public function updatePivotTag(User $user, Post $post, Tag $tag): bool
{
    return $user->is_editor;
}
```

### Relation fields inherit target policy authorization

Relation field payloads (BelongsTo, HasMany, BelongsToMany, MorphTo, MorphToMany, MorphMany, MorphOne, HasOne) include `authorizedToCreate` / `authorizedToViewAny` computed from the **target resource's** policy. The inline "Create Related" button is suppressed when the user cannot create the related model — without the developer needing to toggle `showCreateRelationButton` by hand.

## Panel access

### Optional panel gate (viewMartis)

A `viewMartis` gate decides who may open the panel, as Nova's `viewNova` does, with three differences:

- **Closed outside `local` and `testing` by default (v2.4.0).** Undefined, every signed-in user gets in only in the environments of `martis.panel_access.open_environments` (default `local` and `testing`, a documented config key); anywhere else the panel stays shut until the gate is defined, like Nova. `martis:install` publishes the gate active.
- **No `local` bypass.** Defined, it applies in every environment, so the restriction can be tried locally.
- **No policy interception.** The user is not passed as a gate argument, so a policy of the user model is never consulted for it.
- **A way out.** A refused page request gets a standalone 403 screen with a **Sign out** button, and impersonating a refused user answers `422`.

See [Authorization → Panel access](https://getmartis.com/docs/auth/authorization#panel-access-viewmartis).

## Tool System

### Tool routes run behind the Martis API middleware

⭐ **Security differential: a tool's routes answer `404` to a user the tool is hidden from, behind the whole API stack.** `Tool::loadRoutes()` gives a tool's routes the middleware of the Martis API routes (authentication, the 2FA challenge, email verification, the locale, the impersonation expiry, the API throttle), then `martis.tool:{uriKey}`, which answers `404 {"message": "Tool not found."}` to a user whose `canSee()` or policy hides the tool: the answer `GET /api/tools/{uriKey}` already gives that user, so the app does not reveal which tools it ships. Nova guards a tool's routes with the tool's `Authorize` middleware, which answers `403` ([Nova → Tools → Routing Authorization](https://nova.laravel.com/docs/v5/customization/tools#routing-authorization); the middleware a generated tool ships: [nova-dusk-suite, `IconsViewer` `Authorize`](https://github.com/laravel/nova-dusk-suite/blob/11.4/nova-components/IconsViewer/src/Http/Middleware/Authorize.php)), and the API routes of a tool Nova's generator scaffolds do not run Nova's authentication ([nova-issues#5495](https://github.com/laravel/nova-issues/issues/5495)). Coming from Nova, expect `404` where the tool is hidden, and `423` / `409` / `429` from the 2FA challenge, email verification and the throttle. See [Tools → Tool routes and their middleware](https://getmartis.com/docs/customization/tools#tool-routes-and-their-middleware).

### Pages at URLs of your own

Nova declares a tool's pages in PHP (`Nova::router()` with `routes/inertia.php`) and registers their components in JS (`Nova.inertia()`); the tool's `Authorize` middleware answers `403` to a user `canSee()` refuses, and a tool page shows no breadcrumb ([Nova → Tools](https://nova.laravel.com/docs/v5/customization/tools)). Martis routes on the client, so an app registers the path and the component in one call, `routeRegistry.register()` (v2.2.0+). Bound to a Tool, the page answers as `/tools/{uriKey}` does: `404` where the Tool is hidden, the lock page where it is locked. The page gets a breadcrumb, and a registered path can never take a URL Martis uses. See [Custom pages](https://getmartis.com/docs/customization/custom-pages).

## Lens System

### Sticky summary row

A `summary()` hook renders aggregates as a sticky row under the lens table — no separate Metric required to show totals alongside a lens.

```
public function summary(Request $request, Builder $query): array
{
    return [
        'revenue' => ['label' => 'Total', 'value' => $query->sum('revenue')],
        'count'   => ['label' => 'Rows',  'value' => $query->count()],
    ];
}
```

### Declarative query cache

Per-lens cache keeps heavy aggregations warm across requests.

```
(new MostValuableClients())->cacheFor(60);                         // seconds
(new MostValuableClients())->cacheFor(new DateInterval('PT5M'));   // interval
```

The cache key mixes lens uriKey, filters, search, sort and page so distinct views never collide.

### Default filters pre-applied

A lens declares defaults that the URL auto-hydrates on first load.

```
(new OverdueInvoices())->withDefaultFilters(['status' => 'overdue']);
```

### URL state sync

Filters, search, sort, direction and page round-trip through the query string. Every lens view is deeplinkable.

## Filter System

### Built-in DateRangeFilter

Native date range filter with `from` and `to` inputs.

```
DateRangeFilter::make('Created Between')->column('created_at')
```

### Lens-exempt filters

Some filters belong to the resource index but make no sense inside lenses (custom date scopes, internal-only toggles). Mark them with `lensExempt()` so they stay out of the lens schema.

```
StatusFilter::make('Status')->lensExempt()
```

### Per-resource SoftDeletes filter control

Resources expose `softDeletesFilter(bool)` (and the inverse `hideSoftDeletesFilter()`) so individual resources can opt out of the standard "Trashed" toggle even when the underlying model uses `SoftDeletes`. Useful for internal tables where the trashed view would leak information that other resources should keep visible.

## Field Extensions

### Tooltip on the field label

`->help()` ships plain-text guidance under the input. `->tooltip(...)` attaches a `(?)` icon to the label and renders **raw HTML** on hover — multi-line, bold, lists, inline links — without growing the form's vertical footprint.

```
Text::make('name', 'Full name')
    ->help('Must be unique')
    ->tooltip(
        '<strong>Full legal name</strong>.<br />Examples:<br />'
        .'• John Smith<br />• Ana Pereira<br /><br />'
        .'<em>Avoid abbreviations.</em>'
    );
```

`tooltip()` costs zero pixels until the user hovers the icon. Applies uniformly to every field via the base class — Panel, Section, TabGroup, ResourceCreate, ResourceUpdate, and detail labels rendered inside Sections/TabGroups.

The label renderer registers the `(?)` icon with `htmlTooltip()`, and the global `MartisTooltip` provider renders a registered trigger as HTML: a field tooltip, a metric's `help()`, or a trigger of your own (see [Tooltip Standard](https://getmartis.com/docs/customization/components#tooltip-standard-primereact)). Any other trigger keeps the plain-text escape, whatever attributes it carries. The markup is sanitised before it is shown, but authors are still responsible for producing it: user or record data never goes into it.

A `Tooltip` field class was deliberately rejected — a `Field` represents a value, not a decoration. See [Fields → Tooltips](https://getmartis.com/docs/core/fields#tooltips-martis-differential) for the full rationale and the `tooltip()` vs `help()` decision matrix.

### Icon — Phosphor picker

The `Icon` field has three modes:

- **Display-only** — render a named Phosphor icon on detail/index. Useful for status cues.
- **Stored with visual picker** — opens a categorised + searchable picker. Saves the icon *name* to the DB (portable, framework-agnostic).
- **Computed from another attribute** — derive the icon from a model field via a closure; the picker is hidden.

Supports palette whitelisting (restrict to a configured subset), `colorFrom()` to pull the hex colour from another attribute, configurable sizes and tooltips. Full API in [Fields Reference](https://getmartis.com/docs/core/fields#icon).

### BooleanGroup — grouped sections + live counter

`BooleanGroup` ships extras beyond a flat list:

- **`grouped([sectionTitle => keys])`** — renders each section as a collapsible panel with its own legend. Keeps 20+ permission flags manageable.
- **`minChecked(int)` / `maxChecked(int)`** — enforced server-side AND surfaced as a live counter pill that turns amber when the user hasn't met the minimum or hits the ceiling.
- **`requireAny()` / `requireAll()`** — convenience presets.

```
BooleanGroup::make('permissions')
    ->options(['clients.view' => 'View clients', /* … */])
    ->grouped(['Clients' => ['clients.view']])
    ->requireAny();
```

### Avatar + UiAvatar — identity pictures with zero plumbing

Both fields share the same palette + initials logic through the [`ResolvesInitialsPayload`](https://github.com/Real-Edge-FX/martis-package/blob/main/src/Fields/Concerns/ResolvesInitialsPayload.php) trait, backed by `Martis\Support\Initials`, which also gives the Topbar and the profile page their avatar: a person gets the same letters on the same `--martis-avatar-N` token everywhere, and a theme recolours them all.

**`Avatar` — upload field with a zero-config empty state:**

- Out of the box, `Avatar::make('photo')` renders an `<img>` when the user uploaded one, and **coloured initials inline** when they haven't. No fallback closure required. No external service hit.
- `fallback($url | Closure)` is available as an opt-in override (per-row Closure-aware).
- `colorFrom('brand_color')` + `initialsFrom('display_name')` + `initials(Closure)` customise the defaults.
- Typed `AvatarShape::Circle | Rounded | Squared` enum.

**`UiAvatar` — always initials, never uploads:**

- Display-only (`hideFromForms()` locked), computed from the model — no DB column.
- Same deterministic 16-slot palette hash. Computed with the record and painted inline, with no external service call.
- Same `colorFrom()` / `initials(Closure)` / `from('other_attr')` knobs as `Avatar`.

```
// Most common case — one line, inline initials for members without a photo.
Avatar::make('avatar_path')->circle()->colorFrom('brand_color');
 
// Read-only resource without a photo column — pure initials pill.
UiAvatar::make('avatar_initials')->from('name')->colorFrom('brand_color');
```

### Audio — canvas waveform, zero server deps

The `Audio` field extends `File` with a **client-side waveform**: the audio is fetched, decoded via `AudioContext`, peaks are sampled and painted onto a `<canvas>`. No server-side image rendering, no ffmpeg, no external service. `downloadable(bool)` toggles the download button.

### Stack + Line — index-capable composite display

`Stack::make(...)` renders on both index and detail — ideal for compressing identity columns (name + email + company) into a single table cell without writing a custom component. `Line::subtitleFrom('attribute'|Closure)` emits a second muted line below the first without declaring an extra `Line`, and `Stack::divider()` inserts a thin separator between entries.

```
Stack::make('identity', __('fields.identity'), [
    Line::make('name')->asHeading()->subtitleFrom('email'),
    Line::make('company')->asMuted(),
])->divider();
```

Line variants — `asHeading()`, `asBase()`, `asSmall()`, `asMuted()`, `asCode()` — map to `.martis-line-*` classes so custom themes restyle every Line in the package through a handful of CSS tokens instead of per-field inline styles. See [Fields Reference](https://getmartis.com/docs/core/fields#stack--line).

### Badge closures — schema-time and per-row

`Badge::map()`, `->labels()`, `->types()` and `->icons()` accept one of:

- An array — a static map.
- **Zero-arg Closure** — resolved once when the schema is serialised. Perfect for enum-backed palettes: `->map(fn () => StatusEnum::badgeMap())`.
- **One-arg Closure** — resolved per row. Receives the raw value (and optionally the model) and returns the single string for that value. Ideal for convention-driven i18n: `->labels(fn (string $v) => __("statuses.$v"))`.

For full control, `->resolveBadgeUsing(fn ($value, $model) => ['type', 'label', 'icon'])` runs per row and returns the resolved payload. Missing keys fall back to the static/closure maps. All per-row results ship to the frontend wrapped in a `__martisBadge` object the Badge component detects transparently.

### Country, Currency, Slug, Timezone

- **`Country`** — `withFlags()` toggles the emoji flag renderer per call site.
- **`Currency`** — `displayMode` switches between text / badge / badge_text. `badgeColor`, `showBadge`, `showText` knobs allow per-row variants.
- **`Slug`** — URL-safe auto-generated identifier with a live collision check against the target column.
- **`Timezone`** — IANA dropdown with a live clock pill next to the option, so the operator immediately sees what time it is in that zone.

### Relationship toolbar hide flags

Every relationship field exposes nine fluent flags to hide affordances inside the relationship card without forking the component. The `HasMany` / `HasManyThrough` / `MorphMany` panels honour all nine. The `BelongsToMany` / `MorphToMany` panels have no View, Edit or Delete of their own (Detach and the pivot edit take their place, and `hideEditAction()` / `hideDeleteAction()` hide those), so `hideViewAction()` has nothing to hide there. The `HasOne` / `MorphOne` cards only have Create, Edit and Delete. See [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting).

```
HasMany::make('Invoices')
    ->hideCreateButton()
    ->hideSearch()
    ->hideSoftDeleteToggle()
    ->hidePerPageSelector();
```

Full set: `hideSearch`, `hideCreateButton`, `hidePerPageSelector`, `hideSoftDeleteToggle`, `hideViewAction`, `hideEditAction`, `hideDeleteAction`, `hideRestoreAction`, `hideForceDeleteAction`. The flags only change the panel: an action is shown when it is authorized and not hidden, so a flag never shows an action the policies deny, and hiding one does not make its endpoint refuse it.

### Grid layout system (12-column)

Responsive multi-column form layouts with `Section::columns()` and `Field::span()`:

```
Section::make('Details', [
    Text::make('first_name')->span(6),
    Text::make('last_name')->span(6),
    Email::make('email')->span(12),
])->columns(12)
```

Supports responsive breakpoints (`md` = 768px, `lg` = 1024px) as a mobile-first cascade: `colSpan()`, `colSpanMd()`, `colSpanLg()`. Below `md` every field takes the full row. The same grid lays out Panel and Tab bodies. See [Grid Layout → Responsiveness](https://getmartis.com/docs/core/grid-layout#responsiveness).

## Repeater

The Martis Repeater ships five surfaces beyond a basic repeater API. Full documentation in [repeater.md](https://getmartis.com/docs/core/repeater).

**`dependsOn([parent attributes])`**

Every field inside every row receives the parent record's attributes in `formValues`, so conditional logic can react to the record state without leaving the row.

**Cardinality + collapse + reorder**

`minRows()`, `maxRows()`, `collapsible()`, `collapsedByDefault()`, `reorderable()` with an auto-managed `position` column in HasMany / Polymorphic mode. Native HTML5 drag-and-drop — zero extra dependency.

**Row header affordances**

`Repeatable::icon()`, `->color()`, `->title('{field} · {field}')` (template resolved per row) and `->badgeCount()`. Surfaces real row context instead of just the class basename.

**`rowTemplates()`, duplicate row, bulk paste**

Pre-filled row templates group beneath the Add button, each row header gets a one-click duplicate, and a "Colar linhas" footer button parses TSV / CSV / JSON clipboard content into rows (header auto-detection when the first line matches field attribute names).

**`asPolymorphic()`**

Single child table shared by every row type, discriminated by a `type` column and a JSON `payload`. Ideal for page-builder-style layouts without one table per Repeatable type.

```
Repeater::make('blocks')
    ->asPolymorphic('type', 'payload')
    ->uniqueField('uuid')
    ->reorderable()
    ->repeatables([HeroBlock::make(), TextBlock::make(), GalleryBlock::make()]);
```

## UI Primitives

### Standardised button classes

Every confirm/cancel/destructive/secondary button in the admin shares the same chip geometry, shadow and hover choreography. Custom override components can drop in the same classes and inherit the full styling for free.

| Class | Purpose | Background | Hover |
| --- | --- | --- | --- |
| `.martis-btn-primary` | Default confirm (save, create, run) | `--martis-accent` | `--martis-accent-hover` |
| `.martis-btn-secondary` | Cancel / tertiary | `--martis-card` + subtle border | Overlay + lift |
| `.martis-btn-danger` | Destructive confirm (delete) | `--martis-danger` | `--martis-danger-hover` |
| `.martis-btn-warning` | Archive / reversible destructive | `--martis-warning` | `--martis-warning-hover` |
| `.martis-btn-filled` | Dynamic `backgroundColor` via inline style (e.g. `UnsavedChangesDialog` honouring a user-provided `confirmColor`) | inline | `filter: brightness(0.92)` |

Shared across every variant:

- **Resting**: 1px border, `box-shadow: 0 1px 2px rgba(15,23,42,0.08)`, 6px radius, `500` weight, 0.875rem font, `0.5rem 1rem` padding.
- **Hover** (not `:disabled`): deeper shadow (`0 3px 6px rgba(15,23,42,0.14)`) plus `translateY(-1px)` lift.
- **Active**: shadow collapses, `translateY(0)`.
- **Disabled**: no shadow, opacity `0.6`, cursor `not-allowed`.
- **Focus-visible**: 2px `--martis-accent` outline with 2px offset.

```
// Inside a custom action or override component
<button className="martis-btn-primary">Save</button>
<button className="martis-btn-secondary">Cancel</button>
<button className="martis-btn-danger">Delete</button>
```

Where the package uses them already: `DrawerShell` footer, `DrawerCreate`/`DrawerUpdate`, full-page `ResourceCreate`/`ResourceUpdate`, `ActionModal`, `DeleteModal`, `UnsavedChangesDialog`.

### <ClearButton />

A reusable clear-button component used by every input-like field (Text, Email, URL, Password, Number, Currency, Date, DateTime, Select, Country, MultiSelect, BelongsTo, MorphTo, Tag):

- Always red (`var(--martis-danger)`).
- Always has a `Clear` tooltip (translated).
- Only renders when `field.nullable === true && hasValue && !field.readonly`.
- Hover: darker red, no background fill.

For consumer apps building custom fields (on `@martis/runtime` since v1.38.0):

```
import { ClearButton } from '@martis/runtime'
 
<ClearButton
  visible={field.nullable && hasValue && !field.readonly}
  onClick={() => onChange(null)}
  style={{ position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)' }}
/>
```

### MartisTooltip engine

Custom `MartisTooltip` component instead of PrimeReact `<Tooltip>`. Uses event delegation on the document, which solves several PrimeReact Tooltip limitations:

- Works reliably with dynamically rendered elements (conditional buttons, pills, drawers).
- No tooltip "skip" when hovering quickly between adjacent items (e.g. sidebar menu).
- Instant switch between targets without delay when moving between tooltip elements.
- First hover uses a 500 ms delay; subsequent hovers between targets are immediate.
- Long text wraps inside the bubble, up to 360 px wide (or the viewport width minus 16 px), and a long unbroken token such as a URL breaks inside it. The ref-based PrimeReact `<Tooltip>` wraps the same way and keeps explicit line breaks (v1.38.0+; before, it kept `white-space: nowrap`, so a sentence ran out of its 300 px bubble).

All elements use `data-pr-tooltip` and `data-pr-position` attributes:

```
<button data-pr-tooltip="Clear search" data-pr-position="top">
  <XIcon size={14} />
</button>
```

## Metrics & dashboards

### Responsive 12-column metric grid

Arbitrary 12-column widths plus responsive breakpoints (`md` = 768px, `lg` = 1024px), as a mobile-first cascade:

```
UsersPerDay::make('Trend')
    ->width(12)       // from md (tablet): full row
    ->widthLg(8)      // from lg (desktop): two-thirds
```

Below `md` every card takes the full row, whatever its declared widths (the same rule the form grid applies to field spans), so a phone never shows a third-width card and a dashboard written for desktop keeps its look above `md`. `widthMd()` sets the `md` tier explicitly; `widthLg()` falls back to it, then to `width()`. The cards carry the resolved tiers as custom properties and `.martis-dashboard-grid` (martis.css) owns the placement per media query, so a theme can re-enable `width()` on phones with one rule. See [Metrics → Responsive widths](https://getmartis.com/docs/core/metrics#responsive-widths-widthmd--widthlg).

Fraction strings (`'1/2'`, `'1/3'`, `'2/3'`, …) are auto-converted for convenience.

### Filter grid layout span

```
StatusFilter::make('Status')->span(4)          // 1/3 width
DateRangeFilter::make('Period')->span(8)       // 2/3 width
```

Filters sit on a 12-column grid with the same breakpoint as the metric cards: full row below `md` (768px), their span from there.

### ActivityFeedMetric

Stream of recent events as a card. Each entry is rendered with a coloured Phosphor avatar tile + actor / verb / target line + mono timestamp.

```
class RecentDeploys extends ActivityFeedMetric
{
    public function calculate(Request $request): ActivityFeedResult
    {
        return $this->result()->add(
            actor: 'Ana Pereira', verb: 'deployed', time: '2m ago',
            target: 'api-core@v2.4.1', icon: 'rocket-launch',
        );
    }
}
```

Generator: `php artisan martis:activity-feed`. Full reference in [metrics.md](https://getmartis.com/docs/core/metrics#activity-feed-metric-martis-extension).

### EndpointTableMetric

Compact HTTP route table card with method chips (GET / POST / PATCH / PUT / DELETE), mono numeric columns and a thin share-of-traffic bar. Drops cleanly into `card-span-3`.

```
class TopEndpoints extends EndpointTableMetric
{
    public function calculate(Request $request): EndpointTableResult
    {
        return $this->result()
            ->errorWarnThreshold(0.2)
            ->add(method: 'GET', path: '/v1/resources', rpm: 482, latencyMs: 42, errorRate: 0.02, share: 28);
    }
}
```

Generator: `php artisan martis:endpoint-table`. Full reference in [metrics.md](https://getmartis.com/docs/core/metrics#endpoint-table-metric-martis-extension).

### Trend sparkline mode

`TrendResult::sparkline()` swaps the full Chart.js panel for an inline SVG sparkline + delta pill. Pairs with `.martis-dash-kpis` rows so four trend metrics fit across the top of a dashboard.

```
return $this->countByDays($request, Order::class)
    ->sparkline()
    ->showLatestValue()
    ->prefix('€');
```

The `<Sparkline>` component is also on `@martis/runtime` (v1.38.0+) for custom framed cards.

### Per-metric color override

Any metric can specify a custom chart color via `->color()`:

```
// TrendMetric: line color
RevenueMetric::make()->color('var(--martis-success)');
 
// ProgressMetric: bar fill (overrides semantic)
TaskCompletion::make()->color('var(--martis-warning)');
 
// PartitionMetric: per-label color map
ProjectsByStatus::make()->colors([
    'Active' => 'var(--martis-success)',
    'Paused' => '#f59e0b',
    'Done' => 'rgb(59, 130, 246)',
]);
```

Accepts any CSS color value — hex, rgb, rgba, hsl, named colors, or `var(--martis-*)`.

### Theme-aware chart colors

Chart.js cannot read CSS variables natively. Martis ships a runtime resolver (on `@martis/runtime` since v1.38.0):

```
import { chartPalette, accentColor, mutedTextColor, resolveColor } from '@martis/runtime'
 
const colors = chartPalette()              // ['#6366f1', '#22c55e', ...] resolved from --martis-chart-*
const accent = accentColor()                // resolved --martis-accent
const muted = mutedTextColor()              // resolved --martis-text-muted
const safe = resolveColor('var(--my-var)')  // works on var() OR literal hex
```

Charts re-render with the correct theme colors automatically when the theme changes.

### Dashboard layout helpers

`.martis-dash-kpis` (4-col KPI row, collapses to 2) and `.martis-dash-grid` (3-col body grid, collapses to 1) ship as public utility classes so consumer dashboards render with the same scaffold as the built-in one. `.span-2` / `.span-3` cell helpers reset on the same breakpoint.

### Custom dashboard cards (with scaffolding)

A single command scaffolds the PHP class and the React component, bound to each other by the key the extension entry registers:

```
php artisan martis:card RevenueChart
```

Creates:

1. `app/Martis/Cards/RevenueChart.php`, with `componentKey('card:revenue-chart')`.
2. `resources/js/martis-extensions/cards/RevenueChart.tsx`, the starter React component, which the extension entry registers under `card:revenue-chart` (filename auto-discovery, no registration call).

Usage in a Dashboard:

```
public function cards(Request $request): array
{
    return [
        (new RevenueChart())->withMeta(['currency' => 'EUR']),
    ];
}
```

The React component receives all `meta` data as props.

### Card width & framing

```
Card::make('Revenue')
    ->componentKey('revenue-card')
    ->width(6)        // grid-column span (1-12) from md, defaults to 4
    ->widthLg(4)      // optional: span from lg (widthMd() sets the md tier)
    ->framed();       // wrap custom component in the default MetricCard chrome
```

- `width(int)`, `widthMd(int)`, `widthLg(int)` — the Dashboard grid wraps the custom component in a `div` it places from these values (full row below `md`, then the cascade described above), so the component body never touches `grid-column` itself.
- `framed(bool = true)` — when `true`, the component renders inside the standard `MetricCard` container (title, icon, border). Defaults to `false` so hero-style cards can render full-bleed.

## SSO subsystem

Pluggable single sign-on with Azure AD, Google, GitHub, or any custom provider — config-driven, role-mapping built-in, `spatie/laravel-permission` auto-detected, generator scaffolds the entire flow with one command.

Three orthogonal axes that compose freely:

| Axis | Values | What it controls |
| --- | --- | --- |
| `role_source` | `groups` / `app_role_assignments` / `callable` | Where external role names come from |
| `role_strategy` | `column` / `config` / `callable` | How external names map to local roles |
| `permission_adapter` | `auto` / `spatie` / `native` / `callable` | How resolved roles get written onto the user |

### Generator command

```
php artisan martis:sso azure --with-spatie --with-migration
```

Scaffolds the provider end-to-end: config block, env stubs, optional `azure_group_name` migration on the `roles` table, and printed next steps (composer require, Azure portal config). After the user fills in `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`, the **Continue with Microsoft** button auto-renders on the login page. Zero code changes.

### Five host-app hooks

```
use Martis\Sso\Facades\MartisSso;
 
MartisSso::resolveUserUsing(fn ($identity, $provider) => User::firstOrCreate(...));
MartisSso::resolveRolesUsing(fn ($externalRoles, $user, $provider) => Role::whereIn(...)->get());
MartisSso::syncRolesUsing(fn ($user, $roles) => $user->syncRoles($roles));
MartisSso::afterLogin(fn ($user, $identity, $provider) => AuditLog::record(...));
MartisSso::onNoRoleMatchUsing(fn ($identity, $provider) => redirect(...)->withErrors(...));
```

### Spatie integration is automatic

`permission_adapter = 'auto'` (default) detects `spatie/laravel-permission` via `class_exists()` and routes role sync through `$user->syncRoles($collection)`. Apps without Spatie use the `NativeAdapter` against the standard `model_has_roles` schema. Apps with bespoke schemas use `permission_adapter = 'callable'` + `MartisSso::syncRolesUsing(...)`.

### Per-environment role mapping via env

Roles change between QA / Staging / Production. Map once in config, override per env:

```
'role_map' => [
    'admin' => env('AZURE_GROUP_ROLE_ADMIN'),
    'sales_rep' => env('AZURE_GROUP_ROLE_SALES_REP'),
],
```

`.env.qa` and `.env.production` carry different group identifiers. The mapper resolves through env, no code change to ship a new environment.

Full reference: [sso.md](https://getmartis.com/docs/auth/sso).

## Notifications

### Scoped notification centre

`Martis::scopeNotificationsUsing()` narrows every notification endpoint (the list, the counts, mark-read, mark-all-read, delete and clear-all) with one query callback, so a multi-context app does not show one context's activity in another. Nova's notification centre always lists every notification of the user. See [Notifications → Scoping the notification centre](https://getmartis.com/docs/reference/notifications#scoping-the-notification-centre).

## Frontend utilities

### Event bus

Decoupled pub/sub for cross-component communication. An extension uses the `martisEventBus` singleton on `@martis/runtime`:

```
import { martisEventBus } from '@martis/runtime'
 
// Emit an event
martisEventBus.emit('martis:record-created', { resourceKey: 'users', id: '42' })
 
// Listen for events, and stop with the same handler
const onCreated = (payload) => console.log('New record:', payload)
martisEventBus.on('martis:record-created', onCreated)
martisEventBus.off('martis:record-created', onCreated)
```

The package's own components use the `useEventBus()` hook over the same singleton, which drops its handlers on unmount (see [components.md](https://getmartis.com/docs/customization/components#event-bus)).

Built-in events: `martis:record-created`, `martis:record-updated`, `martis:record-deleted`, `martis:record-restored`, `martis:action-executed`, `martis:refresh-index`.

Since v2.3.0 the record events fire from every page, drawer, panel and inline create that writes, and `martis:action-executed` after every action run; dashboard metrics refetch on it, as Nova's metric cards refetch on `action-executed`. A record `id` (and each of the `ids`) is always a string.

### Configurable loader

Customizable loading indicator:

```
'loader' => [
    'logo' => '/images/my-logo.png',
    'spinnerColor' => '#6366f1',
    'overlayOpacity' => 0.6,
    'disableOn' => ['table' => false, 'search' => true],
],
```

### User preferences

Persisted per-user theme/accent/density/locale, server-backed by `martis_user_preferences` (one row per user). Preferences survive across devices and sessions.

- **Arbitrary brand colour per tenant.** Optional hex input (`#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA`) overrides the preset accent. Off by default (`allowBrandColor = false`) — flip on for multi-tenant apps where each tenant has its own colour.
- **Persisted preferences + shareable URL presets.** Named presets (`?preset=exec-comfort`) compose over the user row so shared links don't overwrite recipient defaults. Resolution chain: URL preset > user row > config defaults.
- **Density per surface + reduced-motion enforced.** `[data-density="dense"]` tokens cut row/button/input heights (including dashboard cards — StatCard, MetricCard, resource shortcuts, welcome hero). `[data-reduced-motion="true"]` clamps every `--martis-dur-*` to `1ms` AND applies a universal `*, *::before, *::after { transition-duration: 1ms; animation-duration: 1ms }` override so inline styles, third-party widgets, and raw Tailwind utilities also honour the preference. The same rules apply under the OS-level `prefers-reduced-motion: reduce` media query.

The preferences panel is a compact topbar overlay — theme / accent / density / language / accessibility. See [preferences.md](https://getmartis.com/docs/reference/preferences) for the resolver, API, and SSR no-flash mechanics.

### 163-token theme system

A single theme file controls the entire admin panel:

- **Background layers** (7 vars): page bg, surfaces, sidebar, topbar, cards, inputs.
- **Text & borders** (3 vars).
- **Accent / brand** (7 vars): primary, hover, active, contrast, alpha tints, focus ring.
- **Semantic colors, solid** (8 vars): success/warning/danger/info + hover variants.
- **Semantic backgrounds & text** (8 vars): for badges, alerts, status.
- **Interactive states** (4 vars): hover, active, search overlay.
- **Overlays & shadows** (5 vars): modal backdrop, sm/md/lg shadows, peek.
- **DataTable** (5 vars): header, rows, borders.
- **Border radius** (5 vars): from sm to full pill.
- **Typography** (32 vars): font families (sans/mono/heading) and their OpenType features, 7-step size scale, 4 weights, 3 line heights, each size, weight and line height with its verbose alias.
- **Chart palette** (10 vars): for partition/trend metrics.
- **Avatar palette** (16 vars): the initials avatar hues.
- **Brand gradient** (12 vars): hero and auth surfaces, badge, logo heights.
- **File icons** (6 vars): semantic per file type.
- **Badge variants** (12 vars): legacy compatibility.
- **Density** (7 vars): row, input, button heights and paddings.
- **Motion** (10 vars): 5 durations, 5 easing curves.
- **Print** (5 vars): the print stylesheet palette.
- **Rich text editor** (1 var): the Trix toolbar icon filter.

```
# Generate a theme scaffold with all variables
php artisan martis:theme MyTheme
```

The generated stub declares the 161 variables `martis.css` gives a value, on `:root` (dark, and the ones that do not depend on the mode) and on `html:not(.dark)` for the 102 it gives a light value, with comments and grouping; the two brand logo heights, set from `.env`, come commented out. Edit any value in `resources/css/martis/mytheme.css`, publish it with `php artisan martis:publish-assets` and refresh the browser: no Vite rebuild needed.

See [Theming Guide](https://getmartis.com/docs/customization/theming) for the complete variable reference.

### Component scaffolding

```
# Custom dashboard card — creates PHP class + React component (filename
# auto-discovery binds card:welcome-card to WelcomeCard.tsx).
php artisan martis:card WelcomeCard
 
# Visual override — creates TSX component. Every --type value
# auto-registers since v1.10.1: shell pieces and auth pages bind to
# fixed registry keys via the OVERRIDE_KEYS map; --type=field and
# --type=generic derive {kebab(filename)} (and -input for the
# field-shape pair) automatically.
php artisan martis:override StatusBadge --type=field
```

`martis:card` writes both the PHP class (`app/Martis/Cards/`) and the React component (`resources/js/martis-extensions/cards/{Name}.tsx`); the bundle's filename → key auto-discovery (`{Name}.tsx` → `card:{kebab-name}`) registers it on the next `npm run build:extensions`. `martis:override` writes a TSX-only file under `resources/js/martis-extensions/overrides/` and the bundle auto-registers it on the next build. See [Override System: Auto-registration scope](https://getmartis.com/docs/customization/overrides#6-creating-custom-components-artisan) for the full filename → key table.

### Command palette entries

The ⌘K palette lists resources, Tools, standalone actions, recent activity and records, and an app adds its own commands and deep links with `Martis::commandPalette()`. The entries are the menu's `MenuItem`s and `MenuGroup`s, filtered per request by the same visibility rules as the menu. Nova has global search, but no registrable command palette.

```
Martis::commandPalette(fn (Request $request) => [
    MenuItem::link('Open analyses', '/tools/analyses'),
]);
```

See [Components → App commands](https://getmartis.com/docs/customization/components#app-commands-v210).

### Skip links and an accessible shell

The sidebar and topnav layouts start with two translated skip links (WCAG 2.2, 2.4.1): "Skip to main content" moves focus to the `<main id="martis-main">` landmark, and "Skip to navigation" (v2.7.0) to the menu's `<nav id="martis-navigation">` landmark, or to the menu button on mobile, where the menu is a closed drawer. Since v2.7.0 the shell also meets these WCAG 2.2 AA criteria out of the box:

- **Named links in the collapsed rail** (4.1.2, 2.4.4). An icon-only link carries its label as `aria-label`; the tooltip stays for sighted users.
- **A modal mobile menu** (2.4.3, 2.1.2). The open drawer is a `role="dialog"` with `aria-modal="true"`: focus moves into it, Tab and Shift+Tab stay inside, Escape closes it, and focus returns to the menu button, which carries `aria-expanded` and `aria-controls`. Closed, the drawer leaves the Tab order instead of taking focus off-screen. The motion follows the reduced-motion preference.
- **Header-first order.** `MARTIS_LAYOUT_HEADER_FIRST=true` renders the top bar before the sidebar, so the Tab order is top bar, menu, main content, without a visual change ([Configuration → Header-first order](https://getmartis.com/docs/reference/configuration#header-first-order-v270)).

Two top-bar slots, `topbar:start` and `topbar:end`, add an app's component (a tenant indicator, an environment badge) to the bar without replacing it ([Overrides → Top-bar slots](https://getmartis.com/docs/customization/overrides#top-bar-slots-v270)). Nova's layout has no skip link.

### Test kit for extensions

`vendor/martis/martis/dist/testing/` is a development build of the runtime that renders your Tools, cards, fields and overrides in Vitest or a Playwright page, through the published shims, with `MartisTestProvider` for the user, preferences, config, locale, translations and route (v2.3.0). Nova has no test runtime for its tools' Vue components. See [Testing extensions](https://getmartis.com/docs/customization/testing-extensions).

## Artisan command suite

| Command | Description |
| --- | --- |
| `martis:install` | Full installation with optional `--with-profile` and `--with-2fa` |
| `martis:resource` | Generate a resource class |
| `martis:field` | Generate a custom field (PHP + React TSX) |
| `martis:action` | Generate an action (`--destructive` for destructive variant) |
| `martis:filter` | Generate a filter (`--boolean`, `--date` variants) |
| `martis:card` | Generate a custom dashboard card (PHP + React TSX; auto-discovers via filename → `card:{kebab}`) |
| `martis:override` | Scaffold a React override component (TSX only; auto-registers for every `--type` value via filename → key derivation since v1.10.1) |
| `martis:policy` | Generate a resource policy |
| `martis:theme` | Scaffold a custom theme with all CSS variables (dark + light) |
| `martis:value` | Generate a value metric |
| `martis:trend` | Generate a trend metric |
| `martis:partition` | Generate a partition metric |
| `martis:progress` | Generate a progress metric |
| `martis:dashboard` | Generate a dashboard class |
| `martis:user` | Create an admin user |
| `martis:list-env-vars` | Markdown / JSON table of every `MARTIS_*` env var (used to refresh the env-var index in [configuration.md](https://getmartis.com/docs/reference/configuration)) |

User preferences
