Quality & tooling

Design system & UI

Embedded apps render inside the tenant dashboard alongside okta-web's native screens, so visual identity must match. Hard rules:

  1. Use existing components: <x-card>, <x-button>, <x-badge>, <x-input-field>, <x-textarea>, <x-alert>, <x-spinner>, <x-modal-card>. No custom components, no third-party UI libs.
  2. Tailwind palette for neutrals and state colours. Your own brand colour has a supported home — see "Room to design" below; what is barred is a raw hex with no dark counterpart.
  3. RTL-first: start/end, ms-/me-, ps-/pe-, text-start/text-end. Mirror arrows via rtl:rotate-180. IDs/ URLs always font-mono + dir="ltr".
  4. Card-based composition: each section in <x-card>. Card padding p-4 mobile / p-5 md:p-6 hero.
  5. Single sticky save bar at the bottom for long forms.
  6. Explicit states: empty / loading / error are first-class.
  7. Modals via wire-elements/modal exclusively. Open with wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with $this->closeModal() or $dispatch('closeModal').
  8. Toasts via $this->dispatch('toast', message: ...).
  9. Animations come from the motion-* layer, not your own keyframes — prefers-reduced-motion is handled there once (see "Room to design").

For the full component catalog with props/slots/examples and the ready-made AI prompt that bakes in all the above rules, see commit a164484 on prod or commit eb910cf on main. Both have the expanded Design System section. The condensed version here is kept for the prod doc budget — the AI prompt content is identical in substance.

Room to design — what is actually yours

The rules above protect the foundation: the tokens, the dark mode, RTL, and the accessibility wiring on form controls. They do not mean every app has to look like the same app. This section is what is genuinely yours, and it exists because most of the reasons partners abandoned the design system were options that already existed, or should have.

Ask your assistant first: the component_catalog MCP tool lists every component with its props and examples, the motion classes, your own brand colour, and which policy rules fail a build versus which are advice.

1. The card is flexible now

<x-card> had exactly one look, so anyone who needed another abandoned the component and hand-rolled a div — losing the tokens and the dark mode to buy a shape. Now the shape comes from the component:

<x-card tone="accent" padding="none" overflow="visible" elevation="lifted">
Prop Values When
tone raised (default) · sunken · plain · accent plain = grouping with no visible box; accent = your brand colour
elevation flat · raised · lifted lifted rises on hover
padding default · tight · none none for a table, a map, a media strip that must bleed to the card's edge
radius default · lg · none
divider true (default) · false The rule under the header
overflow hidden (default) · visible For a menu that has to escape the card

All optional, and every default reproduces the old look exactly — nothing that exists changes.

2. Your own colour

The only route to a brand colour was a literal, bg-[#0F766E]. A literal has one value, so it is right in one theme and wrong in the other — and the platform cannot help, because a hex inside a class string is opaque to it.

Declare it in the manifest with both values and it becomes something the platform knows:

"brand": {
  "accent":          { "light": "#0F766E", "dark": "#2DD4BF" },
  "accent_contrast": { "light": "#FFFFFF", "dark": "#042F2E" },
  "accent_soft":     { "light": "#CCFBF1", "dark": "#134E4A" }
}

Then use it as a variable — it follows the theme the way platform tokens do:

<div class="bg-[var(--app-accent)] text-[var(--app-accent-contrast)]">…</div>
<x-card tone="accent">…</x-card>
  • Hex only (3, 6 or 8 digits). The value is written into a <style> tag, so anything else — rgb(...), var(...), color-mix(...) — is dropped rather than escaped.
  • Three roles, no more. A longer list is a second theme system, and a second theme system is how the platform's own stops meaning anything.
  • Scoped to your pages via [data-app-brand] — it never reaches the platform chrome or another app on the same screen.
  • Declared only light? Dark inherits the same value, so nothing is half-painted.
  • A malformed role drops on its own and does not take the others with it.

3. Motion

A ready layer instead of writing @keyframes:

Group Classes
Entrances motion-fade · motion-rise · motion-slide-in · motion-scale-in
Attention motion-pop · motion-shake · motion-pulse
Interaction motion-hover-lift · motion-press
Modifiers motion-slow · motion-delay-1..3
<div class="motion-stagger">
    @foreach ($rows as $row)
        <x-card class="motion-rise">…</x-card>
    @endforeach
</div>

motion-stagger on the container starts each child 40ms after the last — for ten steps, then it stops adding delay, so the tail of a long table does not arrive a second and a half late.

Why use these rather than your own keyframes: prefers-reduced-motion is handled for all of them in one place. Someone who turned motion off in their OS did not do it as a preference — for a person with a vestibular disorder, motion they did not ask for is a symptom, not decoration. Every keyframe you write yourself is one more place that has to remember them.

And retime freely: --motion-duration on any element retimes everything under it, and 0ms stops it.

4. The gate has two channels, not one

  • Fails the build — what breaks outside your design: raw <input>/<select>/<textarea>, the gray|zinc|slate|indigo neutrals, and container-scale max-w-* (3xl and wider, full, screen-*).
  • Reported, does not fail — consistency you may overrule: a raw <button>, and a hard-coded colour literal.

max-w-prose and max-w-[42ch] are not violations — that is typography, not a container. And a raw <table> is no longer a violation at all: <x-table> cannot express a timetable or a calendar, and a rule everyone overrides teaches people to override rules rather than respect them.

5. Modals

Do not build one yourself with x-show/x-data and a hand-rolled backdrop. The platform uses wire-elements/modal, and the stack is already registered in the layout — do not register it again.

1. The component extends ModalComponent, not Component:

use LivewireUI\Modal\ModalComponent;

class WithdrawalRequestModal extends ModalComponent
{
    public int $moduleId;

    public static function modalMaxWidth(): string
    {
        return 'lg'; // sm | md | lg | xl | 2xl …
    }

    public function mount(int $moduleId): void
    {
        $this->moduleId = $moduleId;
    }

    public function submit(): void
    {
        // …
        $this->closeModal();
    }
}

2. The view always uses <x-modal-card> — never a raw <header> or <footer>:

<x-modal-card class="w-[min(92vw,32rem)]" :title="__('…')" :subtitle="__('…')">
    <form wire:submit="submit" id="my-form" class="space-y-3">
        <x-input-field :label="__('…')" wire:model="amount"
            :errorMessage="$errors->first('amount')" />
    </form>

    <x-slot:footer>
        <x-button variant="primary" type="submit" form="my-form">{{ __('actions.save') }}</x-button>
        <x-button variant="secondary-subtle" type="button"
            wire:click="$dispatch('closeModal')">{{ __('actions.cancel') }}</x-button>
    </x-slot:footer>
</x-modal-card>

<x-modal-card> gives you the header, the close button, consistent borders and padding, and a <x-slot:footer> with its own top rule. Note w-[min(92vw,32rem)] rather than max-w-* — that is how a modal's responsive width is written here.

The button sits outside the <form> and points at it with form="my-form": the footer is the form's sibling, not its child, and a submit button outside its form does nothing without that reference.

3. Opening, from anywhere, with the arguments mount() takes:

wire:click="$dispatch('openModal', {
    component: 'partner.modules.new-version-modal',
    arguments: { moduleId: {{ $module->id }} }
})"

The name is the component's alias in flat form. Avoid :: in it — Livewire 4 runs the name through normalizeName() before looking it up, so an alias containing :: never resolves.

4. Closing: $this->closeModal() from the server, or wire:click="$dispatch('closeModal')" from the markup.

Why not roll your own: the backdrop, the focus trap, Escape to close, returning focus to the button that opened it, and locking background scroll are all in the package. A hand-built modal looks right and leaves a keyboard user tabbing around the page behind it.

Charts — <x-chart>

One component for every chart, and the library (ApexCharts) is bundled inside the platform — never load Chart.js or ApexCharts from a CDN: your app runs inside the platform's page, and a script from outside runs outside its bundle and its security policy and draws in colours that know nothing about the theme. The checker fails the build on it (remote-chart-library).

{{-- from a Livewire component: pass the data the way your query yields it --}}
<x-chart
    type="bar"
    :series="['Present' => $present, 'Absent' => $absent]"
    :categories="$days"
    :title="__('my-app::stats.attendance')"
    format="percent"
    height="280"
/>

<x-chart type="donut" :series="['Present' => 42, 'Absent' => 3, 'Late' => 5]" />

<x-chart type="line" :series="[['name' => 'Fees', 'data' => $fees]]" :categories="$months" format="currency" currency="SAR" />
  • type: line (default) · area · bar · column · scatter · radar · heatmap · treemap · pie · donut · radial.
  • series takes what your query yields, unreshaped: a flat list, a keyed list (keys become the categories or slices), a name→list map (one series per key), [{name, data}], and {x, y} points for scatter. Proportional charts also take [{name, value}]. null is a gap on the line, not a zero.
  • format: number · currency (+ currency="SAR") · percent, with precision for decimals. Numbers are formatted in the page's language.
  • Colours come from the platform tokens: your app's colour (from brand in the manifest) first, then the platform palette; axis text and grid from the same tokens — so the chart follows dark mode and RTL by itself and redraws when the theme flips. :colors overrides the palette when you must.
  • Live updates with no code: pass :series from a property or method on your Livewire component; every re-render updates the chart in place with an animated transition. No event, no entangle.
  • Empty is handled: all-null values (or an all-zero pie) show an empty state (emptyText changes the copy). Zero on a line is a value, not an absence.
  • :options is the escape hatch: any raw ApexCharts option, merged last — reach for it before writing new ApexCharts( yourself (which the checker flags: hand-driven-chart).
  • Two slots: <x-slot:actions> beside the title (a filter, a range toggle) and <x-slot:footer>.

Reference files in the platform

  • resources/views/livewire/partner/modules/module-edit.blade.php — hero header + sticky tab nav + save bar.
  • resources/views/livewire/partner/modules/notifications/notifications-index.blade.php — table + empty state + version picker.
  • resources/views/livewire/partner/modules/notifications/notification-form-modal.blade.php — full modal form.

View the whole guide on one page · ↧ Raw Markdown