Instruction file imported from GuildOfGleks/gleks_web_ui (
.github/instructions/styling.instructions.md). Copyright stays with the author.
@guildofgleks/ui — Styling & Theming
Consistent styling is what makes the library feel like one product across projects.
Every component .scss file follows the rules below. See
gleks-ui-library.instructions.md for the overall authoring guide.
Class naming — BEM, block-prefixed
- Use BEM:
block,block__element,block--modifier. - The block name is always prefixed with
gogand matches the component (gog-btn,gog-spinner). - Examples:
.gog-btn,.gog-btn__content,.gog-btn__content--hidden,.gog-spinner__arc-outer,.gog-spinner__wrap--sm. - Drive modifiers from the template with
[class.gog-btn--lg]="size() === 'lg'"bindings — neverngClass.
Comments in src/styles/ ship to every consumer
Everything under src/styles/ — theme.css, the global stylesheets and the presets — is copied
into the package as written, comments included, and index.css puts the global ones in every app's
bundle. Before 21.15.0 comments were three quarters of that stylesheet's gzipped size. So a comment
there:
- says why, not what — the reason a value is what it is, or why a rule is global, doubled or ordered the way it is. The declaration already says what it does.
- is one to three lines. A longer argument belongs in a
docs/plan or the changelog. - carries no history (versions, "was X until", "found on"), no measurements and no
references to
docs/, which consumers do not have. Those go inCHANGELOG.mdand the plan. - keeps section headers exact (
/* ── Name ───):generate-tokens.mjsgroupsTOKENS.mdby them. Intheme.css's derived block a comment must not contain a brace — the theme-starter generator counts them.
Component .scss comments are stripped from the published bundle, so this rule is about
src/styles/ only.
Theming via CSS custom properties — the three layers
Every themeable value is a CSS custom property named --gog-<block>-*. Where it is
declared is the whole design, so read this before adding one. The authoritative version
of this model lives in the header comment of src/styles/theme.css; this is the short form.
| Layer | Name shape | Declared in | Purpose |
|---|---|---|---|
| 1. Foundation | --gog-accent-color, --gog-space-md, --gog-duration-base |
theme.css |
palette / scale — restyles everything |
| 2. Component | --gog-btn-primary-bg, --gog-select-option-gap |
theme.css |
the actual values, one block per component |
| 3. Instance | --gog-btn-bg, --gog-tag-accent |
nowhere | deliberately undeclared escape hatch |
A component stylesheet declares no --gog-* value of its own — ever. It only reads
tokens, falling through the layers with nested var():
/* button.component.scss — reads only, declares nothing */
.gog-btn {
background-color: var(--gog-btn-bg, var(--gog-btn-variant-bg, var(--gog-btn-primary-bg)));
}
/* variants re-map the *internal* variant tier, never the instance tier */
.gog-btn--outline {
--gog-btn-variant-bg: var(--gog-btn-outline-bg);
}
Why each rule matters:
- Layer 3 must stay undeclared.
--gog-btn-bghas no value anywhere, which is exactly what lets.my-form gog-button { --gog-btn-bg: red }beat.gog-btn--primarywithout a specificity fight or::ng-deep. Declare it once — even as a "harmless default" on the block — and every button on the page is pinned to that value, because the block selector out-cascades nothing but still wins over an inherited custom property. This is the single most load-bearing property of the theming system; do not break it. - No literal fallbacks in component SCSS.
var(--gog-input-float-label-in-top, 8px)puts the real default in a file a theme cannot reach: a consumer can override the token, but nobody can discover that8pxwithout grepping the SCSS, andtheme.cssno longer documents the component's full surface. The fallback chain must bottom out in a token thattheme.cssdeclares, not in a number.scripts/check-tokens.mjsenforces this. - The only nested
var()fallbacks allowed are token-to-token — the instance → variant → component chain above, or a shared token (var(--gog-spinner-color, var(--gog-accent-color))).
Design-token contract
The library references app-level design tokens (palette, type scale, control metrics,
button sizing). These are shipped with working defaults in src/styles/theme.css,
pulled in via src/styles/index.css, which is the one stylesheet a consumer imports.
Rules when touching tokens:
- Every app-level token the components read MUST have a default in
theme.css. A token consumed but never declared makes the whole declaration invalid at computed-value time —background-color: var(--undefined)silently becomestransparent, it does not fall back to the previous value. - Which block in
theme.cssa new token goes in is decided by one question: does its value containvar()? A custom property'svar()references are substituted on the element that declares it, not where it is read — so a derived token declared only on:rootfreezes to the root palette and will not follow a scoped[data-theme]subtree.- value is a literal (
8px,#fae000,ease) → the:rootblock; - value reads another token (
var(--gog-accent-color),color-mix(… var(…))) → the:root, [data-theme]block, so it re-derives per theme scope. Getting this wrong produces a token that works on a full-page theme and silently breaks in the showcase's side-by-side theme lab — a bug class that is very hard to spot locally.
- value is a literal (
- A theme block declares only what that theme changes — the palette, and, since 21.7.0's
character layer (
docs/themes.mditeration 1), corner rounding (--gog-radius), border weight (--gog-control-border-*/--gog-panel-border-*/--gog-border-*) and emphasis casing/tracking (--gog-text-transform,--gog-letter-spacing) where the theme wants a different one — plus spacing, via--gog-density(docs/themes.mditeration 6), the one number every padding and gap in the library derives from. A theme that wants to be tighter or roomier sets that, never a component padding. The derived layer re-resolves from all of them automatically; re-listing component tokens per theme is what makes themes drift apart — seematerial.css/primeng.css/ledger.cssfor what a theme with real character looks like once it uses this layer instead of the roughly ten per-component overrides each one used to need for casing and tracking alone (docs/themes.md, iteration 3's write-up has the exact count). - Document any new app-level token in the README's theming table.
- Do not hardcode brand colors when a token exists.
Panels rendered outside the component subtree
A dropdown opened with [appendToBody] is stamped into <body>, so it inherits no
--gog-* token declared on the component's wrapper block. Every token the panel and its
children read has to be redeclared in the --portal modifier block (see
.gog-select__dropdown--portal / .gog-ms__dropdown--portal). Miss one and that
property silently drops out only in append-to-body mode.
data-theme is exactly the same problem: it can be scoped to any element, not just
:root (the themes showcase page puts it on a plain <article> so several themes render
side by side). A panel appended straight to <body> sits outside that scoped subtree and
would otherwise fall back to whatever theme :root carries. GogDropdownOverlay.attach()
copies data-theme from the trigger's nearest [data-theme] ancestor onto the overlay
host for exactly this reason — any new append-to-body panel must go through that same
overlay helper rather than appending to document.body directly, or it will silently lose
scoped theming.
Scrollable content
Wherever a component's own markup would otherwise need a raw overflow-x/overflow-y: auto|scroll, wrap that content in <gog-scroll> instead. A native scrollbar can't be
themed (no --gog-* tokens reach it), doesn't auto-fade like the rest of this library's
overlay chrome, and would be the one un-restyleable strip of browser chrome inside an
otherwise fully themeable component. gog-dialog's body, gog-select's and
gog-multiselect's dropdown panels, and gog-tooltip's bubble (once its content exceeds
--gog-tooltip-max-height) all do this already — follow that pattern for a new one:
<gog-scroll size="thin" [focusable]="false" overscrollBehavior="contain" class="gog-foo__scroll">
<!-- the content that might overflow -->
</gog-scroll>
.gog-foo__scroll {
/* a max-height (to grow-then-cap) or a fixed height — whichever the component needs */
max-height: var(--gog-foo-max-height);
}
size="thin"for a compact chrome inside a small panel; leave it at'normal'for a larger, primarily-scrollable surface.[focusable]="false"when the parent already owns focus/keyboard handling (a dialog running its own focus trap, a decorative tooltip that can't be tabbed to at all) so this doesn't add a redundant/unreachable tab stop; leave it at its defaulttruefor a scrollable region that has no other focus story of its own.overscrollBehavior="contain"on anything that's an overlay (a panel, a dialog body, a tooltip) so scrolling past its edge doesn't chain into the page behind it.- Give it a
max-height(grows with content, then caps and scrolls), not a fixedheight, unless the component genuinely needs a constant size regardless of content — seescroll.component.scss's own top-of-file comment for why the whole chain from:hostdown uses flex sizing (flex: 1 1 auto+min-height: 0) rather thanheight: 100%to make that capping work at any nesting depth.
This applies to projected/dynamic content too, not just a component's own static markup —
gog-dialog's body wraps its *ngComponentOutlet-rendered content in exactly this pattern.
Encapsulation & scope
- Never write global selectors that leak outside the component. Scope every rule under the
block class or
:host/:host(...). - Use
:host(.gog-host--full-width)style host-state selectors instead of::ng-deep. - Avoid
::ng-deep; if a child must be themed, expose a--gog-*custom property instead. - Use
contain: layout styleon self-contained blocks to limit reflow scope.
Geometry and typography are computed, not chosen
Nobody on this project is a designer, and the library does not pretend otherwise: every length
it ships is derived from a rule that can be checked, not picked because it looked right. That is
a strength rather than an apology — a value chosen by eye is unfalsifiable and drifts component by
component, which is exactly how the library ended up with 177 hard-coded paddings in two units
before --gog-density existed.
Five laws govern any length a component declares, and all five are enforced by CI as of
21.11.0 — npm run check:geometry runs four scripts over every component, from the token values
and the stylesheets rather than from a rendered page. A new component satisfies all five before
it is done, and an existing one that violates one is a defect, not a style.
- The grid is 4px. Every padding, gap, margin, offset and inset reads a step of the spacing
scale, never a literal —
check-tokensrule H already fails the build on a literal that restates a step — and every step of that scale is a multiple of 4:--gog-space-4…--gog-space-48, nine steps. The five 2px-granular steps (-2,-6,-10,-14,-18) were removed once their 102 readers moved; a new one is not added back without changing this rule first. Focus rings are not spacing:--gog-focus-ring-width/-offsetare their own foundation tokens and a 2px ring offset is unaffected by this. - Concentric radii. A radius nested inside another is the outer radius minus the padding
between them — an inner corner that repeats its parent's radius reads as a mistake at every
size, and one that ignores it reads as a different component. Anything sitting inside a
rounded box derives its radius from the two tokens involved; it does not restate either.
Restating is not a style preference: the radius is a plain length and the padding is
calc(Npx * var(--gog-density)), so a hardcoded subtrahend is only correct at density 1. Two things the check taught that the rule did not say. A child that never reaches its parent's corner — a cell in a grid, a button centred in a field — has no concentric relationship at all, and forcing one squares off things that should stay round;check-radiiholds those inNOT_CONCENTRIC, each with its reason. And a square corner is a real answer: where the inset equals the parent's radius, the inner corner point sits on the centre of the outer arc, so a right angle is equidistant from every point of that curve — the only shape that keeps the gap constant, not a value clamped to zero. - Optical ratio: horizontal padding is exactly twice vertical. The same multiple at every
size step, on every control. 2.0 is not a taste, it is the arithmetic: with both paddings
on the 4px grid, 2.0 and 1.0 are the only ratios reachable at all five steps, so any other
value would need an exception at
xsmfor every block in the library. A surface is out of this law and says so in its own stylesheet —card,panel,dialog,toast,tooltip, the accordion body and the table cell frame content rather than balancing a label. - The typographic ratio. Line-height is a function of font size and role, not a per-component
choice: text that wraps takes the relaxed end of
--gog-line-height-*, a single-line label the tight end, and the ratio moves inversely with size — a 24px heading does not want 1.5. - The target grows its hit area, not its paint. Anything a pointer activates meets WCAG
2.5.8's 24×24 CSS px at every size the component offers — and where the painted control is
smaller, a transparent
::beforeinflates the hit area to 24×24 (44×44, 2.5.5 AAA, where a thumb is expected) rather than the design getting bigger. That is the difference between "the target got easier" and "xsmstopped beingxsm". Spacing exemption only where even that is impossible, stated in the component's own stylesheet rather than assumed. Measured at--gog-density: 1; a compact theme is the consumer's decision and does not license shipping a 22px button.
Where a law and a measurement disagree, the measurement wins and the law gets an exception with
a reason — the same discipline check:contrast's exceptions already follow. What is not
acceptable is a length with no derivation at all.
A glyph is governed too, by a law of its own. docs/component-geometry.md's L7: an icon
centres its ink inside its own viewBox, so that centring the box centres the mark — and where the
mark is filled, it is the area that centres, not the outline, because a solid triangle's
centroid sits W/6 from the middle of its bounding box. check:geometry's second half measures
all 41 built-in glyphs and gates it. The law is deliberately not written against the ink's
centre of mass: a monoline set reads by extent, so a directional glyph such as arrow-right
carries its mass 2 units off centre and is correct exactly as drawn.
A box that holds a glyph is never smaller than the glyph, and the way to guarantee that is to
size both from one declaration. <gog-icon> draws its <svg> at --gog-icon-size (1.2em) of
its own font-size, so an element that sets its own square box and contains an icon has two
sizes to keep in agreement. Put the font-size and the box on the same element and let the box
read --gog-icon-size:
.gog-<block > __<mark > {
/* the basis the mark is drawn from … */
font-size: calc(var(--gog-<block>-<thing>-font-size) * var(--gog-<block>-<thing>-ratio));
/* … and the box, from the same declaration the icon reads */
width: var(--gog-icon-size, var(--gog-icon-fallback-size));
height: var(--gog-icon-size, var(--gog-icon-fallback-size));
}
A box that is deliberately roomier than its mark — a checkbox, a calendar's nav button — states
its own size and is outside this; the rule is one-directional. Nothing painted the overflow in
any of the three cases that had it, so nothing showed it. What it cost was the focus indicator,
which :focus-visible draws on the box: a ring smaller than the mark it indicates, clearing it
only by whatever --gog-focus-ring-offset happened to be.
Two traps, both paid for twice. A relative unit resolves against the element carrying the
property, not the element the value was written for — so an em box on the parent and a
font-size that arrives through a token chain are not the same number, and at lg/slg they were
5% to 25% apart. That is D7's ch finding in a third component. And a ratio token multiplied on
top of --gog-icon-size does not mean what its name says: --gog-select-chevron-icon-ratio: 0.875 rendered a chevron at 1.05 of the field's type, not 0.875 of it.
npm run check:glyph-box gates this (21.13.0), and it is the only check here that measures a
rendering rather than reading source — for the reason the first trap above gives: an em cannot
be resolved honestly without knowing which element it landed on, and the attempt that guessed
produced a fix 25% worse at slg. It serves the prerendered ui-showcase, walks all 46 routes in
Playwright and compares every <gog-icon>'s <svg> against the element holding it. Needs
npm run build:showcase first.
Two things it settled that the prose above had not. The border box is the box — the first
version compared against the content box and reported gog-checkbox, whose 12px tick spans its own
2px outline, which is what a checkbox looks like. And it found two more instances the three
above had missed: gog-table's sort icon (9% over, the fourth instance of the relative-unit
trap) and gog-textarea's clear mark (20% over, and the one control whose ratio is deliberately
1, which is what made it the largest).
It sees only what the showcase renders — an icon in a state no page reaches is not measured, which is a reason the showcase's coverage matters beyond documentation.
Accessibility & motion
- Provide a visible
:focus-visibleoutline for every interactive element. - Reduced motion removes the animation, not the information. Under
@media (prefers-reduced-motion: reduce)every animation MUST be disabled — but if the animation was the only thing telling the reader something, what remains has to say it another way. Two instances so far, both shipped broken: a toast whose countdown was the progress bar's slide (fixed in 21.7.1 withsteps(20, end)— twenty jumps still report the time), and a button whose entire press feedback wastransform: scale(), which this media query switched off, so a reader with animations off pressed a button and nothing happened at all (21.9.0). A state change is not an animation: it survives this query, and with transitions off it simply lands on the first frame. - A press must be a state, and the ripple does not count. It is off by default and
suppressed under reduced motion, deliberately, because it genuinely is decoration. Anything a
reader can press paints
--gog-<block>-press-bg— a step past that surface's own hover, in the same ingredient.gogCollapsibleTriggeris the one exception, and the reason is the rule: the library paints nothing on that element in any state, because the consumer owns it. - Ensure color choices meet WCAG AA contrast in every shipped theme, not only light and dark
—
npm run check:contrastis the gate, and it is a CI step. It runs in three passes: the palette pairs, a curated table of composited states, and a mechanical sweep of every label/ground pair the compiled stylesheets themselves state. You do not add pairs by hand for a new component; the sweep finds them. You do add a curated entry when a state sits on a ground the sweep cannot infer (a menu item over--gog-menu-bg, a ghost button over the page or a card).
The colour rule that keeps being learned the hard way
An accent-coloured label may not sit on an accent-tinted ground. Tinting a surface with the
accent walks it toward any label already using the accent, and the contrast collapses — worst in
light themes, where --gog-accent-color as text is barely AA to begin with and has no headroom
to spend on a ground.
Four components shipped this exact bug and every one was found by measurement rather than by eye:
gog-button's outline hover label, which failed AA in all 11 themes (1.11:1 at worst); the
same button's ghost hover; gog-accordion's header, which turned accent on a strip tinted with
--gog-accent-dim; and gog-autocomplete's selected option. gog-select escaped only because
its selected option has no tint behind it.
So: a tinted state takes --gog-text-color (or the block's own text token), and the lift is
carried by the background alone. A filled state is the other valid answer — fill with
--gog-accent-dim and label it --gog-accent-text-color, which is the pair the check already
gates. Do not reach for a weaker tint: half-strength washes, neutral washes and text scrims were
all measured across the 11 themes, and in light every one of them still fails.
Icons are 3:1, not 4.5:1 (WCAG 1.4.11 against 1.4.3). A chevron or a spinner glyph held to
the text bar would be "fixed" into near-black for no reason — NON_TEXT_ELEMENTS in
check-contrast.mjs is that list, and anything not on it is treated as text.