Files
2026-08-28 14:09:58 -07:00

455 lines
15 KiB
CSS

/* @luxfi/design/roles.css — the shared ROLE layer.
*
* Hand-authored, and a pure consumer of the token layer: every colour, size,
* distance and duration below is a var() off tokens/*.css, so the knobs a
* reader turns through the appearance controller — --type-scale, --type-ratio,
* --density — reach every role here for free.
*
* OPT-IN. It is not part of styles.css and nothing imports it implicitly.
* A surface takes it deliberately:
*
* @layer base, roles;
* @import '@luxfi/design/roles.css' layer(roles);
*
* These are the roles no component owns. Anything with behaviour — a tab, a
* disclosure, an input, a spinner — is a component and is not here;
* what is here is pure presentation, which is why it can be a class.
*
* base.css already grants the focus ring, the transition and the reduced-motion
* zeroing on :where(a,button,…). Roles restate none of it.
*
* TWO KINDS OF LITERAL are allowed, and only two, because neither has a token
* that could carry it:
* - an ACCESSIBILITY FLOOR — 44px for a thumb, 24px for a pointer, the 6px
* mark drawn inside one. A floor is a physical measurement of a hand; it
* must not move when a reader retunes their type or density.
* - a STRUCTURAL constant — the 1px hairline, a breakpoint (a media query
* cannot read a custom property), and the unitless line-height ratios,
* written as the fractions they are so the relation survives a retune.
* Every other value is a token.
*/
@layer roles {
/* ── type ──────────────────────────────────────────────────────────────
Structure is the component library's H1/Paragraph; the ROLE — which rung, which ink,
which tracking — is here, said once instead of at every call site. */
/* The page heading. Three rungs of one ramp, so retuning --type-scale moves
all three together and none can drift out of relation with the others. */
.display {
font: var(--type-hero);
font-size: var(--text-4xl);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
text-wrap: balance;
}
@media (min-width: 640px) {
.display {
font-size: var(--text-5xl);
}
}
@media (min-width: 1024px) {
.display {
font-size: var(--text-6xl);
}
}
/* The section heading — the rung below the display. Two rungs, and it stops
there: a section heading is deliberately not a page heading and must not
grow into one at 1024. The leading is stated because it is NOT the
display's; --type-hero sets 1.05 and these render at their own ratios. */
.title {
font: var(--type-hero);
font-size: var(--text-4xl);
line-height: calc(2.5 / 2.25);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
text-wrap: balance;
}
/* One rung quieter, for a heading under another section's. Same role, same
face, same tracking — only where it stands on the ramp moves, so it is an
attribute rather than a second class to keep in agreement with this one. */
.title[data-quiet] {
font-size: var(--text-3xl);
line-height: 1.2;
}
@media (min-width: 640px) {
.title {
font-size: var(--text-5xl);
line-height: 1;
}
.title[data-quiet] {
font-size: var(--text-4xl);
line-height: calc(2.5 / 2.25);
}
}
/* The two lines that travel with a heading: the small caps label above one,
and the paragraph under it. */
.eyebrow {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: calc(1.25 / 0.875);
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--neutral-400);
}
.lede {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--neutral-400);
}
/* ── controls ──────────────────────────────────────────────────────────
Three members of one set: the button, the outline, and the quiet link.
A caller sets a STATE on the shape rather than choosing between two
classes that would have to be kept in agreement. */
/* The filled weight drops the border outright instead of making it
transparent — a transparent border is still a border, and it would make
the loudest control on the page 2px wider than the quiet one beside it. */
.action {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
padding-inline: 28px;
border: 1px solid var(--neutral-700);
border-radius: var(--radius-full);
font-size: var(--text-sm);
/* A rung carries its own leading. Naming the size alone leaves the line box
to whatever an ancestor sets, which is how one control ends up a pixel
and a half taller than the one beside it. */
line-height: calc(1.25 / 0.875);
font-weight: var(--weight-medium);
color: var(--pure-white);
text-decoration: none;
transition: border-color var(--duration-fast) var(--ease-in-out);
}
.action:hover {
border-color: var(--neutral-400);
}
.action[data-fill] {
border: 0;
background-color: var(--pure-white);
color: var(--pure-black);
transition: opacity var(--duration-fast) var(--ease-in-out);
}
.action[data-fill]:hover {
opacity: 0.9;
}
/* A word in an outline. Not an action — it carries no fill and no weight —
so the padding is the caller's, because a chip and a badge are the same
outline at different sizes. It brightens on approach only as a link. */
.pill {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: 1px solid var(--neutral-800);
border-radius: var(--radius-full);
font-size: var(--text-sm);
line-height: calc(1.25 / 0.875);
color: var(--neutral-300);
text-decoration: none;
}
a.pill {
transition: border-color var(--duration-fast) var(--ease-in-out);
}
a.pill:hover {
border-color: var(--neutral-600);
}
/* The quiet link — "View all →", "See full pricing ↗". The third member of
the set, and the hover is the reason it is a rule rather than props. */
.more {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
line-height: calc(1.25 / 0.875);
font-weight: var(--weight-medium);
color: var(--neutral-300);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-in-out);
}
.more:hover {
color: var(--pure-white);
text-decoration: none;
}
/* ── surfaces ──────────────────────────────────────────────────────────
Card has no lift/ground API, so these are roles rather than components. */
/* A card. The defaults change nothing: no ground, and a hover ground that is
whatever the resting one is, so a card naming only --leaf-ground gets a
ground and no lift. The rise is `translate` rather than `transform` on
purpose — an animation library owns `transform` on most of these, and the
two properties compose instead of replacing each other. */
.leaf {
--leaf-pad: var(--space-4);
--leaf-edge: transparent;
--leaf-edge-hover: var(--white-15);
--leaf-lift: var(--pure-white);
--leaf-ground: transparent;
--leaf-ground-hover: var(--leaf-ground);
--leaf-rise: 0;
display: block;
height: 100%;
padding: var(--leaf-pad);
border: 1px solid var(--leaf-edge);
border-radius: var(--radius-xl);
background-color: var(--leaf-ground);
text-decoration: none;
transition:
border-color var(--duration-fast) var(--ease-in-out),
background-color var(--duration-fast) var(--ease-in-out),
translate var(--duration-fast) var(--ease-in-out);
}
.leaf:hover {
border-color: var(--leaf-edge-hover);
background-color: var(--leaf-ground-hover);
translate: 0 var(--leaf-rise);
}
.leaf [data-lift] {
transition: color var(--duration-fast) var(--ease-in-out);
}
.leaf:hover [data-lift] {
color: var(--leaf-lift);
}
/* A surface whose ground answers approach. IT ADDS NO GEOMETRY — no edge and
no corner — because it was born as the cell of a ruled grid, which rules
its own lines with a 1px gap over a neutral ground. Giving it .leaf instead
would put a corner on every tile and break the flush rule the grid is made
of. The ink is the same shape as the ground, and [data-lift] is the ink of
what is INSIDE, because a card whose own ink holds while a name and an
arrow inside it brighten is most of a page's cards. */
.cell {
--cell-lift: var(--pure-white);
--cell-ground: var(--pure-black);
--cell-ground-hover: var(--neutral-950);
--cell-ink: inherit;
--cell-ink-hover: var(--cell-ink);
background-color: var(--cell-ground);
color: var(--cell-ink);
transition:
background-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
}
.cell:hover {
background-color: var(--cell-ground-hover);
color: var(--cell-ink-hover);
}
.cell [data-lift] {
transition: color var(--duration-fast) var(--ease-in-out);
}
.cell:hover [data-lift] {
color: var(--cell-lift);
}
/* ── marks on approach ─────────────────────────────────────────────────
[data-lift] above is one of three. [data-show] is a line only OFFERED on
approach; [data-step] is a mark that steps toward where it goes.
NO ROLE ON THE PARENT, and that is the difference from [data-lift]. Ink is
the card's decision, so a card names it; presence and position are the
mark's, and the thing being approached is either the LINK the mark is
inside or whatever it sits directly in. Two selectors answer both.
THE RESTING HALF IS IN THE RULE, and that is the whole reason these exist:
an inline `opacity: 0` out-ranks the :hover that has to undo it, so a
reveal written as a style attribute beside a utility never reveals.
[data-step] names only the step. The transition rides the call site,
because these marks change ink AND position and a rule here would REPLACE
the `.leaf [data-lift]` transition rather than add to it. */
[data-show] {
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-in-out);
}
a:hover [data-show],
:hover > [data-show] {
opacity: 1;
}
a:hover [data-step],
:hover > [data-step] {
translate: 2px 0;
}
/* ── the rest ──────────────────────────────────────────────────────────── */
/* Two controls that close a section: stacked on a phone, side by side from
40rem. GRID, not flex — a row of two is a track list, and
`grid-auto-flow: column` is the direction said once, while the column case
needs nothing said at all because a grid is already one. The breakpoint is
spelt out because a media query cannot read a custom property. */
.pair {
display: grid;
gap: var(--space-4);
justify-items: center;
justify-content: center;
align-items: center;
}
@media (min-width: 40rem) {
.pair {
grid-auto-flow: column;
}
}
/* A run of dots, one of them current. The DOT is drawn inside the target
rather than being it: a 6px dot sized as the control would obey the
pointer floor by stretching into a 6x24 bar. Sized as a target with a dot
in it, both are right, and the WIDTH follows the same pointer question the
height already asks. */
.dot {
--dot-rest: var(--white-22);
--dot-hover: var(--white-60);
--dot-on: var(--pure-white);
display: grid;
place-items: center;
height: 24px;
width: 24px;
border: 0;
background: none;
border-radius: var(--radius-full);
cursor: pointer;
}
.dot > * {
height: 6px;
width: 6px;
border-radius: var(--radius-full);
background-color: var(--dot-rest);
transition: background-color var(--duration-fast) var(--ease-in-out);
}
.dot:hover > * {
background-color: var(--dot-hover);
}
.dot[data-on] > * {
background-color: var(--dot-on);
}
@media (pointer: coarse) {
.dot {
width: 44px;
}
}
/* A horizontally-scrolling row that does not draw its scrollbar. The rule
describes what the scroller has on it rather than which part is missing. */
.bare {
scrollbar-width: none;
}
.bare::-webkit-scrollbar {
display: none;
}
/* The skip link — out of the page until a keyboard reaches it, then the first
control on it. It is NOT `display: none` and never can be: a hidden skip
link is one a keyboard cannot focus, so the whole affordance goes with it.
A 1px box clipped to nothing is the construction that stays reachable. */
.skip {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
.skip:focus {
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip-path: none;
white-space: normal;
top: var(--space-4);
left: var(--space-4);
z-index: var(--z-dropdown);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--neutral-700);
border-radius: var(--radius-full);
background-color: var(--pure-black);
font-size: var(--text-sm);
line-height: calc(1.25 / 0.875);
color: var(--pure-white);
}
/* `.tap` says "this is a standalone tap target". A link in a paragraph is not
one — stretching it to 44px would tear the line it sits in — so the floor
cannot name every a[href], and a link that IS a control has to say so.
A TAP TARGET IS A BOX, and the floor applies to the SMALLER SIDE. Height
alone is half a policy: an icon-only control has no text to widen it, so it
stays as narrow as its glyph however tall the rule makes it. The box is
unconditional; only the thumb-sized minimum tracks the pointer.
`!important` IS LOAD-BEARING and is not a shortcut. A component library writes a
control's size INLINE, and an inline declaration outranks a stylesheet rule
on the same property at any specificity — so without it this rule is a
silent no-op on exactly the controls it exists for. */
.tap {
display: inline-flex;
align-items: center;
}
@media (pointer: coarse) {
.tap {
min-height: 44px !important;
min-width: 44px !important;
}
}
/* Said once here, where the transitions are declared. Every call site used to
carry its own motion-reduce rule beside every transition — copies of one
policy, and one of them had been forgotten. */
@media (prefers-reduced-motion: reduce) {
.action,
.action[data-fill],
a.pill,
.more,
.leaf,
.leaf [data-lift],
.cell,
.cell [data-lift],
.dot > *,
[data-show] {
transition: none;
}
.leaf:hover,
a:hover [data-step],
:hover > [data-step] {
translate: none;
}
}
}