Skip to content

packages/ui/PRINCIPLES.md

Principles

How Bezel decides things. These are read off the components, not aspired to, so each one says where it currently holds and where it does not. A principle with a known violation list is a standard; a principle without one is decoration.


1. The file is the interface

A component is distributed as readable source and copied into your project one file at a time (npx bezel-add add <slug>). The package main points at src/index.ts, not a bundle. Once you take a file, you own it.

Three things follow, and the library already obeys all three:

  • No component imports another component. The single file that broke this rule (loaders/MultiStepLoader.tsx, which reaches for MagicRings and ShinyText by relative path) had to be excluded from the CLI, because a flat single-file copy cannot carry its dependencies. That exclusion is the rule proving itself.
  • Shared helpers are duplicated on purpose. The three-line cn() is redefined in seven files rather than imported from one. A shared util would be a fourth file the consumer did not ask for.
  • Tokens are a theming surface, not a required import. Every component carries its own literal values and works standalone. tokens.css lets you re-theme what you have taken; it is never a prerequisite for taking it.

The cost is real: a fix to one copy of a helper does not reach the other six. That is the price of the copy model, and it is paid deliberately.

2. Motion is the point, and it must be refusable

Most of this library exists because of a motion idea. That earns it no exemption. Anything that moves without the user asking honours prefers-reduced-motion, and an ambient loop that ignores it is a defect.

The good implementations are the standard: sections/ParallaxProductStage.tsx subscribes to matchMedia and re-runs its whole GSAP context on change; loaders/ScrollUnfurlPreloader.tsx uses gsap.matchMedia for a real reduced branch; display/DepthText.tsx bails out of its rAF loop entirely.

Where it holds: 36 of the 90 files that animate. Where it does not: the other 54. animation/Marquee.tsx, badges/ShinyBadge.tsx and sections/ContactSection.tsx run infinite loops with no guard at all. Those are the worst of them, because an ambient loop is exactly what the media query exists to stop.

3. Keyboard focus is not a style choice

Every interactive element shows a visible focus indicator: a 2px outline at 2px offset, clearing 3:1 against its own ground. outline-none without a replacement is a defect, not a preference. :focus-visible is used rather than :focus, so the ring appears for keyboard users and not on mouse click.

buttons/PinchedButton.tsx and the star ring inside buttons/BorderBeamButton.tsx are the reference: both were written with :focus-visible outlines from the start.

Where it did not hold: at the start of this pass, 15 of 43 interactive files had any focus handling, and three of those had removed the default with outline-none and put nothing back. The base GlassButton, the first component in the library, had no focus ring at all.

Two related rules the forms already keep and the rest of the library does not: controls are at least 48px on their smallest axis (min-h-[48px] appears in four form components), and a control that only responds to a pointer is unfinished. A hover effect with no focus equivalent is half a component.

4. Contrast is a gate, not a preference

npm run check:contrast fails on any violation and is expected to stay green. Text clears 4.5:1; marks, borders and focus rings clear 3:1; disabled text is exempt under WCAG 1.4.3 and is reported rather than gated.

A colour that cannot meet the bar is renamed, not excused. Three did:

  • #059669 is 3.77:1 on white and was being used as body text in three components. It survives as --bz-emerald-decor for rules and dots; text uses --bz-emerald: #047857.
  • #c9a227 is 2.42:1 against white. It is a fill that carries dark ink, never a text colour, and never a fill under white.
  • red-500 is 3.76:1. It stays as the error mark; error text is #b91c1c.

The same rule runs the other way on a dark ground, where --bz-emerald is 3.1:1 and --bz-amber 2.6:1 against --bz-void-raised. The void half of each family is its own token, --bz-emerald-on-void and --bz-amber-on-void, and as a fill it carries --bz-on-void-fill rather than white. Components choose between the halves with light-dark(), so one copy-paste file reads correctly on whichever ground the host declares.

Control edges have their own token, --bz-line-control (#8a8a8e, 3.4:1 on paper), because the hairlines separate surfaces and cannot identify a control.

The script parses tokens.css directly and cross-checks tokens.ts against it, so the two token surfaces cannot drift apart without the gate noticing.

Tokens are only half of it: a component can hard-code a colour, and a preview can stack an opacity on top. So the gallery also measures what every preview actually paints. In a browser, it checks text, icons, control edges and focus rings against the ground they land on, and the gallery build refuses a failing pair or a measurement older than the sources. How that works, and what it cannot measure, is in GALLERY-AUDIT.md under "Rendered contrast".

5. One curve, one ladder

The library shipped five near-identical editorial ease-out curves ((.23,1,.32,1), (.22,1,.36,1), (.16,1,.3,1), (.25,1,.5,1)) plus four separate spring tunings for the same class of motion. Nobody can tell them apart in a side-by-side. A system that ships all five has not made a decision.

There is one ease-out (--bz-ease-out), one in-out, one overshoot, and linear for loops. Durations are a four-rung ladder (150 / 300 / 500 / 800ms) with 300 as the default, because it was already the most common single duration in the library. Ambient loops are a separate set and are always reduced-motion gated.

6. Pills on paper

The shape language is not up for negotiation per component. rounded-full is the most common shape in the library by a wide margin (32 uses counting both spellings), so controls are pills. Surfaces are paper: near-white, separated by a rgba(10,10,10,0.06) hairline rather than a shadow, the pattern in every card grid in the library.

The one deliberate exception is PinchedButton's asymmetric 0 40px 0 40px. It is kept as a token (--bz-radius-pinched) because it is the single piece of form language this system owns outright rather than inherits.

Where the library breaks its own rule it is visible and should be closed: six different radii are currently in use for the same card role, and three different radii for the same primary CTA.


The standing audit

PrincipleHoldsDoes not yet
1. The file is the interface89 of 90 files are standaloneMultiStepLoader (excluded from the CLI)
2. Motion is refusable36 of 90 animated files54 files, 3 of them infinite loops
3. Focus is visibleevery interactive file, after this passmaintain it; :focus should become :focus-visible in 6 older files
4. Contrast is a gateall tokens, and every measurable pair the 90 previews render: 587 at the last run, 0 failing65 rendered pairs sit on images, gradients, canvases or blend modes and are reported, not measured; components still carry pre-token literals
5. One curve, one ladderthe token setcomponents carry 5 curves and 4 springs
6. Pills on paperthe token set6 card radii, 3 CTA radii in components

Rows 2, 5 and 6 are counts of animated files and of radii, not of components, and they were measured before the September 2026 removals: seven components first, then twelve more on 20 September, when four card grids also merged into CardGrid, and then a pass that took the library to 89, and PixelAvatar made it 90. They have not been re-measured since, so they do not track the library total. The components added in September each gate their own motion on reduced motion, including the two annotation components, SketchHighlight and SketchArrow, and PixelAvatar, whose idle loop stops under reduced motion and whose hover keeps the blink and drops the hop.

Principle 4 applies to the gallery too. Every colour pair below is resolved from the same tokens.css and measured before each build; a pair under its minimum, or a text colour in the chrome that is not on this list, fails the build.

Measured contrast of the gallery’s colour pairs
WherePairRatioNeeds
Text
Headings, names, body copy#ffffff on #0c0c0f19.534.5:1
Descriptions, counts, footer, inactive nav#cececf on #0c0c0f12.424.5:1
Install command, code block, search field#ffffff on #1a1a1a17.404.5:1
Search placeholder, prompt symbol#d1d1d1 on #1a1a1a11.404.5:1
Active category chip#0a0a0a on #f7f3ee17.924.5:1
Active category chip count#4a4a4c on #f7f3ee8.004.5:1
Skip link#0a0a0a on #ffffff19.804.5:1
Stage hint on a void stage#d6d6d6 on #3131318.954.5:1
Stage hint on a paper stage#4a4a4c on #fefefe8.774.5:1
Stage hint on a cream stage#4a4a4c on #fefdfc8.704.5:1
Controls
Focus outline on the page#ffffff on #0c0c0f19.533:1
Focus outline on raised surfaces#ffffff on #1a1a1a17.403:1
Replay focus outline on a paper stage#912c22 on #fafafa7.783:1
Replay focus outline on a cream stage#912c22 on #f7f3ee7.353:1
Icon buttons (expand, copy, close, clear)#ffffff on #0c0c0f19.533:1
Search field border against the page#6c6c6c on #0c0c0f3.723:1
Stage: paper
Muted text on the stage#6b6b70 on #fafafa5.084.5:1
Muted text on a muted surface#6b6b70 on #f0f0f04.654.5:1
Primary button label#ffffff on #912c228.124.5:1
Destructive button label#ffffff on #b91c1c6.474.5:1
Primary as text#912c22 on #ffffff8.124.5:1
Stage: cream
Muted text on the stage#6b6b70 on #f7f3ee4.804.5:1
Muted text on a muted surface#6b6b70 on #f0f0f04.654.5:1
Primary button label#ffffff on #912c228.124.5:1
Destructive button label#ffffff on #b91c1c6.474.5:1
Primary as text#912c22 on #ffffff8.124.5:1
Stage: void
Muted text on the stage#d1d1d1 on #1a1a1a11.404.5:1
Muted text on a muted surface#d1d1d1 on #25252510.044.5:1
Primary button label#0a0a0a on #f7f3ee17.924.5:1
Primary as text#f7f3ee on #1a1a1a15.754.5:1

This page is rendered from PRINCIPLES.md at build time, so it always matches the document that ships with the package.