provident. UI Design System Dark

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.

This is not the brand system. Exported artwork reads 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

The alias trap. Legacy names are kept so older rules flip for free — but --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.

Body — the studio's live base
rolebase
size17px
weight300
line-height1.7
Section headingMaster
class.p-cap
size12.5px
weight500
tracking0
class.p-lab / > s
size13px
weight400 / 300
valuemargin-left:auto
Numbers only, shown as AED 12,200,000
class.p-hint
size11px
weight300
line-height1.5
No all-caps anywhere in the chrome. Captions, option labels and group headings are sentence case at normal tracking. Tracked caps at 500 is a canvas role — eyebrows in exported artwork — and it is a binding brand rule there, not here. A field label is a sentence-case label, not an eyebrow: grouping it with the captions once forced 500 weight and .1em tracking on every label in the rail and read heavy and gappy.

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.

--ps-r-sm
--ps-r · panels
--ps-r-lg · 2-line rows
--ps-r-ctl · fields
--ps-r-tile · tiles
--ps-r-drop · drop zones
--ps-r-field · guided
--ps-pill · buttons
50%circles — a shape, not a token

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.

Layout
section padding12 / 16 / 18
--ps-secpad16px
group padding13px 14px
group gap9px
field gap4px
label gap6px
caption gap8px
row gap10px
panel gap12px

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-child carries 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 and align-self:stretch resolves 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-dockpad is 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.

Buttons

Secondary .p-ghost

height35px
padding10px 18px
type12px / 500
tracking.01em
radius999px
fill--ps-fill
onaccent + hairline + ring

Primary .p-btn

height36px
padding10px 16px
type13px / 500
fill--ps-accent-h
hover--ps-accent-h2
ink--ps-accent-i

Preview .p-prev — top bar only

height36px
padding7px 14px
type14px / 600 / +.16px
fill--ps-primary
edgenone

It was --ps-brand here and nowhere else: both re-skinned documents moved it to the navy action when orange stopped being a fill, and this library was the last place still painting it orange. White on orange measures 2.76:1, and the brand's own rule is that orange is punctuation.

The top bar .p-top — one height, and every control on it

bar55px, pad 0 16, gap 14
ground--ps-app
edge1px --ps-hair, bottom
every control36px
icon15px em / 10.6px ink

ONE HEIGHT FOR THE WHOLE BAR. Before the supplied Header frame landed it ran four in one row — 29 for .p-btn-q, 31 for .p-prev, 35 for the segmented track and 36 for everything else — which is what made it read as assembled rather than designed. .p-sm still means 29 and still belongs inside a rail group; nothing here takes it. The icon is matched to the label's cap height rather than to its em, which is why a 36px bar does not read as crowded.

This specimen carries NO inline overrides, deliberately. The bar was declared in this library with no specimen at all, and the first attempt at one reused .p-top as a stripped wrapper for the segmented pair — height, padding, ground and edge all overridden inline. An inline override on a specimen is the tell that the class does not work as declared, and the guide-versus-studio diff duly reported six differences on a class that was fine.

Segmented pair .p-seg — the studio switcher

track36px, pad 3, gap 2
surface--ps-card
button30px, 7px 14px
type14px / 600 / +.16px
on--ps-primary, no stroke

Two halves of ONE control, so ON is a fill and never a ring — and in the top bar the fill takes the PRIMARY register, because "which app am I in" is the loudest question on that row. The fill carries NO stroke — see the note under Option tiles. 3px of gutter rather than 2 is what puts the button at 30 in a 36 track; at 2px it was 31 in 35 and read as a fill with a leak. The track carries no edge of its own in dark, where --ps-card is a full step of surface — and keeps one in light, where --ps-card IS the bar.

Button group .p-tgrp — the top bar's delivery trio

height36px
edge1px --ps-hair
corner--ps-pill
cell0 18px, gap 6
rule1px --ps-hair

Three separate pills for one job were the widest run of chrome in the bar. The cells give up their own fill, so the group's hairline is the only boundary and the hover is the only fill — the surfaces-not-strokes rule reached from the other side. A cell keeps .p-ghost's type, ink, icon gap and row; only the surface and the corner come off.

Quiet .p-btn-q · Link .p-link · Badge .p-badge

quiet36px · 0 16 · 14/600, ink-2
disabledopacity .4
link24px · 0 10 · 12/500
badge24px · 0 10 · 12/500
badge onink fill, app ink

Badges are Title case. The all-caps sweep swept text-transform, which cannot reach a string that is literally uppercase in a render value — 'MASTER' was the survivor. The .14em tracking went with it: tracking is a tracked-caps device and reads as gappy on mixed case.

Compact .p-sm — a control inside a rail group

height29px
padding7px 14px
setsheight + padding only
inheritsweight, colour, state

Two heights, and the rule for which goes where. 35px is a top-bar or panel action — it has room and should read as the primary weight of its region. 29px belongs to a control inside a rail group, where four extra pixels a row lengthens three rails you already scroll. .p-sm sets height and padding only, so a compact button is visibly the same button rather than a different one.

Destructive .p-danger

ink--ps-bad
fill--ps-bad-q
hoverborder --ps-bad
radius999px

Tokens, not literals: the reds differ per theme (#F0705F on dark, #D92D20 on light), and the hardcoded pair this replaced read as a muddy smudge in one of them. It keeps the pill — it is an obvious button, and the 6px rect it used to carry was the only rogue radius in the studio.

Segmented .p-tabs

track padding5px
gap5px
button padding8px 14px
radius999px
onFILL, never a ring

Two-line choice .p-choice

radius8px, not a pill
padding8px 12px
on--ps-accent-q + border
sub-line on--ps-ink-2, not --ps-dim

Icon-only .p-ico-o · icon cell .p-ic

size35 × 35
padding0 · centred
radius999px

The icon-only button is the one label-less control in the top bar, so it needs an explicit square box — it once shipped with no .p-ghost class at all and rendered as a bare glyph between four proper buttons.

Fields

Text .p-in · with label and hint .p-fld

height36px
padding8px 10px
type14px / 400
radius10px
fill--ps-fill
focus2px --ps-focus
offset2px, on the ground

Select select.p-in

chevron--ps-chev
glyphMaterial 300
positionright 9px
size18 × 18

Stepper .p-step · error state [data-bad]

3
11 digits required.
stepper28px circles
error border--ps-bad
error ring2px --ps-bad-q
A <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

tile height62px fixed
tile radius16px
selected fill--ps-accent, no stroke
selected label--ps-ink at 500
unselected--ps-fill, no stroke
unselected label--ps-ink-2 at 400
marks--ps-skel → --ps-accent-i-q
label gap4px, outside

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

glypha <b>, never <i>
on--ps-accent fill, glyph → --ps-accent-i

Glyph specimen tile .p-mini-g · .p-mini-ar

weight400 — the specimen weight
Latin27px / 1.3 → 35.1px glyph box
Arabic34px / 1.35 → 45.9px glyph box
why biggerArabic body 46 per 100px vs a Latin cap's 72.8
neverline-height 1 — it crops the Arabic tail
  • 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-accent fill, a 2px --ps-link ring, 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-accent is #101E39 now: 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 i is the absolutely-positioned skeleton bar, and a glyph in one inherits all of it.
  • State flags are '1' or undefined, never a boolean. React renders data-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

surface--ps-fill
padding8px 10px
gap6px
radius--ps-r-ctl
name13px / 400
role12px / 400, truncates
specimenno frame of its own
cornercount at rest, + on hover
added280ms accent wash
  • 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-box on the children, explicitly. The studios do not mirror this library's own reset, so a width: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

track4px · 999px
track fill--ps-line
thumb14px circle
thumb fill--ps-link
margin-top-5px
strokenone

Three-stop .p-tri

Medium
control44px capsule
gutter5px, all sides
inner34px capsule
marks3 × 11, at 6/35/65/94
name column54px fixed
click zones0–25 / 25–75 / 75–100
  • No stroke on a thumb. The 2px ring also made the box 18px while margin-top assumed 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 sets height: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:0 makes 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

radius14px, not a pill
border1px dashed --ps-hair
surface--ps-fill
type12px / 500

QR row .p-qrrow

Listing number The QR carries it — it is not printed on the card
--ps-qr120px
sizingfixed width
box-sizingborder-box
gap12px
column gap5px
  • 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

rail
stage
rail--ps-sb
stage--ps-stage
card--ps-card
hover--ps-card-h
top bar54px · --ps-sb

Floating dock .p-dock-t / .p-dock-b

pill height36px
pill padding0 16px
pill shadownone — a ring
panel padding14px
panel shadow0 18px 44px
panel gap12px

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

Export
radius--ps-r
border1px --ps-line
shadow--ps-shadow
enter160ms · 3px rise
  • A position:absolute menu inside a scrolling panel gets clipped. An export dropdown opening upward inside an overflow-y:auto panel 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 outside pointerdown — 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 is el.getAnimations().filter(a => a.playState === 'running').

Feedback

Chips, info disc, notes

Neutral Ready Blocked 43% copy Scrim i
tag24px · 0 10 · 12/500
info15px tinted disc
info ink--ps-ink-2
ring count0 — a disc, not a ring

Note .p-note

Photos are stored at export-grade resolution, so a 2160px export stays sharp.
surface--ps-tint-1
ink--ps-tint-ink
type12px / 300 / 1.5

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] matched data-icon-tint="", so the tint layer painted white when tinting was off — a guaranteed preview/export mismatch.
  • :empty does not match an interpolated span. Template interpolation leaves a whitespace text node, so a :empty::before placeholder 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. Use data-src and promote it after mount, skipping any value that still contains braces.
  • Never write viewBox inside 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 svg type 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. getComputedStyle can 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 color against 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 multiplying opacity, 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 sayWhat 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.cssThe library. Tokens, both themes, and every component class on this page.
provident-ui.tokens.jsonThe same tokens, machine readable, with the rules attached.
index.htmlThis page. It links the stylesheet, so it is a live test of the library rather than a description of it.