Bun Alkaif Design System · v0.1 · Arabic-first, RTL-default
Annotations are in English for review; every demo renders in the locale selected here. Brand values are marked FIXED; everything else is derived by this system.
Three fixed colours, a traced lockup, and no photography — that is the whole inheritance. The lockup is vector outlines, not live fonts (provenance in assets/brand/README.md); official artwork from the brand designer supersedes it. The logo contains both scripts, so it never mirrors and never swaps per locale.
Fixed palette — never edited, only extended
--brand-navy FIXED
#22426B · exact flat sample
--brand-copper FIXED
#C89878 · normalised swatch
--brand-near-white FIXED
#FBFFFF · normalised swatch
Deployment rule: navy is chrome and voice (bands, buttons, ink); copper is warmth in small, deliberate doses (accents, sale, the arc); near-white is the page itself. On light surfaces the resold bags supply the loud colour — the brand recedes, exactly like the physical shelf wall.
Lockup usage
Reversed lockup on navy or a photo scrim only — the wordmark is near-white and dies on light ground.
On light surfaces use the primary (navy tile) version, or type the name; never recolour the mark.
Minimum width 72px; clear space = the monogram's handle height.
Header height uses the monogram-and-wordmark lockup as supplied; no horizontal variant exists yet — request one from the brand designer.
02
Colour system + contrast evidence
Every ramp is derived from the two brand hues — navy carries structure and text, copper carries warmth, and the sand ramp is copper at low chroma so the imagery surface never fights the bags. Semantic states are tuned to sit beside navy + copper: terracotta error, olive-green success. Sold-out is neutral, never red (the page stays live and linkable); sale is brand copper, never red. Ratios below are computed live from the actual token values.
Derived ramps
{{ramp.name}}
FIXED
{{s.name}}
{{s.hex}}
Semantic states
نجاح · Successتنبيه · Warningخطأ · Errorمعلومة · Infoنفد المخزون · Sold outعرض · Sale
Error terracotta (#B23B32) deliberately shares copper's warmth yet stays unmistakably "wrong"; success leans olive so it doesn't read as a foreign UI green next to navy.
Surface stack
--surface-brand · bands: header, footer, hero
--surface-page · #FBFFFF (brand near-white)
--surface-card · pure white, 1px border, barely any shadow
--surface-image · sand — behind every product cutout
--surface-brand-deep · menu panel, seasonal turn-up
Contrast evidence — WCAG AA, computed live from the tokens
Arabic sits optically lighter than Latin at the same px, so this system does not lean on the "large text" allowance for Arabic UI copy: Arabic body/UI text is floored at 15px and never set below weight 400; anything relying on the 3:1 large-text tier is display-scale Zain (24px+) or decorative. Each row samples real strings in both scripts.
قهوة مختصةAa 6.500
{{r.label}}
{{r.usage}}
{{r.ratio}}
{{r.verdict}}
03
Typography — the committed pairing
No typeface exists in the brand, and typography never changes seasonally (S1) — so it carries the brand alone. Committed here: Zain for display and IBM Plex Sans Arabic for text and UI. Both are Arabic-first designs with genuinely drawn Arabic — not Latin faces with bolted-on Arabic — both free webfonts, five files total (~240 KB subset), inside the performance budget.
Display — Zain 700 / 800
بُن الكيف
محاصيل مختارة، من محامص نثق بها
BUN ALKAIF
Why Zain: its rounded, open Kufi geometry is the closest webfont voice to the traced lockup's lettering — warm and confident without costume. Used at 20px and above only; its Latin carries the letterspaced BUN ALKAIF echo in copper.
Text + UI — IBM Plex Sans Arabic 400 / 500 / 700
محصول من مزارع سيداما، تجفيف طبيعي، تحميصة تناسب V60. إيحاءات اليوسفي والتوت الأسود مع قوام ناعم وقفلة نظيفة.
A Sidama lot, natural process, roasted for filter. Mandarine and blackberry over a soft body and a clean finish.
0123456789 · 6.500 · +968 9986 3999
Why Plex Sans Arabic: an Arabic-first cut with open counters that stays legible at 13–15px on cheap phone screens, a disciplined Latin from the same superfamily, and even, tabular-feeling numerals — the priority in a store where every price has three decimals. Western digits in both locales (client decision; matches their own price cards).
Type scale — phone-first (laptop value in parentheses)
display · Zain 800 · 30px (44) · 1.25
المحاصيل الجديدة وصلت
The new crops are in
title-1 · Zain 800 · 24px (32) · 1.25
البوكسات الجاهزة
Ready boxes
title-2 · Zain 700 · 20px (24) · 1.3
استلام من الفرع خلال 24 ساعة
Branch pickup within 24 hours
heading · Plex 700 · 17px · 1.4
يمن حراز الحماسي — أسطول البن
Yemen Haraz Al Hamasi — Ostool Albon
body · Plex 400 · 15px (16) · 1.75 ar / 1.55 en
تجفيف طبيعي على أسرّة مرتفعة، ثم راحة قبل التحميص.
Dried naturally on raised beds, then rested before roasting.
label · Plex 500 · 13.5px · 1.4
المحمصة · المصدر · المعالجة
Roaster · Origin · Process
caption · Plex 400 · 12.5px · 1.5 · floor
الأسعار شاملة ضريبة القيمة المضافة
Prices include VAT
price · Plex 700 · 17 / 22px · tabular
6.500
numeric runs are always <bdi dir="ltr"> — see §07
Per-script rules — the invariants
Arabic letter-spacing is always 0. Tracking breaks the joined script. Latin caps may track +0.08em (eyebrows, wordmark echo) — never Arabic.
Arabic takes taller leading: 1.75 body vs 1.55 Latin; headings 1.25–1.3 both.
No italics. Emphasis is weight (500/700) or copper-700, in both scripts.
Arabic UI text floors at 15px body / 13.5px labels / 12.5px captions — one step above the Latin minimum, because Arabic sits optically smaller at equal px.
Latin brand names stay Latin inside Arabic sentences (WINDMILL, V60); Arabic transliterations from the shop's own sheet are preferred where they exist (ويند ميل).
Digits are Western 0-9 in both locales. Arabic-Indic ٠-٩ is reserved and unused (client call, matches their price cards).
محصول V60 من محمصة WINDMILL — 6.500 ← mixed-run test: Latin marks, digits and the symbol hold their order inside RTL
Alternates considered — kept small on purpose
بُن الكيف — المحاصيل
Alexandria — one family, both scripts, both roles
Sturdier, more geometric; one less font to load. Passed over because its squarer Kufi drifts from the lockup's rounded warmth and it flattens the display/text hierarchy.
بُن الكيف — المحاصيل
IBM Plex Sans Arabic alone — the quiet route
Safest and lightest. Passed over because with no photography at launch, headlines are the only brand carrier — and Plex headlines read utilitarian.
04
Spacing · radius · elevation · borders
Canonical values live in tokens/*.css in this project. Geometry is rounded because the brand is — the monogram, their own spec-sheet cards, the pill tables. Depth comes from borders first; shadows are navy-tinted and rare.
Spacing — 4px base
--sp-1 · 4
--sp-2 · 8
--sp-3 · 12
--sp-4 · 16
--sp-5 · 20
--sp-6 · 24
--sp-8 · 32
--sp-10 · 40
--sp-12 · 48
--sp-16 · 64
Rhythm: 16 inside components · 24 between siblings · 48–64 between page sections (phone), one step up on laptop.
Radius
xs 6 · tags
sm 10 · inputs
md 14 · cards
lg 20 · sheets
xl 28 · hero
pill · buttons + chips
Elevation — navy-tinted, never black
e0 · border
e1 · card hover
e2 · dropdown
e3 · sheet/panel
Borders: 1px default (#D9DEE6), 1.5px on inputs, warm variant #E6DFD5 on sand. Hit targets: 44px minimum, 48px controls, 56px checkout CTA.
05
Grid + breakpoints
Product grids run 2 / 3 / 4 columns (IA §5.2) and never more. Above laptop the content column caps at 1200px and centres; the navy bands run full-bleed so a 27-inch screen reads as a framed page, not a stretched phone. Reading surfaces (PDP text, checkout, order page) cap at 680px.
One component set, logical CSS properties throughout (margin-inline-start, inset-inline-end…), dir on the root — never a mirrored second stylesheet (ADR-0001). Every demo in this document is the same markup rendered under both directions; use the toggle in the masthead to prove it.
Mirrors with direction
Layout order: navigation, cards, footer columns, form labels
The menu panel's entry edge — from the right in Arabic, from the left in English
Directional icons: back arrows, chevrons that mean forward/back, breadcrumb separators
Progress: order-status rail, qty steppers, finder progress (future)
Never mirrors
The lockup and monogram (they contain both scripts already)
Same markup: the panel is pinned to inset-inline-start, so the writing direction alone chooses the edge. Live version in §10.
07
Money — OMR, three decimals, one symbol
Prices always show three decimals (integer baisa underneath) and are always VAT-inclusive — Oman VAT Law Art. 37, a legal requirement, so the VAT line is a caption, never arithmetic at checkout. The shop supplied the official rial symbol; it renders as an inline SVG that inherits text colour, with the three text tokens (ر.ع / ريال / OR) kept as swappable fallbacks — flip the currencyToken tweak to re-render every price in this document.
The price component
6.500{{curTxt}}
{{t.vat}}
6.500{{curTxt}}2.600 {{t.per100}}
card size · per-100g makes the flat 6.500 shelf price comparable across weights
{{t.sale}}12.900{{curTxt}}15.000
sale price is copper-700, the struck price stays neutral — never red
The token, in each form
6.500
official symbol · default
6.500 ر.ع
their captions
6.500 ريال
their price cards
OR 6.500
their shelf stickers
Symbol weights (supplied SVG)
bold ≤17px text · medium at display sizes · height 0.62em, optically baseline-centred
Rules
Always three decimals: 6.500, never 6.5 — money is integer baisa underneath.
Numeric run is always <bdi dir="ltr">; Western digits both locales.
Token placement: after the amount in Arabic (their cards), before it in English (their stickers). One token per surface, set globally.
Prices are VAT-inclusive everywhere; the {{t.vat}} caption appears on PDP and at the order total, not on every card.
Price is text, never an image (the competitor ships a 3840px PNG as its currency glyph).
Which token ships is IA5 — client's call; the symbol is this system's recommendation.
08
Controls
Pills for anything tappable, 48px default height, 44px minimum target. Primary actions are navy; copper fills are reserved for promotion (sale, gifting) and always carry ink text — white-on-brand-copper fails AA and is banned. These render in the selected locale; flip the masthead toggle to check both directions.
Buttons
← focus ring: 2px navy-500, 3px offset; near-white ring on navy surfaces
sizes 56 (checkout) / 48 (default) / 40 (compact) · hover steps one ramp tone · press scales 0.98 for 150ms · disabled is neutral fill + disabled text, never a faded brand colour
Inputs + selects
+968
{{t.phoneHint}}
— {{t.mapReq}}
48px fields, 1.5px borders, radius 10 · phone field is a locked LTR +968 sector + 8 digits · error = terracotta border, tinted fill, message under the field · the phone address form is governorate → wilayat → landmark → map pin (no postcode, no company, email optional)
Badges + chips
{{t.sale}}{{t.soldOut}}{{t.newBadge}}{{t.omani}}
status badges — max one per card, image corner, inline-start
One content model, reused on home rows, category grids, collections and search (research §4·15): image · roaster · name · tasting triplet · price with per-100g · add affordance. Fields that aren't filled simply don't render — an honest short card beats a padded one. Roast level has no slot anywhere (dropped: on zero of four bags, absent from their own selling). The photo sits on the sand surface so heterogeneous roaster bags hang together like the physical shelf; no photography exists yet — the watermark tile below IS the placeholder spec.
The four states — baseline-locked, phone card width
full · sparse-but-honest (no notes ⇒ no notes row; in English, أدهم keeps its Arabic name — a missing English value renders the Arabic, never a blank) · sold out (photo untouched, neutral badge, disabled add — page stays live) · sale (copper, struck price neutral). Cards never carousel their images. All four add buttons sit on one line and all four prices on another: the action row is pinned to the card floor, the slack lands above it.
Imagery — 1:1, sand ground, 1 vs 3 shots
1 image — no dots, no strip
3 images — thumb strip, PDP only
Standard: 3 shots per product (front · back label · in context), AI-assisted per D7, always cut out onto sand-50. The card always shows shot 1; galleries exist only on the product page. Placeholder = sand tile + 12% watermark — never a grey box, never a broken-image glyph.
Image spec — the photographer's sheet
Every product asset is square and shot on one ground. Three delivered sizes cover every slot in the system — the largest card image is 203px, so 640 already carries 3× density.
AssetExportRenders at
{{s.what}}{{s.px}}{{s.slot}}
Ground
#F7F2EC
sand-50 · --surface-image
Flat and baked into the file — no alpha, no gradient, no vignette, no studio sweep. Corners are CSS (10 on cards, 14 on PDP), never painted in. Product fills ~80% of the frame with ~10% air all round; a contact shadow is allowed at 8% navy, nothing softer or wider.
File + exceptions
WebP primary, JPEG fallback, q≈85 · ≤40KB at 640, ≤160KB at 1440.
Bands (seasonal, about) are the one non-square case: 16:9, 1920 wide, on navy #22426B, full-bleed.
Placeholder ships at all three sizes: sand tile + monogram at 12%.
Sold-out photo is untouched — the badge carries the state, never a grey wash.
Honest short spec — renders only what is filled
{{t.prodCamboda}}
{{t.specSource}}{{t.brazil}}
{{t.specRoaster}}{{t.rKhibra}}
{{t.specProcess}}{{t.anaerobic}}
{{t.specVarietal}}{{t.arara}}
{{t.specAlt}}{{t.alt1000}}
{{t.prodAdham}}
{{t.specRoaster}}{{t.rBlackKnight}}
{{t.specWeight}}{{t.g250}}
two filled fields ⇒ two rows. No empty rows, no dashes, no invented values.
Baseline lock — the add button never floats
An optional row changes the height of the text block, never the position of the action. Cards in a row are one height (grid stretch); the price + add row is pinned 48px above the card floor — 12 padding, a 15px per-100g slot, half of the 44px button — and the slack collects above it. The per-100g slot holds its 15px whether it is filled or not: air, never a dash. This is why it matters even one card at a time — in a 2-up phone grid or a scrolling row you always see a neighbour.
✕ action follows the content — two baselines
✓ one baseline — slack sits above the price
Grid ladder — same card, more columns
Phone — 2-up grid
360–430 · margin 16 · gutter 12 · card 164–199 · add 44 · notes may take 2 lines
≥1280 · gutter 20 · card 285 · add 48 · never 5-up (photo would drop under 240)
One card model at every width — only the column count and two sizes change. The add stays visible at all sizes: no hover-reveal, because tablets don't hover. Phone home rows scroll horizontally with a 1.6-card peek, so the next card is always half in frame and the baseline lock still does the work.
10
Navigation
The finder is held back from v1 (project-lead call, 2026-08-23), so the phone bar carries exactly three things: logo · cart · menu — and search lives at the top of the menu panel. The panel is full-screen, one level at a time with a way back — never an accordion — and enters from the writing-direction edge. No account icon exists anywhere (no accounts in v1). The demo is live: open the menu, push into البوكسات, switch language from inside the panel.
{{t.home}}{{t.crumbSep}}{{t.catCoffee}}
{{t.catCoffee}}
{{t.crops12}}
{{t.menu}}
{{t.catCoffee}}
{{t.catQuick}}
{{t.catDrink}}
{{t.pgBranches}}
{{t.pgStory}}
{{t.pgCart}}
{{t.pgDelivery}}
{{t.pgContact}}
{{t.catBoxes}}
{{t.boxSeason}}
{{t.boxReady}}
live · sticky bar 60px · panel enters from the {{t.crumbSep}} writing edge · one level at a time, back on top
Tablet / laptop bar — dropdowns open on tap, never hover
{{t.catCoffee}}{{t.catQuick}}{{t.catDrink}}
{{t.boxSeason}}
{{t.boxReady}}
{{t.catBoxes}} ←
tap {{t.catBoxes}} to open — a menu that behaves differently by device is a menu with two bugs. The last dropdown row links to the parent category page itself.
The short-header reality — deliberate at three items
{{t.catCoffee}}{{t.catBoxes}}{{t.catQuick}}
Empty categories hide themselves (S3) and gear ships hidden (B14), so launch may look exactly like this. Items sit inline-start beside the logo — no stretched centring, no filler links, and the actions cluster keeps the end balanced. Roasters never appear here (they are a filter), and there is no account icon.
11
Filters — sheet, panel, rail
The facet set is exactly what the evidence supports (catalogue-model §3): brew method · roaster · origin · process · tasting tags. No roast level, no price slider at a flat 6.500, no sort menu invented. Phone: a bottom sheet — the grid behind dims but never reshuffles. Tablet: the same groups as a panel beside the grid. Laptop: permanently visible rail. Live: open the sheet in the phone frame.
Applied values echo above the grid as removable chips. Filters write to the query string (?roaster=khibra), never to a URL folder — a filter combination can never become a page someone must maintain.
12
Cart → checkout → order — the parts
Phone-first parts now; full page compositions land with the page specs (as agreed). Checkout is guest-only, phone-as-identity, and always ends in a hosted-redirect to the gateway — so the CTA says where you're going, and an unpaid return lands back in the cart with the error notice from §08. Live: pick a delivery method — the summary re-totals; pickup reveals the branch choice.
Delivery-method selector — live, prices from the wilayat table
{{t.methodTitle}}
{{t.branchNizwa}}{{t.branchMuscat}}
The promise line under each method is business-edited bilingual copy, displayed verbatim. A method switched off for the customer's wilayat simply doesn't render (S3) — if none remain, the empty state offers branch pickup + WhatsApp. Prices are the business's wilayat table, never constants.
Order page — one page, two moods (no lookup form; reached by emailed/WhatsApp link)
{{t.orderThanks}}
{{t.orderLater}}
{{t.orderNo}} #1042
{{t.stPlaced}}
{{t.stPrep}}
{{t.stReady}}
{{t.stDelivered}}
{{t.stPlaced}}
{{t.stPrep}}
{{t.stReady}}
{{t.stDelivered}}
exception statuses, shown only when applied:{{t.stReturned}}{{t.stRefunded}}
Statuses only — never a carrier tracking link (couriers keep numbers internal). The rail runs in the writing direction; "ready to collect" swaps to "shipped" for courier/home orders.
13
Empty states — where the competitor died
Every state from IA §6, none of them a dead end: each offers the categories, search, or WhatsApp. Type-led, no sad illustrations, no invented mascots. All wording below is placeholder — the client owns the voice (finder-related states are gone with the finder; its slot returns when it ships).
category empty · link still held
{{t.eCat}}
{{t.eCatBody}}
{{t.catCoffee}}{{t.catQuick}}
normally unreachable — an empty category removes itself from the menu (S3)
search empty
{{t.eSearch}}
{{t.eSearchBody}}
{{t.catCoffee}}{{t.catBoxes}}
cart empty
{{t.eCart}}
{{t.eCartBody}}
404
404
{{t.e404}}
{{t.e404Body}}
{{t.catCoffee}}{{t.search}}
the brand's own monogram, gently tipped — our Amazon-dogs moment. The real mark, never a drawn mascot; upright everywhere else, it only tips here
sold out — PDP buy area
{{t.prodYemen}}
{{t.rOstool}}
{{t.eSoldTitle}}
page stays live + linkable, dropped from sitemap same-day; notify-me deliberately deferred until a WhatsApp provider exists
14
Footer + breadcrumbs
Four fixed columns (IA §5.4). The structure is ours; the business edits phone numbers and links as values — there are no free-text widgets to fill with "IPA". Numbers are LTR runs. Payment marks render as monochrome text chips until the gateway is chosen (D5 open).
{{t.home}}{{t.crumbSep}}{{t.catCoffee}}{{t.crumbSep}}{{t.prodYemen}}breadcrumb · separator follows writing direction · emitted as BreadcrumbList JSON-LD · unambiguous: one category per product
Nothing moves on its own. Zero autoplay carousels (the competitor runs eleven, five at 1000ms), zero marquees, zero attention loops. Motion only answers a tap.
Surfaces travel (panel, sheet, dropdown); content never slides under a still cursor.
Images fade in over 250ms on load; no layout shift — every image slot has its ratio reserved.
prefers-reduced-motion collapses all durations to 1ms — state changes become cuts, and every demo in this document obeys it.
16
Seasonal theme — one token layer, one toggle
S1: seasonal looks are pre-designed variants the business activates and schedules, never assembles — each carries a date window and reverts itself (the realistic failure is Ramadan decorations still live in July). Typography never changes; layout never changes; each theme is a small token layer plus at most one structural motif, and real seasonal content does the rest. Four variants ship designed-and-approved: Ramadan and Eid as a night/day pair, National Day deliberately restrained, New Year as midnight. Greeting lines below are placeholders — the client owns the wording.
{{seasEyebrow}}
{{seasTitle}}
{{seasLine}}
This theme: {{seasDiff}}
Shared discipline, every theme: fonts, spacing, layout and components untouched · at most one motif, used once, always hairline-subtle · every text/background pair stays AA (the tuned tints exist for exactly this: copper-300 on deep navy 5.6:1, flag-red CTA with white 6.8:1) · themed placeholder tiles here stand in for real seasonal photography, which does most of the work · date window set at activation, auto-reverts. What a theme may never do: swap fonts, scatter decoration, add a countdown widget, or recolour product cards — the shop floor stays calm while the hero dresses up.
17
Decisions made here + what I need next
The repo didn't settle these — this document does (all reversible)
Typeface pairing: Zain (display) + IBM Plex Sans Arabic (text/UI). The one decision to contest hardest — alternates in §03.
Every non-brand colour: the ramps, navy-tinted neutrals, sand image surface, terracotta error, olive success, neutral sold-out, copper-owned sale.
Geometry: pill controls + 6–28px radius family, read off the monogram and their own spec-sheet cards.
Imagery convention: 1:1 cutouts on sand-50, 3-shot standard, watermark placeholder (confirmed this session: warm sand ground).
Digits: Western 0-9 in both locales (confirmed this session).
Currency default: the official symbol you supplied, amount-first in Arabic, token swappable — final token is still the client's call (IA5).