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:
- 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. - 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.
- RTL-first:
start/end,ms-/me-,ps-/pe-,text-start/text-end. Mirror arrows viartl:rotate-180. IDs/ URLs alwaysfont-mono+dir="ltr". - Card-based composition: each section in
<x-card>. Card paddingp-4mobile /p-5 md:p-6hero. - Single sticky save bar at the bottom for long forms.
- Explicit states: empty / loading / error are first-class.
- Modals via wire-elements/modal exclusively. Open with
wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with$this->closeModal()or$dispatch('closeModal'). - Toasts via
$this->dispatch('toast', message: ...). - Animations come from the
motion-*layer, not your own keyframes —prefers-reduced-motionis 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_catalogMCP 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>, thegray|zinc|slate|indigoneutrals, and container-scalemax-w-*(3xland 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 withform="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.seriestakes 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}].nullis a gap on the line, not a zero.format:number·currency(+currency="SAR") ·percent, withprecisionfor decimals. Numbers are formatted in the page's language.- Colours come from the platform tokens: your app's colour (from
brandin 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.:colorsoverrides the palette when you must. - Live updates with no code: pass
:seriesfrom a property or method on your Livewire component; every re-render updates the chart in place with an animated transition. No event, noentangle. - Empty is handled: all-
nullvalues (or an all-zero pie) show an empty state (emptyTextchanges the copy). Zero on a line is a value, not an absence. :optionsis the escape hatch: any raw ApexCharts option, merged last — reach for it before writingnew 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.