Design system

Every primitive, in every variant. Nothing ships to a product page until it renders correctly here in both themes.

Materials

theme.css · @layer components

Every surface is exactly one of these. A resting card casts e1 and lifts to e2 under the pointer — the third edition of the elevation doctrine, after «cast nothing» made the product read flat. Shadows are wide, deep and low-alpha ink; a tight contact line is what the second edition got wrong.

canvas
subtle
solid
elevated
glass
luminous
success
warning
destructive

Elevation

shadow-e0 … shadow-e5

Soft, wide and deep at low alpha — a bloom, not a contact line. e1 is what every resting card gets, e2 is its hover, e3 a dropdown, e5 the modal. Untinted near-black ink: a brand-tinted shadow at this spread reads as a colour cast rather than as light, which is what «looks like PowerPoint» meant. The one exception is `featured`, whose shadow is brand-tinted because the object casting it is.

e0
e1
e2
e3
e4
e5

Gradients

.gradient-brand · .gradient-signature · .gradient-sheen

Derived entirely from the brand ramp and the supplied aurora accents — no new hues. Reserved for brand moments: the hero, the logo mark, a ticket. They are no longer on cards or buttons, which is where they read as gloss.

brand
signature
sheen on solid

Motion

duration-(--duration-*) · ease-*

Five durations, five easings. Hover each tile. Everything here is CSS — no animation library, and the global prefers-reduced-motion rule in theme.css switches all of it off in one place rather than per component.

ease-out the house curve
ease-out-soft larger travel
ease-in exits
ease-in-out there and back
ease-spring arrival only

Event spotlight

<x-ui.event-spotlight />

Une diapositive du carrousel d'accueil. Trois couches : un fond pleine largeur qui est une copie floutée de l'affiche de l'événement lui-même, une carte arrondie qui porte la bannière nette, et un panneau givré en bas. ⚠ Les deux flous ne sont pas la même technique : le fond utilise filter: blur() sur une image statique — nous contrôlons ce qu'il y a derrière, donc flouter l'image directement est identique à l'œil et ne coûte aucune couche de composition. Le panneau utilise le vrai .glass, parce qu'il doit flouter du contenu vivant, la photo nette en dessous. C'est la deuxième des trois surfaces autorisées.

Samedi 4 octobre · Ksar Ghilane · Tozeur

Origins

Le désert, un line-up international et rien d'autre à des kilomètres.

Confetti

<x-ui.confetti />

La rafale d'arrivée : une seule fois dans tout le produit, quand l'acheteur atterrit sur ses billets après avoir payé. Zéro octet de JavaScript — trente pièces, une keyframe partagée, transform et opacity uniquement, donc le compositeur s'en occupe sur son propre thread. Sous prefers-reduced-motion elle disparaît entièrement plutôt que de clignoter : la règle globale de theme.css réduit la durée à 0,01 ms, ce qui pour ces pièces serait un flash plein écran — pire que l'animation, et précisément pour la personne qui a demandé moins de mouvement. Rechargez la page pour la rejouer.

Paiement confirmé

Button

<x-ui.button>

No crimson variant: --highlight is reserved for non-interactive urgency. `touch` is the 56px scanner target.

As a link Disabled link

Label

<x-ui.label>

Text field

<x-ui.text-field>

The notched floating label, Google's outlined field reproduced with a real <fieldset>/<legend> so the browser cuts the gap natively. No JavaScript: the float is :focus or :not(:placeholder-shown). Click into one, then type.

Le billet est envoyé à cette adresse.

Cliquez pour ouvrir le calendrier.

Checkbox

<x-ui.checkbox>

A real <input type=checkbox> with the OS paint removed by appearance:none — not a hidden input behind a fake box. :checked and :indeterminate do the styling, so it is correct before JavaScript loads.

Un e-mail par événement, pas plus.

Radio

<x-ui.radio>

Same construction. A radio group also needs a <fieldset>/<legend> around it — the caller's job, since only the caller knows what the group is called.

Mode de livraison
Apple Wallet / Google Wallet

Switch

<x-ui.switch>

Also a real checkbox, with role=switch. The usual div-plus-Alpine switch submits nothing with the form and stops working if the bundle fails — for a control that decides whether an event is on sale, that is not cosmetic.

Les billets sont achetables immédiatement.

Money

<x-ui.money>

Every dinar in the product renders through this. It takes a Money value object or raw millimes and never does arithmetic on either — the columns are cast to Money precisely so that dividing them is impossible, and the checkout's first draft fatally errored trying. Tabular figures come from Onest's own tnum, so a column lines up without changing typeface.

45,000 TND 45,000 TND 90,000 TND 1 234,567 TND 0,500
Aligned by tnum, not by a mono face.
CatégorieVendusTotal
Général214 9 630,000 TND
Pelouse VIP147 13 230,000 TND
Carré Or50 9 000,000 TND

Avatar

<x-ui.avatar>

Initials rather than a broken-image icon, and the full name in an sr-only span so a screen reader hears the person, not two letters.

Amine Ben Salah Amine Ben Salah Amine Ben Salah Zerda Events

Badge

<x-ui.badge>

Solid and saturated — the opposite of the alert. A 20px chip has too little area for a pastel to read as a colour at all, which is why the old /15 alpha tints looked like grey pills. Scarcity is a sales signal, not an error: `inventory` must never render in the same red as a failure.

Neutral Primary Outline Muted Payé Action requise Remboursé Info En attente Fin dans 2h
32 places 3 restantes Complet
Small Medium

Alert

<x-ui.alert>

Your hold expires in 12 minutes

Complete payment before it is released to other buyers.

Payment received

Your tickets are on their way by email.

Only 3 left

This tier will close when they are gone.

Payment in flight

The bank has not answered yet. Violet, deliberately not amber — “we do not know” must never read as “it failed”, because the reflex after a perceived failure is a retry, and a retried refund pays the buyer twice.

Order abandoned

Nothing to do. The hold was released and the inventory is back.

Scan result

<x-ui.scan-result>

Exactly the five states the gate can produce — three from `ScanOutcome::signal()`, two from the device. A duplicate is amber, never red: REQ-ACC-03 makes it a staff judgement call, and red decides it before the volunteer has read the line. The two neutral states are not decisions and must never look like one.

ADMIS

Accès général · Porte Nord

ETK-4H7K-2P9R

DÉJÀ SCANNÉ

21h34 par Ahmed · Porte Nord

ETK-4H7K-2P9R

Billet remboursé

Annulé le 14 septembre

ETK-9QW2-XR41

EN ATTENTE

Hors ligne — sera vérifié au retour du réseau

ETK-1TT8-BB23

ERREUR

Le serveur n'a pas répondu

ÉTAT INCONNU

Ce que produit un état inconnu

Card

<x-ui.card>

Every card responds on hover — surface, stroke and shadow move together, no prop required. `interactive` adds the 1px lift and the focus ring, and means «clickable», not «alive»: a card that moves when it cannot be clicked is a lie about affordance. `glass` stays rationed to three surfaces platform-wide.

Solid

Default surface.

Elevated

One step off the page. For anything that overlaps.

Sunken

Recessed. A region inside a surface.

Translucent

Most of the effect, none of the cost.

Glass

Rationed. Never on checkout or the scanner.

Luminous

A state, not a container — confirmation only.

Interactive

Hover me. Rises one elevation step, presses back down.

Interactive, elevated

Same physics, one step higher.

Flush

PorteScans
Nord412
Sud288
Unpadded, for a card that owns its own layout.

Table

<x-ui.table>

Alignment is text-start, never text-left, so RTL is a token flip. Hover a row: a neutral tint rather than the brand accent, because a blue wash reads as selected and the row is only under the pointer.

Commandes récentes
RéférenceAcheteurBilletsTotal
ETK-4H7K-2P9RAmine B.284.000
ETK-9QW2-XR41Sarra M.142.000
Journal des scans — compact
HeurePorteBilletRésultat
21:34:02NordETK-4H7K-2P9RAdmis
21:34:11NordETK-9QW2-XR41Déjà scanné
21:34:19SudETK-1TT8-BB23Admis

Modal shell

<x-ui.modal-shell>

A native <dialog>: focus trapping, Escape and the top layer come free. `tone` bands the HEADER only — «Rembourser 340.000 TND ?» and «Enregistrer ?» must not open the same-looking box, but a dialog whose whole surface is red turns its buttons into shapes on a coloured field, and the button is what the person has to aim at.

Libérer votre réservation ?

Vos deux billets repartent en vente immédiatement et pourraient ne plus être disponibles si vous changez d'avis.

Rembourser 340.000 TND ?

Le remboursement est demandé à la banque avant toute annulation de billet. L'opération est irréversible et sera consignée au journal.

Spinner

<x-ui.spinner>

Inline SVG: the one icon shown while the network is what we are waiting on.

Loading Loading Loading

Skeleton

<x-ui.skeleton>

Fixed heights: a loading state that resizes on arrival blows the CLS budget.

Empty state

<x-ui.empty-state>

Distinct from an error on purpose: 'nothing yet' and 'it broke' look identical unless we say which.

No orders yet

Sales appear here the moment your first ticket is bought.

No scans recorded

Event card

<x-ui.event-card>

Image-led listing card. The scrim is a contrast guarantee, not decoration: covers are organizer-supplied and can be anything, so the text's ground is ours regardless of what was uploaded. Hover lifts the card and zooms the image — never the card, which would scale its own type.

Event hero

<x-ui.event-hero>

The cover floats over a blurred, scaled copy of itself. This is filter: blur() on a static image — a one-time raster — NOT backdrop-filter, which re-blurs a live layer every scroll frame and is the documented lag source the glass budget caps. Same look, no per-frame cost.

Zerda Events

Samedi 8 août · portes 20h00

Nuit Blanche Tunis

La Marsa, Tunis

Event banner

<x-ui.event-banner>

A wide promotional band for the next big thing, with a live countdown and a call to action. ⚠ The reference this was built from autoplays a video and this does not: an autoplaying loop on the LCP page is several hundred kilobytes before a buyer has decided they care, on the connections REQ-NFR-04 is written about — and we have no video assets, so shipping the markup would have meant a black rectangle on every install. The motion is a 24s transform drift over the event's own artwork, which the compositor runs off the main thread and prefers-reduced-motion collapses with everything else. ⚠ The countdown is rendered by the SERVER and only ticked by script, so a visitor whose bundle never arrives sees a figure that was right at page load rather than four dashes. It is a `<time>` with the real instant in `datetime` and it is NOT aria-hidden, unlike the reference: the countdown is the one thing on this band repeated nowhere else. ⚠ `tabular-nums` on the digits is load-bearing — without it the row re-measures and jitters sideways once a second, which is what makes a countdown look broken.

Genre icon

<x-ui.genre-icon>

One outlined mark per event genre, for the browsing strip and the header menu. Inline SVG rather than an icon package: the storefront's whole script budget is ~2 KB gzipped, and pulling a set to draw seven glyphs would cost hundreds of kilobytes of source for eight paths. Stroke and currentColor only, so it inherits the chip's text colour in both themes and owns no token of its own. ⚠ `genres.icon` has existed since the genre work and was read by nothing — GenreSeeder writes it and no view had ever rendered it, the same shape as venues.lat/lng before the venue picker. ⚠ An unknown name draws the CALENDAR rather than nothing: a genre is a row somebody adds without a deploy, so the eighth genre arrives before its glyph, and an empty box beside a label reads as a broken image where a calendar reads as « an event ».

Festival Musique Clubbing Sport Humour Ateliers Conférences Genre inconnu

Ticket

<x-ui.ticket>

The art is above the perforation; the QR sits below it on a flat white panel with a real quiet zone, in BOTH themes. A scanner reads reflectance, not our dark-mode preference, and nothing may ever be placed behind that panel. The notches are PAINTED, not cut — they take their colour from `--perf-ground`, overridden here because these tickets sit on a white panel rather than on the canvas.

Valide

Samedi 8 août · 20h00

Nuit Blanche Tunis

La Marsa, Tunis

Catégorie
Accès général
Billet
Billet 1 / 2
Titulaire
Amine Bouazizi
Référence
ETK-8GNP-9HZB
Remboursé

22 septembre · 21h00

Sahara Electronic Festival

Tozeur

Catégorie
Carré Or
Billet
Billet 1 / 1
Référence
ETK-4KQP-2LMN

Ce billet a été remboursé le 2 août. Il ne donne plus accès à l'événement.

Line icon

<x-ui.line-icon>

THE ONE DOOR TO THE ICON PACK (§19): a semantic name in, a **Heroicons outline** glyph out, normalised to one size and `currentColor` so it takes the colour of whatever it sits inside. ⚠ This note used to say *&quot;there is no icon library in this project and there is not going to be one… a package is tens of kilobytes on a page whose whole script budget is ~2 KB gzipped&quot;* — **wrong twice**: `blade-heroicons` has been installed all along as a Filament dependency, and `blade-icons` is a SERVER-SIDE PHP package that ships nothing to the browser but the one `&lt;svg&gt;` it prints. The reasoning confused a composer package with a bundled JS icon library, and the result was two people's idea of a chevron shipping side by side. An UNKNOWN name renders NOTHING — deliberately the opposite of `genre-icon`, whose fallback is a neutral calendar. A genre is a row somebody adds in the back office, so the eighth genre arrives before its glyph and needs something to draw; these names are written by us in a view, so a typo is a bug and a placeholder would hide it.

calendar pin ticket user user-copy check chevron chevron-left chevron-right close menu share arrow-right envelope play pause sun moon info success warning danger pending muted nom inconnu → rien
22 septembre · 21h00 hérite de la couleur du texte et de sa taille

Participant card

<x-ui.participant-card>

One collapsible per admission on a nominative order (`REQ-CAT-11`). It is a NATIVE `details` element with a shared `name`, so open, close, keyboard focus, Escape and the exclusive behaviour are the platform's and cost no JavaScript. THREE STATES, NOT TWO: « À renseigner » is work not begun and « À compléter » is work somebody thinks they finished — the half-typed card is the one that gets missed, and one word for both hides it. The collapsed row carries the name and the ticket type and NOT the e-mail: a checkout page is screenshotted and shown to whoever is paying. The save button below the fields is REMOVED by `app.js` once autosave is live; it ships so the page still works with the bundle blocked, which matters most for a buyer with money in flight.

Participant 1 · Sonia Trabelsi Pelouse VIP Renseigné
2 Participant 2 · Yosr Pelouse VIP À compléter

Il manque l'e-mail pour ce participant.

3 Participant 3 Pelouse VIP À renseigner