Provident UI.
The design system for the tool's own interface — Campaign Ad Studio, Organic Post Studio, the project browser, the guided run, and any webapp built on top. Every number here was measured off the running studio.
CampaignStudio.PAL and OrganicStudio.ART and never touches a
--ps-* token. That separation is verified across 126 component × variant ×
size combinations, and it is what lets the chrome be re-themed without moving a pixel of
a finished ad. For brand web components see _ds/.
Using it
Two lines. LIGHT is the default now; dark is what the bare :root
carries, so the attribute is what a light app sets. The three consumers all default
to light, and this page follows them.
<link rel="stylesheet" href="ui-design-system/provident-ui.css">
<html data-theme="light"> <!-- the default; omit for dark -->
Three files ship together: provident-ui.css is what you build on, provident-ui.tokens.json is the machine-readable copy for build tools, and this page is what you read and prompt against.
Serve this page rather than opening it from Finder. Chrome refuses a
relative font URL over file://, so a double-clicked copy falls back to
system sans and every measurement on this page shifts — the same reason the standalone
image tool base64-embeds its font. Any static server works:
python3 -m http.server from the project root, then open
/ui-design-system/.
Colour tokens
Each swatch shows the value resolving live in the theme you are viewing — the toggle in the header re-reads them.
Surfaces
Control fills
A control is a fill, not an outline. Every control once carried a 1px stroke and at rail density the whole panel read as a wireframe — the strokes added up to more visual weight than the controls themselves. A control is now a quiet fill that lifts on hover and takes the accent when it is on. Measured across three rails: 27 visible borders became 8.
Lines
Ink
Accent
The accent is derived from Provident Navy, and it used to be derived
away from it. #487194 sat at hsl(208, 34%, 43%) while
#1A2942 is hsl(218, 44%, 18%) — less saturated than the
brand colour, with its hue 10° warmer. Desaturating away from Provident is the one
direction that makes a colour look switched off.
Then a single vivid mid blue was tried, and rejected for a better
reason. #2968D6 was correctly derived — same hue as the navy, twice the
chroma, and it cost nothing (the white label went 5.17 → 5.19). But a mid blue
used as the resting fill of every selected control is loud everywhere and
therefore emphatic nowhere: there was no register left for the action you should
actually take.
Three registers, by job. --ps-accent
#101E39 is the dominant resting fill — every selected pill, ON tile,
active tab, nav row and wash — and it carries the highest label contrast in the system
at 16.58. --ps-accent-h #2053D0 is hover, and the
resting fill of an important action, so there is exactly one loud thing per screen.
--ps-accent-h2 is that action’s own hover, so it still lifts.
The deep fill has no boundary of its own on the dark theme.
#101E39 measures 1.00–1.15:1 against every dark surface, so as a
block it is invisible and only its white label reads. Every dark-theme ON state
therefore carries a --ps-link hairline, which clears 3:1 against both the
fill (7.27) and every dark ground (6.70–8.18). On light it needs none — the
same fill measures 14.97 on the app — so the light hairline is deliberately
invisible, and harmless. Do not “fix” that asymmetry by lightening the fill;
the hairline is the fix.
A deep navy cannot be a mark. Anything that must read on the ground
rather than carry ink of its own — a border, an inset ring, a slider thumb, a caret, a
diagram bar, the accent used as text — resolves to --ps-link, never to
--ps-accent. The property is the test: background is a fill,
everything else is a mark. That one rule reclassified about 90 declarations across the
three mirrors.
Orange is still a top-bar colour, used by exactly two controls, both genuinely primary: the studio switcher and Preview.
Status & tints
Skeleton
--ps-navy resolves to --ps-card-h, which is
#FAF9F7 in light mode. Any surface still painted with it turns near-white
while literal #fff ink stays put. That one alias made three whole screens
unreadable at 1.05:1 contrast. If you are writing new CSS, use the real token names:
--ps-navy · --ps-gold · --ps-cream · --ps-sand · --ps-orange · --ps-blue
all exist only for compatibility.
Type roles
One family: Google Sans Flex. Light 300 is the default weight for everything; 400 is a label or an input, 500 is a button or a heading.
A field's guidance is its own line, never appended to its label. At a
277px rail column, Price — numbers only, shown as AED 12,200,000 wraps and
orphans the example value, which reads as a broken row rather than as help.
Radius
One scale. A pill means an obvious button.
Every swatch above is drawn with the token and labelled from it. It
used to hardcode border-radius:4px … 999px and print the numbers as text — so
the one section documenting the radius scale was the one section that was not a live test
of it, and changing --ps-r-tile left this page still showing 13px. The
50% circle is the exception because it is a shape rather than a token.
The base is a flat 6px taken from the structure of dwtc.provident.ae — no pills on panels. Buttons override it to a full capsule by request. A card, a specimen tile, a grid container and a dashed drop zone are all clickable and none of them looks like a button, so none of them is a pill — a sweep that included the card classes turned every project miniature into a circle.
Two shapes are deliberate exceptions. The three-stop slider is a full capsule, track and fill; the two-line choice rows are an 8px rect, because a capsule round two lines of text reads as a lozenge.
Spacing & dividers
A control group is a band, not a card — a quiet header, its controls, and a hairline before the next one.
A divider is edge to edge. A row carries
margin-inline: calc(var(--ps-secpad) * -1) with matching
padding-inline, so its border spans the whole rail and reads as a seam across
it rather than as an underline under one control. --ps-secpad is the single
declaration of that inset, so the negative margin cannot drift from it.
- A
:last-childcarries no seam. The last row closes its region; a trailing hairline above the section's own edge reads as a stray line. - One group is one seam. The divider separates groups; it never appears
inside one. A sub-heading within a group is
.p-lab-2— 6px of space, not a rule. Getting this wrong made one control read as four stacked sections. width:100%on a flex item defeats the negative margin. It resolves against the container and then shifts left, leaving the seam 32px short at both ends. Omit it andalign-self:stretchresolves the width as container-inner minus the item's own margins — which negative margins therefore widen.- A group inside a column row is already contained — no band, no seam.
- The dock cancels its own inset, not the rail's.
--ps-dockpadis 14px against the section's 16px. Two different paddings, two different tokens; using one for both puts the seam 2px off at one of them.
Fields
Text .p-in · with label and hint .p-fld
Select select.p-in
Stepper .p-step · error state [data-bad]
<select> has no empty state. With an unset value the browser
shows the first option, so the control reads as answered while the field is still
'' — the dropdown looks broken and the preview stays blank. Commit the
displayed value the moment the control becomes current.
Never use the background shorthand on a select. The
chevron is a background-image, and the shorthand resets it to
none — so the arrow vanished the moment you hovered or focused. Use
background-color, and re-assert the image on every interactive state.
Option tiles
A miniature is a diagram, not a picture of a canvas. Reach for one when the option changes where something sits; when it changes a mode, use a glyph.
Layout preset .p-opt + .p-mini
A SOLID NAVY FILL CARRIES NO STROKE, by design decision taken in the
UI-Kelvz frames. This tile had a 2px --ps-link ring, and the rule behind
it had been re-derived on four successive accents: --ps-accent is 1.43:1
against the ground, so a navy fill has no boundary of its own on the dark theme, and
every previous pass answered that with a hairline. The state is now carried by the
LABEL — --ps-ink at 500 against its neighbours' --ps-ink-2 at
400 — which is text, and therefore governed by 1.4.3 rather than 1.4.11, and passes.
A control that fills with navy and carries no label needs its boundary back.
A control filled with the WASH keeps its ring — .p-choice,
.p-nav, the palette tile's add flash. A 22% wash is not a blue button and
has nothing else to show it. Only the solids lost their stroke.
Mode tile .p-mini-i
Glyph specimen tile .p-mini-g · .p-mini-ar
- The button carries nothing at all. The frame, the fill and the selected outline live on the tile inside it, and the label sits outside and below. Boxing a box in a box was the old shape.
- The selected tile and the three-stop slider are ONE treatment —
--ps-accentfill, a 2px--ps-linkring, white marks. They are the two controls that sit next to each other in the Layout rail and they used to disagree on every axis: the tile was a quiet fill with an accent-coloured diagram, the slider was an accent fill with white marks. Same question, two answers. - This reverses an earlier rule here — “selected is an outline, never a
wash” — and the reversal is the accent change, not a change of mind. A wash in a
bright mid blue was a loud blob beside a plain tile.
--ps-accentis#101E39now: darker than the rail, so the filled tile reads as a recessed navy chip, and filling it is what this system's own rule (an ON state is a fill, not a ring) asked for all along. The ring stays because on the dark theme the navy has no boundary of its own. - Marks inside the accent fill are white; marks on a quiet surface are skeleton ink. That was the slider's own rule and the tile follows it now that the tile is filled — 14.15:1 on the navy, against 7.27 for the ink step.
- The unselected tile has no stroke at all. It used to carry a 1px hairline on top of the fill it already has, and eleven tiles in one rail is where that shows: counted in Campaign's Layout rail, 19 visible strokes, 7 of which said nothing. The fill carries the extent. This system decided long ago that a control is a quiet fill rather than an outline — measured then at 27 borders down to 8 — and the option tile was simply never included in that pass. The selection gets stronger for free: its ring is now the only stroke in its group instead of one of three.
- Height is fixed at 62px, never an
aspect-ratio. A square makes two-up tiles taller than three-up ones and the rail loses its rhythm. - A glyph in a tile is a
<b>..p-mini iis the absolutely-positioned skeleton bar, and a glyph in one inherits all of it. - State flags are
'1'orundefined, never a boolean. React rendersdata-on={false}as the string"false", which[data-on]matches — so every tile lights up at once.
Palette row
A row you click to add something. Campaign's Design Components rail is the only one in the system, and it is where the pattern was measured.
Palette row .p-tile
- The specimen has no frame. The row used to carry a surface, a border and a
radius, and the specimen inside carried another surface, radius and 7px 9px of
padding — both measured
rgba(255,255,255,.05), the same colour. The inner frame bought nothing visible and cost ~14px on each of thirteen rows: about 180px of scroll for no effect. Same conclusion as the option tile — stop boxing a box in a box. - The corner carries state at rest and the action on hover. The count says how many are already on the canvas — the palette used to report nothing, so three clicks gave you three eyebrows and no acknowledgement. On hover the count fades out and the add glyph fades in. One place to look, and the swap is what says the row is live.
- The role hint truncates; it never collides. It is the least important of the three things in the row — you read it once — so it is the one that gives way.
- The add confirmation is the one authored moment, and the only thing in this library that runs past the 140–200ms hover envelope. A confirmation has to be noticed, not merely felt: 280ms, ease-out, no overshoot, no loop, starting at the accent and settling to the row's own surface so you see which row landed.
- Stamp it from a delegated listener. A palette re-renders on every state
change, so a per-row handler is re-bound on each one and a handler built in the render
values has no node. Delegate, stamp
data-added, clear it on a timer — and if a re-render drops the attribute mid-animation the flash simply ends early, which degrades to no confirmation rather than to a stuck state. box-sizing:border-boxon the children, explicitly. The studios do not mirror this library's own reset, so awidth:100%child plus padding overflows its row by exactly the padding — which is how the count badge kept landing on the role hint however much room was reserved for it.
Sliders
Range .p-range
Three-stop .p-tri
- No stroke on a thumb. The 2px ring also made the box 18px while
margin-topassumed 14px, so the thumb sat 2px below the track's centre line. Without it the arithmetic is exact:-(14 - 4) / 2 = -5. - An overlay input must out-specify
input[type=range]. That base rule setsheight:18px, and an attribute selector (0,1,1) beats a bare class (0,1,0) — so the invisible input covered only the top 18px of a 44px control and the whole lower half was dead. It read as "takes a lot of clicks just to slide it". - Zero the thumb. A range input maps a click across the thumb-centre
travel, not the track, so a default 18px thumb leaves a strip at each end where
pressing changes nothing.
width:0makes the travel the full track. - Marks are elements, drawn twice — ink on the track, white inside the fill. One
colour cannot read on both #487194 and a light track. Never a
repeating-linear-gradient: the chrome carries no gradient fills. - The preset's name sits beside the track, not inside the fill. Inside, it moved with the value and crowded the first marks; the column is a fixed width so the track does not resize as the name changes length.
Drop zones & QR
Drop zone .p-dash
QR row .p-qrrow
- The dash is the affordance, not decoration — a drop zone is the one control that keeps its outline while everything else became a fill.
- The square is a fixed width, not
align-self:stretch+aspect-ratio. This row's height varies with whether a number, an error and a link are showing, so deriving the width from it makes the same control a different size on three screens — the opposite of unifying them. box-sizing:border-box, or the error state's 2px ring plus 2px padding grow the square from 120 to 128 and the control resizes the moment a QR is missing.- A panel photo slot takes the aspect ratio of the canvas it feeds. A fixed height frames a 9:16 photo as a letterbox and so lies about the crop.
Surfaces
Ground & panels
Floating dock .p-dock-t / .p-dock-b
A floating control needs an opaque surface in both modes: a 12% accent wash is legible on #0A0D11 and all but invisible on #E9E6E1.
Popover .p-pop
- A
position:absolutemenu inside a scrolling panel gets clipped. An export dropdown opening upward inside anoverflow-y:autopanel put its first row out of reach entirely. Inside a scrolling panel, expand in flow. - A popover must be told to close. Close on
blur,visibilitychange, Escape and an outsidepointerdown— one survived a window switch and sat over the editor. - Never put a transition on a fixed sheet that re-renders every frame. A rAF tick
restarts the transition before it finishes; it then sits permanently in
playState: 'running', and a running transition holds its interpolated value, beating every normal declaration. The one-line check isel.getAnimations().filter(a => a.playState === 'running').
Feedback
Chips, info disc, notes
Note .p-note
Help prose lives in tooltips, not inline. Anything explanatory is
passed as data-tip on the label's info icon rather than set as a paragraph —
ten rings per rail was real noise, which is also why the icon is a filled disc.
Quietness expressed as opacity does not survive the theme flip. White
at 38% on near-black is legible; #5A6675 at 38% on white is not — locked-layer
chips measured 1.36:1. Where the opacity only means "this is quiet", set
opacity:1 and move the quietness into the token, which already carries it in
both modes. Where opacity encodes state, leave it alone.
Rules that bite
Each of these cost a real debugging round. They are the reason the system looks the way it does.
- Inline style objects beat any stylesheet rule. A themed control written in JS
must say
background: 'var(--ps-fill)', never a literal — a CSS rule can never win. This is why ~500 literals in JS style objects had to be converted by hand, and why a change to this file is pushed into the studios rather than inherited. - A missing style key renders the browser's default white box. When a control's look is supplied by an interpolated style object rather than a class, an omitted property is invisible in the source and looks like a theming failure in the browser. Default the key at the factory so a new row cannot reintroduce it.
- Declaring a rule last is not enough when an earlier rule is more specific. A two-class selector at 0,2,0 outranks a bare class in a later block.
- An empty
data-attribute still matches its selector.[data-icon-tint]matcheddata-icon-tint="", so the tint layer painted white when tinting was off — a guaranteed preview/export mismatch. :emptydoes not match an interpolated span. Template interpolation leaves a whitespace text node, so a:empty::beforeplaceholder never appears. Use a real value or an explicit flag.- Never write an interpolated URL into
src. The literal placeholder text is fetched as a path on every load, twice over — the preload scanner reads it straight out of the file, and the framework mounts the markup once more before interpolating. Usedata-srcand promote it after mount, skipping any value that still contains braces. - Never write
viewBoxinside a CSS string in a.dc.html. Design Cursor camel-cases attribute names, including inside a CSS data URI, so the SVG silently has no viewBox: it decodes fine, reports a size, and paints nothing. - A
svgtype selector out-specifies a size class..wrap svg{width:100%}is 0,1,1 and.mark{width:6.9cqw}is 0,1,0 — every mark rendered at the full size of its container. - The rAF tick does not fire while the browser pane is not compositing. Anything driven by it reads as dead. Screenshot before diagnosing the code.
- Reload into a theme rather than toggling into it when measuring.
getComputedStylecan return a mixed pre/post-flip picture that looks exactly like a theming bug. - An audit that composites opacity is the only one that finds invisible text.
Comparing computed
coloragainst the nearest opaque ancestor background misses everything above it — the ink is often correct and the inherited alpha is what kills it. Walk the chain multiplyingopacity, composite, then measure.
Changing the system
This file is the place to decide; the studios are updated to match in the same pass.
| What you say | What happens |
|---|---|
| "Make the accent warmer" | --ps-accent, -h, -h2 and the quiet wash
steps change here and in both studios' token blocks. Every selection, focus ring,
slider and active state follows from the one token. |
| "Buttons should be less round" | The pill sweep is the radius on
.p-ghost / .p-btn / .p-prev / .p-badge / .p-tabs plus the
borderRadius written inline by the studios' button factories — a
stylesheet alone cannot reach those. |
| "Is it all applied?" | Don't trust a spot check. Load this page in a
hidden same-origin iframe, copy its specimen sections' innerHTML into a
container inside .p-shell, and diff computed values
element-for-element. Identical markup, two stylesheets, one document. Mounted outside
the shell, every ancestor-scoped rule is skipped and half the controls report the
browser default. Compare design properties only, never width/height. |
| "I keep seeing different buttons" | Enumerate every
<button>, group them by computed
height + padding + size + weight + radius, and check each group maps to a role on this
page. Campaign measured 15 shapes for what the guide defines as 10 roles; the
fix was mostly the JS style objects, not CSS. Do not eyeball it — the values that
disagree are 2–4px apart. |
| "Tighten the rails" | --ps-secpad and the group padding.
Because the divider's negative margin is derived from that token, the seams stay
edge to edge automatically. |
| "Add a control I don't have" | It is designed here first, with its specimen and its numbers, then implemented in whichever studio needs it. |
| "Start a new webapp on this" | Link provident-ui.css, set
data-theme, and use the classes on this page. Nothing else is
required. |
What is deliberately not here
The canvas / brand output system — the type scale, the two palettes, canvas margins, the layout guard rails. That is a different system with a different authority: it is a binding brand specification for artwork, it must not follow the chrome's theme, and it already has its own sheet inside the app. Merging the two is what leaked the UI's blue into eight places in finished posts.
Files
provident-ui.css | The library. Tokens, both themes, and every component class on this page. |
provident-ui.tokens.json | The same tokens, machine readable, with the rules attached. |
index.html | This page. It links the stylesheet, so it is a live test of the library rather than a description of it. |