Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/design-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ Token sheets ship with the package (`./tokens` export):
- **Dark mode**: `[data-theme="dark"]` on `<html>` (falls back to `prefers-color-scheme`).
- **Runtime theming per tenant**: `TenantThemeService` in `@fireflyframework/core` overrides the same tokens at runtime — cascade: defaults → tenant → dark → tenant-dark.

### Overriding a component token by context

A component's **base** tokens (e.g. `--ff-select-min-width`, `--ff-panel-bg`) are *consumed* with a fallback and never *declared* by the component itself:

```scss
// ff-select.component.scss
.ff-select {
min-width: var(--ff-select-min-width, 180px);
}
```

This is deliberate: a custom property specified on an element always wins over one inherited from an ancestor. If the component declared `--ff-select-min-width: 180px;` on `.ff-select` itself, that declaration would always be the specified value for every `ff-select`, and no surrounding container could ever narrow or widen it — the ancestor's value would be inherited but immediately shadowed. Skipping the local declaration and only ever reading the token through `var(--token, <default>)` leaves the property open for any ancestor to set, while the fallback keeps the original look when nobody does.

To retint or resize a component from a specific context, set its token on a container that wraps it:

```scss
// A pagination footer that needs its page-size select to shrink below the
// component's own 180px floor, without touching ff-select itself.
.ff-list__page-size {
--ff-select-min-width: 0;
width: 6rem;
}
```

```html
<div class="ff-list__page-size">
<ff-select [options]="pageSizeOptions" ... />
</div>
```

The same technique works for any other base token (`--ff-panel-bg`, `--ff-button-radius`, `--ff-input-border`, …): declare the token on a wrapping element, not on the component's own class.

**Variant tokens are the exception.** A variant modifier picks the final value for that variant on the element itself (`.ff-panel--warning { --ff-panel-accent: var(--ff-color-warning-500); }`), so an ancestor cannot override it — the element's own declaration wins. Retint a variant through the palette instead (`--ff-color-warning-500` on `:root` or a theme scope), which is what the variant resolves from.

## Living catalog

The monorepo's `playground` app is the catalog: every component with its real variants, a foundations page rendering the token scales, and a theming page with dark toggle + token inspector.
Expand Down
193 changes: 193 additions & 0 deletions packages/design-system/src/lib/overridable-tokens.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import 'zone.js';
import 'zone.js/testing';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { FfSelectComponent } from './primitives/ff-select/ff-select.component';

TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
teardown: { destroyAfterEach: true },
});

/**
* Walks a directory tree and returns the absolute paths of every
* `*.component.scss` file found underneath it.
*/
function collectComponentStylesheets(rootDir: string): string[] {
const result: string[] = [];
for (const entry of readdirSync(rootDir)) {
const fullPath = join(rootDir, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
result.push(...collectComponentStylesheets(fullPath));
} else if (entry.endsWith('.component.scss')) {
result.push(fullPath);
}
}
return result;
}

/**
* Splits a SCSS source string into the list of "own" declaration bodies of
* every brace-delimited rule, i.e. the text that sits directly inside each
* `{ ... }` excluding the text that belongs to further-nested rules. SCSS
* interpolations (`#{...}`) are blanked out first so their braces never
* confuse the same brace-depth bookkeeping used for real rule blocks.
*/
function ownRuleBodies(scss: string): string[] {
const withoutInterpolation = scss.replace(/#\{[^}]*\}/g, '__I__');
const bodies: string[] = [];
const stack: string[] = [''];

for (const char of withoutInterpolation) {
if (char === '{') {
stack.push('');
} else if (char === '}') {
const own = stack.pop();
if (own !== undefined) {
bodies.push(own);
}
} else {
stack[stack.length - 1] += char;
}
}

return bodies;
}

/** Strips `//` line comments so prose mentioning a token name cannot be mistaken for code. */
function withoutLineComments(text: string): string {
return text
.split('\n')
.map((line) => line.replace(/\/\/.*$/, ''))
.join('\n');
}

/**
* Custom properties declared as a statement (`--ff-foo: ...;`) directly inside
* a rule body. Consumption (`var(--ff-foo, ...)`) is never followed by a
* colon — only `,`/`)` — so matching `--ff-foo:` unambiguously finds
* declarations regardless of what precedes them (start of block, a `;`, or a
* comment line).
*/
function declaredTokens(ruleBody: string): Set<string> {
const matches = withoutLineComments(ruleBody).matchAll(/(--ff-[\w-]+)\s*:/g);
return new Set(Array.from(matches, (m) => m[1]));
}

/** Custom properties read via `var(--ff-foo, ...)` directly inside a rule body. */
function consumedTokens(ruleBody: string): Set<string> {
const matches = withoutLineComments(ruleBody).matchAll(/var\(\s*(--ff-[\w-]+)/g);
return new Set(Array.from(matches, (m) => m[1]));
}

const libDir = join(dirname(fileURLToPath(import.meta.url)));
const stylesheets = collectComponentStylesheets(libDir);

describe('design-system component tokens are overridable by an ancestor', () => {
it('finds component stylesheets to check (sanity guard against a broken glob)', () => {
expect(stylesheets.length).toBeGreaterThan(20);
});

it.each(stylesheets)(
'never declares and consumes the same --ff-* token in the same rule (%s)',
(path) => {
// A custom property specified on an element always wins over one
// inherited from an ancestor. The moment a rule both declares
// `--ff-x` and reads `var(--ff-x)`, that rule's own declaration is
// guaranteed to be the specified value for `--ff-x` on every element
// it matches, so an ancestor supplying `--ff-x` can never take
// effect. Declaring a token in one rule (e.g. a `--variant` modifier
// picking its final color) and consuming it in a *different* rule is
// fine — this only forbids the self-shadowing pattern.
const scss = readFileSync(path, 'utf8');
const offendingRules = ownRuleBodies(scss)
.map((body) => {
const declared = declaredTokens(body);
const consumed = consumedTokens(body);
const overlap = Array.from(declared).filter((token) => consumed.has(token));
return { body, overlap };
})
.filter(({ overlap }) => overlap.length > 0);

expect(offendingRules).toEqual([]);
},
);
});

/**
* Behavioural half of the contract above. `getComputedStyle` in the jsdom
* environment used by this Vitest suite does not implement CSS Custom
* Properties at all: it neither inherits `--foo` declarations down to
* descendants nor substitutes `var(--foo, fallback)` in longhand
* properties (verified against this exact scenario before writing this
* spec — the computed value comes back as the literal, unresolved
* `"var(--foo, fallback)"` string, and an inherited custom property reads
* back as `""` on the child). Asserting on `getComputedStyle` here would
* therefore pass or fail for reasons unrelated to the actual cascade, i.e.
* it would not test anything. A real assertion on the final resolved value
* needs a real browser engine (a Playwright/Storybook interaction test),
* which is a larger addition than this fix and is left as a follow-up.
*
* What *is* reliable in jsdom, and is exercised here, is the DOM wiring:
* that the ancestor really is an ancestor of the component's native
* element, and that the ancestor really carries the overriding
* declaration. Combined with the static proof above (the component only
* ever reads this token through `var(--ff-select-min-width, ...)` and
* never re-declares it), the CSS cascade — which is a browser guarantee,
* not application behaviour — is what makes the override effective.
*/
describe('an ancestor can widen/narrow a component through its token', () => {
@Component({
standalone: true,
imports: [FfSelectComponent],
template: `
<div class="narrow-container" style="--ff-select-min-width: 64px">
<ff-select [options]="[]" />
</div>
`,
})
class NarrowingHostComponent {}

let fixture: ComponentFixture<NarrowingHostComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NarrowingHostComponent],
}).compileComponents();

fixture = TestBed.createComponent(NarrowingHostComponent);
fixture.detectChanges();
});

it('renders the select as a descendant of the ancestor that declares the override', () => {
const host = fixture.nativeElement as HTMLElement;
const container = host.querySelector('.narrow-container') as HTMLElement;
const select = host.querySelector('ff-select') as HTMLElement;

expect(container).toBeTruthy();
expect(select).toBeTruthy();
expect(container.contains(select)).toBe(true);
});

it('exposes the overriding token on the ancestor, ready to be inherited', () => {
const host = fixture.nativeElement as HTMLElement;
const container = host.querySelector('.narrow-container') as HTMLElement;

expect(container.style.getPropertyValue('--ff-select-min-width').trim()).toBe('64px');
});

it('confirms the select never re-declares --ff-select-min-width, so the ancestor value is free to cascade', () => {
const scssPath = join(libDir, 'primitives', 'ff-select', 'ff-select.component.scss');
const scss = readFileSync(scssPath, 'utf8');

expect(scss).toMatch(/min-width:\s*var\(--ff-select-min-width,\s*180px\)/);
expect(scss).not.toMatch(/^\s*--ff-select-min-width\s*:/m);
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
.ff-avatar {
// Component tokens
--ff-avatar-bg: var(--ff-color-primary-100);
--ff-avatar-color: var(--ff-color-primary-700);

// Component tokens are consumed with a fallback, never declared here: a
// declaration on this same selector would always beat a value inherited
// from an ancestor, so a container could never retint an avatar.
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--ff-radius-full);
background-color: var(--ff-avatar-bg);
color: var(--ff-avatar-color);
background-color: var(--ff-avatar-bg, var(--ff-color-primary-100));
color: var(--ff-avatar-color, var(--ff-color-primary-700));
overflow: hidden;
flex-shrink: 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
.ff-banner {
--ff-banner-bg: var(--ff-color-info-50);
--ff-banner-border: var(--ff-color-info-200);
--ff-banner-text: var(--ff-color-neutral-900);
--ff-banner-icon-color: var(--ff-color-info-600);
--ff-banner-action-color: var(--ff-color-info-700);

// Component tokens are consumed with a fallback, never declared here: a
// declaration on this same selector would always beat a value inherited
// from an ancestor. The host always renders a `ff-banner--<type>` variant
// class alongside this one, so `--ff-banner-bg`/`-border`/`-icon-color`/
// `-action-color` are provided per variant below (the variant is the
// semantic decision, so those declarations are the final value on
// purpose and are not meant to be retinted from outside); the info
// values here are only the literal fallback used before any variant
// declaration reaches the element. `--ff-banner-text` has no per-variant
// override, so it is a plain reserved-default fallback.
display: flex;
align-items: center;
gap: var(--ff-spacing-sm);
padding: var(--ff-spacing-sm) var(--ff-spacing-md);
background-color: var(--ff-banner-bg);
border: 1px solid var(--ff-banner-border);
background-color: var(--ff-banner-bg, var(--ff-color-info-50));
border: 1px solid var(--ff-banner-border, var(--ff-color-info-200));
font-family: var(--ff-font-family);
font-size: var(--ff-font-size-sm);
color: var(--ff-banner-text);
color: var(--ff-banner-text, var(--ff-color-neutral-900));
width: 100%;

// Variant overrides
Expand Down Expand Up @@ -47,7 +51,7 @@

&__icon {
flex-shrink: 0;
color: var(--ff-banner-icon-color);
color: var(--ff-banner-icon-color, var(--ff-color-info-600));
font-size: var(--ff-font-size-md);
}

Expand All @@ -66,7 +70,7 @@
&__action {
border: none;
background: none;
color: var(--ff-banner-action-color);
color: var(--ff-banner-action-color, var(--ff-color-info-700));
font-family: var(--ff-font-family);
font-size: var(--ff-font-size-sm);
font-weight: var(--ff-font-weight-semibold);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
.ff-bottom-sheet {
--ff-bottom-sheet-bg: var(--ff-color-surface);
--ff-bottom-sheet-border: var(--ff-color-neutral-200);
--ff-bottom-sheet-title-color: var(--ff-color-neutral-900);
--ff-bottom-sheet-body-color: var(--ff-color-neutral-700);
--ff-bottom-sheet-backdrop: rgba(0, 0, 0, 0.4);

// Component tokens are consumed with a fallback, never declared here: a
// declaration on this same selector would always beat a value inherited
// from an ancestor, so a container could never restyle a bottom sheet.
display: contents;

&__backdrop {
Expand All @@ -14,7 +11,7 @@
display: flex;
align-items: flex-end;
justify-content: center;
background-color: var(--ff-bottom-sheet-backdrop);
background-color: var(--ff-bottom-sheet-backdrop, rgba(0, 0, 0, 0.4));
}

&__panel {
Expand All @@ -23,7 +20,7 @@
width: 100%;
max-width: 640px;
max-height: 85vh;
background-color: var(--ff-bottom-sheet-bg);
background-color: var(--ff-bottom-sheet-bg, var(--ff-color-surface));
border-radius: var(--ff-radius-lg) var(--ff-radius-lg) 0 0;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.15);
font-family: var(--ff-font-family);
Expand Down Expand Up @@ -51,14 +48,14 @@
align-items: center;
justify-content: space-between;
padding: var(--ff-spacing-md) var(--ff-spacing-lg);
border-bottom: 1px solid var(--ff-bottom-sheet-border);
border-bottom: 1px solid var(--ff-bottom-sheet-border, var(--ff-color-neutral-200));
}

&__title {
margin: 0;
font-size: var(--ff-font-size-lg);
font-weight: var(--ff-font-weight-semibold);
color: var(--ff-bottom-sheet-title-color);
color: var(--ff-bottom-sheet-title-color, var(--ff-color-neutral-900));
}

&__close {
Expand Down Expand Up @@ -90,7 +87,7 @@
&__body {
padding: var(--ff-spacing-lg);
font-size: var(--ff-font-size-md);
color: var(--ff-bottom-sheet-body-color);
color: var(--ff-bottom-sheet-body-color, var(--ff-color-neutral-700));
flex: 1;
overflow-y: auto;
}
Expand All @@ -101,7 +98,7 @@

&__footer {
padding: var(--ff-spacing-md) var(--ff-spacing-lg);
border-top: 1px solid var(--ff-bottom-sheet-border);
border-top: 1px solid var(--ff-bottom-sheet-border, var(--ff-color-neutral-200));
display: flex;
justify-content: flex-end;
gap: var(--ff-spacing-sm);
Expand Down
Loading