@tfrc/foundation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRACT.md +58 -0
- package/README.md +28 -0
- package/bin/tfrc-verify.js +351 -0
- package/package.json +30 -0
- package/src/base.css +47 -0
- package/src/index.css +8 -0
- package/src/palettes/daylight.css +46 -0
- package/src/palettes/lilac.css +50 -0
- package/src/palettes/meadow.css +51 -0
- package/src/palettes/meridian.css +69 -0
package/CONTRACT.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Token contract
|
|
2
|
+
|
|
3
|
+
Any package that defines a semantic layer (`@tfrc/marketing`, `@tfrc/product`, or any
|
|
4
|
+
future sibling) MUST define every token below in its `tokens.css`.
|
|
5
|
+
Components reference these names only — never a `--ramp-*` value directly,
|
|
6
|
+
and never a raw color literal. See `AGENTS.md` rule 2.
|
|
7
|
+
|
|
8
|
+
`scripts/check-contract.js` verifies this mechanically for every file it's
|
|
9
|
+
pointed at. Run it before merging any new or edited `tokens.css`.
|
|
10
|
+
|
|
11
|
+
The other side of the contract — that a *consumer* uses only these names and
|
|
12
|
+
never a raw value — is checked by `tfrc-verify`, which ships as an
|
|
13
|
+
executable in this package and runs inside the consumer's own repo. See
|
|
14
|
+
`CONSUMING.md` and `decisions/0009.md`.
|
|
15
|
+
|
|
16
|
+
## Surfaces
|
|
17
|
+
- `--color-surface`
|
|
18
|
+
- `--color-surface-raised`
|
|
19
|
+
- `--color-surface-sunken`
|
|
20
|
+
|
|
21
|
+
## Borders
|
|
22
|
+
- `--color-border-subtle`
|
|
23
|
+
- `--color-border-strong`
|
|
24
|
+
|
|
25
|
+
## Text
|
|
26
|
+
- `--color-text-primary`
|
|
27
|
+
- `--color-text-secondary`
|
|
28
|
+
- `--color-text-muted`
|
|
29
|
+
|
|
30
|
+
## Action
|
|
31
|
+
- `--color-accent`
|
|
32
|
+
- `--color-accent-hover`
|
|
33
|
+
- `--color-accent-fg`
|
|
34
|
+
- `--color-accent-bg`
|
|
35
|
+
- `--color-accent-bg-fg`
|
|
36
|
+
|
|
37
|
+
## System
|
|
38
|
+
- `--color-focus`
|
|
39
|
+
|
|
40
|
+
## Status
|
|
41
|
+
- `--color-info-bg`, `--color-info-fg`, `--color-info-line`
|
|
42
|
+
- `--color-danger-bg`, `--color-danger-fg`, `--color-danger-line`, `--color-danger-solid`
|
|
43
|
+
- `--color-success-bg`, `--color-success-fg`, `--color-success-line`
|
|
44
|
+
|
|
45
|
+
## Reserved — `@tfrc/product` only
|
|
46
|
+
These must NOT be defined or referenced in `@tfrc/marketing`. See
|
|
47
|
+
`decisions/0002-reserved-hues-for-financial-semantics.md`.
|
|
48
|
+
|
|
49
|
+
- `--color-gain`, `--color-gain-tint`
|
|
50
|
+
- `--color-loss`, `--color-loss-tint`
|
|
51
|
+
|
|
52
|
+
## Contrast floor
|
|
53
|
+
- `--color-text-primary` ≥ 4.5:1 against both `--color-surface` and
|
|
54
|
+
`--color-surface-raised`.
|
|
55
|
+
- `--color-accent-fg` ≥ 4.5:1 against `--color-accent`.
|
|
56
|
+
|
|
57
|
+
Check with any WCAG contrast tool before merging. This is not currently
|
|
58
|
+
automated — see `STATE.md`'s known gaps for v1.3.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @tfrc/foundation
|
|
2
|
+
|
|
3
|
+
Ships no user-facing CSS directly. Consumed by `@tfrc/marketing` and
|
|
4
|
+
`@tfrc/product`, and therefore installed transitively by every consumer —
|
|
5
|
+
which is why the consumer-side checker lives here rather than in a fourth
|
|
6
|
+
package (`decisions/0009.md`).
|
|
7
|
+
|
|
8
|
+
Contains:
|
|
9
|
+
- `bin/tfrc-verify.js` — the check a *consumer* runs on itself, via
|
|
10
|
+
`npx tfrc-verify`. Enforces one-dialect-only, pinned versions, no raw
|
|
11
|
+
color values, and the reserved gain/loss colors, in repositories this one
|
|
12
|
+
cannot see. Tested by `scripts/test-verify-consumer.js`.
|
|
13
|
+
- `src/palettes/meridian.css` — the active v1 palette (raw ramps only)
|
|
14
|
+
- `src/palettes/{lilac,meadow,daylight}.css` — designed alternates, not
|
|
15
|
+
wired into any shipped package. See `decisions/0003.md`.
|
|
16
|
+
- `src/base.css` — reset, focus-visible, reduced-motion, the 4px spacing
|
|
17
|
+
scale, and shared easing curve shapes (not durations — those diverge
|
|
18
|
+
per package, see `decisions/0004.md`).
|
|
19
|
+
- `src/index.css` — imports the active palette + base, in that order.
|
|
20
|
+
|
|
21
|
+
Does **not** contain semantic tokens (`--color-accent` and friends) or any
|
|
22
|
+
component. See `CONTRACT.md` for what every consumer must define, and
|
|
23
|
+
`decisions/0004-token-contract-not-shared-values.md` for why the contract
|
|
24
|
+
lives here but the values don't.
|
|
25
|
+
|
|
26
|
+
To change the active palette, do not edit this package — point
|
|
27
|
+
`@tfrc/marketing`'s and `@tfrc/product`'s `tokens.css` imports at a different
|
|
28
|
+
palette file, and update `STATE.md`.
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tfrc-verify — the consumer-side half of this design system's governance.
|
|
4
|
+
*
|
|
5
|
+
* Ships with @tfrc/foundation so that a product repo, which this design
|
|
6
|
+
* system's maintainers cannot see, can check itself:
|
|
7
|
+
*
|
|
8
|
+
* npx tfrc-verify
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: once the packages are installed from a registry
|
|
11
|
+
* (decisions/0007.md), nothing inside the design-system repo can stop a
|
|
12
|
+
* consumer from mixing the two dialects, using a reserved market-direction
|
|
13
|
+
* color as decoration, or hard-coding a hex value next to a token. Both of
|
|
14
|
+
* the first two are forbidden by Articles VI and V of the constitution, and
|
|
15
|
+
* both were unreachable states while the only consumers lived in-repo.
|
|
16
|
+
* Article II ("every rule that can be mechanically checked must be")
|
|
17
|
+
* therefore obliges us to ship a check a consumer can run. See
|
|
18
|
+
* specs/002-product-consumption-contract/ for the requirements this
|
|
19
|
+
* implements (FR-002, FR-006, FR-007, FR-008).
|
|
20
|
+
*
|
|
21
|
+
* Dependency-free by design: it must run via `npx` in a repo that has
|
|
22
|
+
* installed nothing but this package.
|
|
23
|
+
*
|
|
24
|
+
* Exit codes: 0 = compliant, 1 = violation(s) found, 2 = usage error.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
|
|
32
|
+
const SCAN_EXTENSIONS = new Set([
|
|
33
|
+
".css",
|
|
34
|
+
".js",
|
|
35
|
+
".jsx",
|
|
36
|
+
".mjs",
|
|
37
|
+
".cjs",
|
|
38
|
+
".ts",
|
|
39
|
+
".tsx",
|
|
40
|
+
".html",
|
|
41
|
+
".svelte",
|
|
42
|
+
".vue",
|
|
43
|
+
".astro",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const SKIP_DIRS = new Set([
|
|
47
|
+
"node_modules",
|
|
48
|
+
".git",
|
|
49
|
+
".hg",
|
|
50
|
+
"dist",
|
|
51
|
+
"build",
|
|
52
|
+
"out",
|
|
53
|
+
"coverage",
|
|
54
|
+
"vendor",
|
|
55
|
+
".next",
|
|
56
|
+
".nuxt",
|
|
57
|
+
".svelte-kit",
|
|
58
|
+
".output",
|
|
59
|
+
".turbo",
|
|
60
|
+
".cache",
|
|
61
|
+
".vercel",
|
|
62
|
+
".astro",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const MARKETING_PKG = "@tfrc/marketing";
|
|
66
|
+
const PRODUCT_PKG = "@tfrc/product";
|
|
67
|
+
const RESERVED_TOKENS = ["--color-gain", "--color-loss"];
|
|
68
|
+
|
|
69
|
+
// oklch hue bands reserved for market direction. Kept in sync with
|
|
70
|
+
// STATE.md's reserved_hue_bands and decisions/0002.md.
|
|
71
|
+
const RESERVED_HUE_BANDS = [
|
|
72
|
+
{ name: "gain", min: 130, max: 170 },
|
|
73
|
+
{ name: "loss", min: 5, max: 40 },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// An escape hatch that is deliberately NOT silent: a raw value preceded by
|
|
77
|
+
// `tfrc-allow-raw: <reason>` is downgraded from a violation to a reported
|
|
78
|
+
// allowance, and the summary always prints how many exist. specs/002's edge
|
|
79
|
+
// cases require that a local override be visible rather than accepted
|
|
80
|
+
// quietly; a hatch that produced no output would fail that.
|
|
81
|
+
const ALLOW_RAW = /tfrc-allow-raw:\s*\S/;
|
|
82
|
+
|
|
83
|
+
const args = process.argv.slice(2);
|
|
84
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
85
|
+
console.log(
|
|
86
|
+
[
|
|
87
|
+
"tfrc-verify — verify a consumer repo against The Full Remote Company design system",
|
|
88
|
+
"",
|
|
89
|
+
"Usage: npx tfrc-verify [--dir <path>] [--quiet]",
|
|
90
|
+
"",
|
|
91
|
+
" --dir <path> directory to verify (default: current working directory)",
|
|
92
|
+
" --quiet print only violations and the summary line",
|
|
93
|
+
"",
|
|
94
|
+
"Checks:",
|
|
95
|
+
" 1. exactly one dialect — @tfrc/marketing and @tfrc/product are never combined",
|
|
96
|
+
" 2. reserved colors — gain/loss tokens and hue bands mean market direction only",
|
|
97
|
+
" 3. no raw values — every color resolves through a named token",
|
|
98
|
+
" 4. pinned version — every @tfrc/* dependency is an exact version",
|
|
99
|
+
"",
|
|
100
|
+
"Exit codes: 0 compliant, 1 violations found, 2 usage error.",
|
|
101
|
+
].join("\n")
|
|
102
|
+
);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const quiet = args.includes("--quiet");
|
|
107
|
+
const dirFlag = args.indexOf("--dir");
|
|
108
|
+
const ROOT = path.resolve(dirFlag === -1 ? process.cwd() : args[dirFlag + 1] || "");
|
|
109
|
+
|
|
110
|
+
if (!fs.existsSync(ROOT) || !fs.statSync(ROOT).isDirectory()) {
|
|
111
|
+
console.error(`✗ Not a directory: ${ROOT}`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const violations = [];
|
|
116
|
+
const allowances = [];
|
|
117
|
+
|
|
118
|
+
function violation(code, file, line, message) {
|
|
119
|
+
violations.push({ code, file, line, message });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function walk(dir, out = []) {
|
|
123
|
+
let entries;
|
|
124
|
+
try {
|
|
125
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
126
|
+
} catch {
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
const full = path.join(dir, entry.name);
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
133
|
+
walk(full, out);
|
|
134
|
+
} else if (SCAN_EXTENSIONS.has(path.extname(entry.name)) && !/\.min\.(css|js)$/.test(entry.name)) {
|
|
135
|
+
out.push(full);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/* Check 4 (first, because it also tells us which dialect we are) */
|
|
143
|
+
/* FR-002: one explicit, recorded version. FR-003: one dialect only. */
|
|
144
|
+
/* ------------------------------------------------------------------ */
|
|
145
|
+
|
|
146
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
147
|
+
const pkgPath = path.join(ROOT, "package.json");
|
|
148
|
+
let declaredDeps = {};
|
|
149
|
+
|
|
150
|
+
if (!fs.existsSync(pkgPath)) {
|
|
151
|
+
violation("NO_MANIFEST", "package.json", 0, "no package.json found — cannot determine which dialect or version this consumer adopted");
|
|
152
|
+
} else {
|
|
153
|
+
let pkg;
|
|
154
|
+
try {
|
|
155
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(`✗ package.json is not valid JSON: ${err.message}`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
declaredDeps = Object.assign({}, pkg.dependencies, pkg.devDependencies, pkg.peerDependencies);
|
|
161
|
+
for (const [name, range] of Object.entries(declaredDeps)) {
|
|
162
|
+
if (!name.startsWith("@tfrc/")) continue;
|
|
163
|
+
if (!EXACT_VERSION.test(String(range))) {
|
|
164
|
+
violation(
|
|
165
|
+
"VERSION_PIN",
|
|
166
|
+
"package.json",
|
|
167
|
+
0,
|
|
168
|
+
`${name} is declared as "${range}" — must be an exact version (e.g. "1.0.0") so this consumer's appearance cannot change without a deliberate upgrade`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const dependsOnMarketing = Object.prototype.hasOwnProperty.call(declaredDeps, MARKETING_PKG);
|
|
175
|
+
const dependsOnProduct = Object.prototype.hasOwnProperty.call(declaredDeps, PRODUCT_PKG);
|
|
176
|
+
|
|
177
|
+
const files = walk(ROOT);
|
|
178
|
+
|
|
179
|
+
// Source-level dialect references, so a dialect reached around package.json
|
|
180
|
+
// (a transitive dep, a vendored copy, a CDN link) is still caught.
|
|
181
|
+
const referencedInSource = { marketing: [], product: [] };
|
|
182
|
+
const RESERVED_TOKEN_USES = [];
|
|
183
|
+
|
|
184
|
+
const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])/;
|
|
185
|
+
const FUNCTIONAL_COLOR = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)\n]*\)?/;
|
|
186
|
+
const OKLCH_LITERAL = /\boklch\(\s*([0-9.]+%?)\s+([0-9.]+%?)\s+([0-9.]+)/g;
|
|
187
|
+
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const rel = path.relative(ROOT, file) || path.basename(file);
|
|
190
|
+
let content;
|
|
191
|
+
try {
|
|
192
|
+
content = fs.readFileSync(file, "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const lines = content.split("\n");
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
const lineNo = i + 1;
|
|
201
|
+
const prev = i > 0 ? lines[i - 1] : "";
|
|
202
|
+
const allowed = ALLOW_RAW.test(line) || ALLOW_RAW.test(prev);
|
|
203
|
+
|
|
204
|
+
if (/@tfrc\/marketing/.test(line)) referencedInSource.marketing.push(`${rel}:${lineNo}`);
|
|
205
|
+
if (/@tfrc\/product/.test(line)) referencedInSource.product.push(`${rel}:${lineNo}`);
|
|
206
|
+
|
|
207
|
+
/* --- Check 2: reserved market-direction colors (FR-008) --- */
|
|
208
|
+
for (const token of RESERVED_TOKENS) {
|
|
209
|
+
const defines = new RegExp(`${token}\\s*:`).test(line);
|
|
210
|
+
const uses = new RegExp(`var\\(\\s*${token}\\s*[,)]`).test(line);
|
|
211
|
+
if (defines) {
|
|
212
|
+
violation(
|
|
213
|
+
"RESERVED_REDEFINE",
|
|
214
|
+
rel,
|
|
215
|
+
lineNo,
|
|
216
|
+
`redefines ${token}, which is owned by ${PRODUCT_PKG} — a consumer may use it, never redefine it`
|
|
217
|
+
);
|
|
218
|
+
} else if (uses) {
|
|
219
|
+
RESERVED_TOKEN_USES.push({ rel, lineNo, token });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Raw oklch inside a reserved hue band: the half of the reserved-hue
|
|
224
|
+
// rule that a token-name grep cannot see. Only oklch is checked, because
|
|
225
|
+
// only oklch states its hue directly; a hex in the same band will still
|
|
226
|
+
// be caught as a raw value below, just with a less specific message.
|
|
227
|
+
// This is a known, deliberate limit, not an oversight.
|
|
228
|
+
OKLCH_LITERAL.lastIndex = 0;
|
|
229
|
+
let m;
|
|
230
|
+
while ((m = OKLCH_LITERAL.exec(line))) {
|
|
231
|
+
const hue = parseFloat(m[3]);
|
|
232
|
+
if (Number.isNaN(hue)) continue;
|
|
233
|
+
const band = RESERVED_HUE_BANDS.find((b) => hue >= b.min && hue <= b.max);
|
|
234
|
+
if (band) {
|
|
235
|
+
violation(
|
|
236
|
+
"RESERVED_HUE",
|
|
237
|
+
rel,
|
|
238
|
+
lineNo,
|
|
239
|
+
`raw oklch hue ${hue} falls in the reserved "${band.name}" band (${band.min}–${band.max}), which means market direction only — never decoration or brand`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* --- Check 3: no raw values (FR-006) --- */
|
|
245
|
+
const hexMatch = matchHexValue(line);
|
|
246
|
+
const funcMatch = line.match(FUNCTIONAL_COLOR);
|
|
247
|
+
const rawFound = hexMatch || (funcMatch ? funcMatch[0] : null);
|
|
248
|
+
if (rawFound) {
|
|
249
|
+
if (allowed) {
|
|
250
|
+
allowances.push({ rel, lineNo, value: rawFound });
|
|
251
|
+
} else {
|
|
252
|
+
violation(
|
|
253
|
+
"RAW_VALUE",
|
|
254
|
+
rel,
|
|
255
|
+
lineNo,
|
|
256
|
+
`raw color value "${rawFound}" — every color must resolve through a named token from the design system's contract`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Hex colors are only reported in a value position. A bare `#abc {` is an
|
|
265
|
+
* ID selector, and `href="#abc"` is a fragment link; flagging either would
|
|
266
|
+
* make the tool cry wolf, and a tool that cries wolf gets switched off.
|
|
267
|
+
*/
|
|
268
|
+
function matchHexValue(line) {
|
|
269
|
+
if (/\b(?:href|xlink:href|url)\s*[=(]/.test(line)) return null;
|
|
270
|
+
const m = line.match(HEX);
|
|
271
|
+
if (!m) return null;
|
|
272
|
+
const before = line.slice(0, m.index);
|
|
273
|
+
// A value position: after a CSS `prop:`, a JS assignment, or inside a string.
|
|
274
|
+
if (!/[:=]|["'`]/.test(before)) return null;
|
|
275
|
+
return m[0];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------------ */
|
|
279
|
+
/* Check 1: exactly one dialect (FR-003, FR-007) */
|
|
280
|
+
/* ------------------------------------------------------------------ */
|
|
281
|
+
|
|
282
|
+
const usesMarketing = dependsOnMarketing || referencedInSource.marketing.length > 0;
|
|
283
|
+
const usesProduct = dependsOnProduct || referencedInSource.product.length > 0;
|
|
284
|
+
|
|
285
|
+
if (usesMarketing && usesProduct) {
|
|
286
|
+
const where = [...referencedInSource.marketing.slice(0, 3), ...referencedInSource.product.slice(0, 3)];
|
|
287
|
+
violation(
|
|
288
|
+
"MIXED_DIALECT",
|
|
289
|
+
where[0] ? where[0].split(":")[0] : "package.json",
|
|
290
|
+
0,
|
|
291
|
+
`both ${MARKETING_PKG} and ${PRODUCT_PKG} are present — they are two dialects of one language and must never be combined in one bundle` +
|
|
292
|
+
(where.length ? ` (seen at ${where.join(", ")})` : "")
|
|
293
|
+
);
|
|
294
|
+
} else if (!usesMarketing && !usesProduct) {
|
|
295
|
+
violation(
|
|
296
|
+
"NO_DIALECT",
|
|
297
|
+
"package.json",
|
|
298
|
+
0,
|
|
299
|
+
`neither ${MARKETING_PKG} nor ${PRODUCT_PKG} is present — nothing to verify. Add exactly one as an exact-version dependency.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A marketing consumer may not touch the reserved tokens at all; a product
|
|
304
|
+
// consumer may use them, for market direction, which is what they're for.
|
|
305
|
+
for (const use of RESERVED_TOKEN_USES) {
|
|
306
|
+
if (usesMarketing && !usesProduct) {
|
|
307
|
+
violation(
|
|
308
|
+
"RESERVED_TOKEN",
|
|
309
|
+
use.rel,
|
|
310
|
+
use.lineNo,
|
|
311
|
+
`uses ${use.token}, which belongs to ${PRODUCT_PKG} only — the marketing dialect must never reference market-direction color`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ------------------------------------------------------------------ */
|
|
317
|
+
/* Report */
|
|
318
|
+
/* ------------------------------------------------------------------ */
|
|
319
|
+
|
|
320
|
+
const dialect = usesMarketing && !usesProduct ? "marketing" : usesProduct && !usesMarketing ? "product" : "indeterminate";
|
|
321
|
+
|
|
322
|
+
if (!quiet) {
|
|
323
|
+
console.log(`tfrc-verify — ${path.relative(process.cwd(), ROOT) || "."}`);
|
|
324
|
+
console.log(` dialect: ${dialect}`);
|
|
325
|
+
console.log(` files scanned: ${files.length}`);
|
|
326
|
+
const pinned = Object.entries(declaredDeps).filter(([n]) => n.startsWith("@tfrc/"));
|
|
327
|
+
if (pinned.length) {
|
|
328
|
+
console.log(` design system: ${pinned.map(([n, v]) => `${n}@${v}`).join(", ")}`);
|
|
329
|
+
}
|
|
330
|
+
console.log("");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (allowances.length) {
|
|
334
|
+
console.log(`! ${allowances.length} explicitly-allowed raw value${allowances.length === 1 ? "" : "s"} (tfrc-allow-raw):`);
|
|
335
|
+
for (const a of allowances) console.log(` ${a.rel}:${a.lineNo} ${a.value}`);
|
|
336
|
+
console.log(" Allowed, but counted. Each one is a value the shared language does not cover.");
|
|
337
|
+
console.log("");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (violations.length) {
|
|
341
|
+
console.error(`✗ ${violations.length} violation${violations.length === 1 ? "" : "s"} found:\n`);
|
|
342
|
+
for (const v of violations) {
|
|
343
|
+
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
344
|
+
console.error(` [${v.code}] ${loc}`);
|
|
345
|
+
console.error(` ${v.message}`);
|
|
346
|
+
}
|
|
347
|
+
console.error("\nSee https://github.com/the-full-remote-company/design-system — CONSUMING.md");
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log("✓ Consumer is compliant: one dialect, pinned version, no raw values, reserved colors respected.");
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tfrc/foundation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Color ramps, spacing base, reset, and the token contract. No user-facing components. Consumed by @tfrc/marketing and @tfrc/product — never the other way around.",
|
|
6
|
+
"keywords": ["design-system", "design-tokens", "css", "tailwind"],
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/the-full-remote-company/design-system.git",
|
|
10
|
+
"directory": "packages/foundation"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/the-full-remote-company/design-system/tree/main/packages/foundation#readme",
|
|
13
|
+
"bugs": "https://github.com/the-full-remote-company/design-system/issues",
|
|
14
|
+
"main": "src/index.css",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.css",
|
|
17
|
+
"./palettes/*": "./src/palettes/*.css"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"tfrc-verify": "bin/tfrc-verify.js"
|
|
21
|
+
},
|
|
22
|
+
"files": ["src", "bin", "CONTRACT.md", "README.md"],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/base.css
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
@tfrc/foundation — base
|
|
3
|
+
Reset, accessibility floor, shared spacing scale, shared easing shapes.
|
|
4
|
+
No semantic color tokens live here — see decisions/0004.md.
|
|
5
|
+
========================================================================== */
|
|
6
|
+
|
|
7
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
8
|
+
|
|
9
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
10
|
+
|
|
11
|
+
body { margin: 0; -webkit-font-smoothing: antialiased; }
|
|
12
|
+
|
|
13
|
+
/* Both @tfrc/marketing and @tfrc/product must define --color-focus in their own
|
|
14
|
+
tokens.css for this to resolve — see CONTRACT.md. */
|
|
15
|
+
:focus-visible {
|
|
16
|
+
outline: 2px solid var(--color-focus);
|
|
17
|
+
outline-offset: 2px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@media (prefers-reduced-motion: reduce) {
|
|
21
|
+
*, *::before, *::after {
|
|
22
|
+
animation-duration: 0.01ms !important;
|
|
23
|
+
transition-duration: 0.01ms !important;
|
|
24
|
+
scroll-behavior: auto !important;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
:root {
|
|
29
|
+
/* 4px base unit — shared unchanged across web and app.
|
|
30
|
+
See ARCHITECTURE.md's shared-vs-diverged table. */
|
|
31
|
+
--space-1: 0.25rem;
|
|
32
|
+
--space-2: 0.5rem;
|
|
33
|
+
--space-3: 0.75rem;
|
|
34
|
+
--space-4: 1rem;
|
|
35
|
+
--space-5: 1.25rem;
|
|
36
|
+
--space-6: 1.5rem;
|
|
37
|
+
--space-8: 2rem;
|
|
38
|
+
--space-10: 2.5rem;
|
|
39
|
+
--space-12: 3rem;
|
|
40
|
+
--space-16: 4rem;
|
|
41
|
+
|
|
42
|
+
/* Easing SHAPES are shared. Durations are not — each package sets its
|
|
43
|
+
own --duration-* scale, since web's motion budget is generous and
|
|
44
|
+
app's is not. See decisions/0001.md. */
|
|
45
|
+
--ease-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
|
46
|
+
--ease-snap: cubic-bezier(0.34, 1.4, 0.64, 1);
|
|
47
|
+
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
@tfrc/foundation — entry point
|
|
3
|
+
Imports the ACTIVE palette (see STATE.md), then the base layer.
|
|
4
|
+
Consuming packages import this, then define their own tokens.css on top.
|
|
5
|
+
========================================================================== */
|
|
6
|
+
|
|
7
|
+
@import "./palettes/meridian.css";
|
|
8
|
+
@import "./base.css";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
DAYLIGHT — the original amber/ink direction, predating the finance/
|
|
3
|
+
real-estate scope. Kept as a documented alternate. Not imported by any
|
|
4
|
+
shipped package.
|
|
5
|
+
========================================================================== */
|
|
6
|
+
|
|
7
|
+
[data-palette="daylight"] {
|
|
8
|
+
--ramp-neutral-50: oklch(0.975 0.004 265);
|
|
9
|
+
--ramp-neutral-100: oklch(0.945 0.006 265);
|
|
10
|
+
--ramp-neutral-200: oklch(0.885 0.009 265);
|
|
11
|
+
--ramp-neutral-300: oklch(0.785 0.013 265);
|
|
12
|
+
--ramp-neutral-400: oklch(0.655 0.018 265);
|
|
13
|
+
--ramp-neutral-500: oklch(0.545 0.022 265);
|
|
14
|
+
--ramp-neutral-600: oklch(0.445 0.026 265);
|
|
15
|
+
--ramp-neutral-700: oklch(0.350 0.028 265);
|
|
16
|
+
--ramp-neutral-800: oklch(0.272 0.028 265);
|
|
17
|
+
--ramp-neutral-900: oklch(0.208 0.026 265);
|
|
18
|
+
--ramp-neutral-950: oklch(0.158 0.024 265);
|
|
19
|
+
|
|
20
|
+
--ramp-brand-50: oklch(0.975 0.022 88);
|
|
21
|
+
--ramp-brand-100: oklch(0.945 0.048 88);
|
|
22
|
+
--ramp-brand-200: oklch(0.895 0.090 86);
|
|
23
|
+
--ramp-brand-300: oklch(0.850 0.125 82);
|
|
24
|
+
--ramp-brand-400: oklch(0.800 0.148 78);
|
|
25
|
+
--ramp-brand-500: oklch(0.752 0.158 72);
|
|
26
|
+
--ramp-brand-600: oklch(0.672 0.150 65);
|
|
27
|
+
--ramp-brand-700: oklch(0.560 0.128 58);
|
|
28
|
+
--ramp-brand-800: oklch(0.452 0.100 55);
|
|
29
|
+
--ramp-brand-900: oklch(0.362 0.078 55);
|
|
30
|
+
|
|
31
|
+
--ramp-support-50: oklch(0.972 0.015 195);
|
|
32
|
+
--ramp-support-100: oklch(0.930 0.032 195);
|
|
33
|
+
--ramp-support-200: oklch(0.862 0.056 194);
|
|
34
|
+
--ramp-support-300: oklch(0.772 0.080 193);
|
|
35
|
+
--ramp-support-400: oklch(0.665 0.092 192);
|
|
36
|
+
--ramp-support-500: oklch(0.562 0.092 192);
|
|
37
|
+
--ramp-support-600: oklch(0.472 0.082 192);
|
|
38
|
+
--ramp-support-700: oklch(0.392 0.066 193);
|
|
39
|
+
--ramp-support-800: oklch(0.312 0.050 194);
|
|
40
|
+
--ramp-support-900: oklch(0.252 0.040 195);
|
|
41
|
+
|
|
42
|
+
--color-gain: oklch(0.510 0.120 150);
|
|
43
|
+
--color-gain-tint: oklch(0.945 0.045 150);
|
|
44
|
+
--color-loss: oklch(0.530 0.190 25);
|
|
45
|
+
--color-loss-tint: oklch(0.940 0.040 25);
|
|
46
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
LILAC — pastel violet alternate. Designed and contract-compliant, but
|
|
3
|
+
NOT imported by any shipped package. See decisions/0003.md.
|
|
4
|
+
|
|
5
|
+
Behind [data-palette="lilac"] so it can be trialed without touching the
|
|
6
|
+
default. To make this the active palette, point @tfrc/marketing's and
|
|
7
|
+
@tfrc/product's tokens.css imports here instead of meridian.css, and update
|
|
8
|
+
STATE.md.
|
|
9
|
+
========================================================================== */
|
|
10
|
+
|
|
11
|
+
[data-palette="lilac"] {
|
|
12
|
+
--ramp-neutral-50: oklch(0.980 0.006 300);
|
|
13
|
+
--ramp-neutral-100: oklch(0.952 0.011 300);
|
|
14
|
+
--ramp-neutral-200: oklch(0.898 0.018 300);
|
|
15
|
+
--ramp-neutral-300: oklch(0.802 0.026 300);
|
|
16
|
+
--ramp-neutral-400: oklch(0.675 0.034 300);
|
|
17
|
+
--ramp-neutral-500: oklch(0.565 0.040 300);
|
|
18
|
+
--ramp-neutral-600: oklch(0.465 0.046 300);
|
|
19
|
+
--ramp-neutral-700: oklch(0.370 0.048 300);
|
|
20
|
+
--ramp-neutral-800: oklch(0.290 0.046 300);
|
|
21
|
+
--ramp-neutral-900: oklch(0.228 0.042 300);
|
|
22
|
+
--ramp-neutral-950: oklch(0.174 0.038 300);
|
|
23
|
+
|
|
24
|
+
--ramp-brand-50: oklch(0.978 0.016 300);
|
|
25
|
+
--ramp-brand-100: oklch(0.948 0.038 300);
|
|
26
|
+
--ramp-brand-200: oklch(0.900 0.072 300);
|
|
27
|
+
--ramp-brand-300: oklch(0.832 0.112 300);
|
|
28
|
+
--ramp-brand-400: oklch(0.738 0.158 300);
|
|
29
|
+
--ramp-brand-500: oklch(0.638 0.198 300);
|
|
30
|
+
--ramp-brand-600: oklch(0.552 0.214 300);
|
|
31
|
+
--ramp-brand-700: oklch(0.468 0.192 300);
|
|
32
|
+
--ramp-brand-800: oklch(0.385 0.152 300);
|
|
33
|
+
--ramp-brand-900: oklch(0.308 0.112 300);
|
|
34
|
+
|
|
35
|
+
--ramp-support-50: oklch(0.976 0.012 232);
|
|
36
|
+
--ramp-support-100: oklch(0.942 0.028 232);
|
|
37
|
+
--ramp-support-200: oklch(0.882 0.050 232);
|
|
38
|
+
--ramp-support-300: oklch(0.802 0.072 232);
|
|
39
|
+
--ramp-support-400: oklch(0.708 0.090 232);
|
|
40
|
+
--ramp-support-500: oklch(0.608 0.105 232);
|
|
41
|
+
--ramp-support-600: oklch(0.512 0.100 232);
|
|
42
|
+
--ramp-support-700: oklch(0.422 0.085 234);
|
|
43
|
+
--ramp-support-800: oklch(0.338 0.066 236);
|
|
44
|
+
--ramp-support-900: oklch(0.270 0.050 238);
|
|
45
|
+
|
|
46
|
+
--color-gain: oklch(0.510 0.120 150);
|
|
47
|
+
--color-gain-tint: oklch(0.945 0.045 150);
|
|
48
|
+
--color-loss: oklch(0.530 0.190 25);
|
|
49
|
+
--color-loss-tint: oklch(0.940 0.040 25);
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
MEADOW — Spotify-adjacent green alternate. Designed and contract-
|
|
3
|
+
compliant, but NOT imported by any shipped package, and should not
|
|
4
|
+
become the brand accent while any product does gain/loss display —
|
|
5
|
+
see decisions/0002.md and decisions/0003.md.
|
|
6
|
+
========================================================================== */
|
|
7
|
+
|
|
8
|
+
[data-palette="meadow"] {
|
|
9
|
+
--ramp-neutral-50: oklch(0.980 0.005 165);
|
|
10
|
+
--ramp-neutral-100: oklch(0.950 0.009 165);
|
|
11
|
+
--ramp-neutral-200: oklch(0.896 0.014 165);
|
|
12
|
+
--ramp-neutral-300: oklch(0.798 0.020 165);
|
|
13
|
+
--ramp-neutral-400: oklch(0.670 0.026 165);
|
|
14
|
+
--ramp-neutral-500: oklch(0.560 0.030 165);
|
|
15
|
+
--ramp-neutral-600: oklch(0.460 0.034 165);
|
|
16
|
+
--ramp-neutral-700: oklch(0.364 0.036 165);
|
|
17
|
+
--ramp-neutral-800: oklch(0.284 0.036 165);
|
|
18
|
+
--ramp-neutral-900: oklch(0.222 0.032 165);
|
|
19
|
+
--ramp-neutral-950: oklch(0.170 0.028 165);
|
|
20
|
+
|
|
21
|
+
/* NOTE: this brand ramp's mid-to-high steps sit inside the reserved
|
|
22
|
+
gain hue band (130-170) per decisions/0002.md. That collision is
|
|
23
|
+
exactly why this palette is not shipped while the finance product
|
|
24
|
+
is in scope — kept here as a documented alternate only. */
|
|
25
|
+
--ramp-brand-50: oklch(0.978 0.018 150);
|
|
26
|
+
--ramp-brand-100: oklch(0.948 0.044 150);
|
|
27
|
+
--ramp-brand-200: oklch(0.902 0.084 150);
|
|
28
|
+
--ramp-brand-300: oklch(0.844 0.124 149);
|
|
29
|
+
--ramp-brand-400: oklch(0.774 0.158 148);
|
|
30
|
+
--ramp-brand-500: oklch(0.702 0.180 148);
|
|
31
|
+
--ramp-brand-600: oklch(0.622 0.166 148);
|
|
32
|
+
--ramp-brand-700: oklch(0.522 0.140 148);
|
|
33
|
+
--ramp-brand-800: oklch(0.422 0.110 149);
|
|
34
|
+
--ramp-brand-900: oklch(0.336 0.086 150);
|
|
35
|
+
|
|
36
|
+
--ramp-support-50: oklch(0.974 0.014 235);
|
|
37
|
+
--ramp-support-100: oklch(0.940 0.030 235);
|
|
38
|
+
--ramp-support-200: oklch(0.880 0.052 235);
|
|
39
|
+
--ramp-support-300: oklch(0.800 0.074 235);
|
|
40
|
+
--ramp-support-400: oklch(0.702 0.092 235);
|
|
41
|
+
--ramp-support-500: oklch(0.600 0.105 235);
|
|
42
|
+
--ramp-support-600: oklch(0.507 0.098 235);
|
|
43
|
+
--ramp-support-700: oklch(0.417 0.082 237);
|
|
44
|
+
--ramp-support-800: oklch(0.332 0.064 239);
|
|
45
|
+
--ramp-support-900: oklch(0.265 0.048 240);
|
|
46
|
+
|
|
47
|
+
--color-gain: oklch(0.510 0.120 150);
|
|
48
|
+
--color-gain-tint: oklch(0.945 0.045 150);
|
|
49
|
+
--color-loss: oklch(0.530 0.190 25);
|
|
50
|
+
--color-loss-tint: oklch(0.940 0.040 25);
|
|
51
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
MERIDIAN — the v1 shipped palette. See decisions/0003.md for why.
|
|
3
|
+
|
|
4
|
+
Three ramps. Components in @tfrc/marketing and @tfrc/product never reference these
|
|
5
|
+
variables directly — only through each package's own semantic tokens/
|
|
6
|
+
tokens.css, per decisions/0004.md. This file's only job is to define the
|
|
7
|
+
raw color material.
|
|
8
|
+
========================================================================== */
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
/* neutral — cool slate, tuned for dense financial tables */
|
|
12
|
+
--ramp-neutral-50: oklch(0.980 0.004 270);
|
|
13
|
+
--ramp-neutral-100: oklch(0.950 0.007 270);
|
|
14
|
+
--ramp-neutral-200: oklch(0.896 0.012 270);
|
|
15
|
+
--ramp-neutral-300: oklch(0.798 0.018 270);
|
|
16
|
+
--ramp-neutral-400: oklch(0.668 0.024 270);
|
|
17
|
+
--ramp-neutral-500: oklch(0.558 0.028 270);
|
|
18
|
+
--ramp-neutral-600: oklch(0.458 0.032 270);
|
|
19
|
+
--ramp-neutral-700: oklch(0.362 0.034 270);
|
|
20
|
+
--ramp-neutral-800: oklch(0.282 0.032 270);
|
|
21
|
+
--ramp-neutral-900: oklch(0.220 0.028 270);
|
|
22
|
+
--ramp-neutral-950: oklch(0.166 0.024 270);
|
|
23
|
+
|
|
24
|
+
/* brand — indigo. The parent brand accent. See decisions/0002.md for
|
|
25
|
+
why hue 275 (clear of both reserved gain/loss bands). */
|
|
26
|
+
--ramp-brand-50: oklch(0.978 0.014 275);
|
|
27
|
+
--ramp-brand-100: oklch(0.946 0.034 275);
|
|
28
|
+
--ramp-brand-200: oklch(0.896 0.062 275);
|
|
29
|
+
--ramp-brand-300: oklch(0.820 0.100 275);
|
|
30
|
+
--ramp-brand-400: oklch(0.700 0.150 275);
|
|
31
|
+
--ramp-brand-500: oklch(0.600 0.180 275);
|
|
32
|
+
--ramp-brand-600: oklch(0.520 0.190 275);
|
|
33
|
+
--ramp-brand-700: oklch(0.440 0.168 275);
|
|
34
|
+
--ramp-brand-800: oklch(0.360 0.132 275);
|
|
35
|
+
--ramp-brand-900: oklch(0.290 0.098 275);
|
|
36
|
+
|
|
37
|
+
/* support — gold. The property/real-estate association. */
|
|
38
|
+
--ramp-support-50: oklch(0.978 0.018 68);
|
|
39
|
+
--ramp-support-100: oklch(0.950 0.042 68);
|
|
40
|
+
--ramp-support-200: oklch(0.902 0.078 67);
|
|
41
|
+
--ramp-support-300: oklch(0.848 0.108 66);
|
|
42
|
+
--ramp-support-400: oklch(0.762 0.130 65);
|
|
43
|
+
--ramp-support-500: oklch(0.680 0.140 65);
|
|
44
|
+
--ramp-support-600: oklch(0.590 0.130 64);
|
|
45
|
+
--ramp-support-700: oklch(0.490 0.108 62);
|
|
46
|
+
--ramp-support-800: oklch(0.398 0.086 60);
|
|
47
|
+
--ramp-support-900: oklch(0.320 0.068 58);
|
|
48
|
+
|
|
49
|
+
/* Reserved market-direction colors. @tfrc/product only — never @tfrc/marketing.
|
|
50
|
+
See decisions/0002-reserved-hues-for-financial-semantics.md. */
|
|
51
|
+
--color-gain: oklch(0.510 0.120 150);
|
|
52
|
+
--color-gain-tint: oklch(0.945 0.045 150);
|
|
53
|
+
--color-loss: oklch(0.530 0.190 25);
|
|
54
|
+
--color-loss-tint: oklch(0.940 0.040 25);
|
|
55
|
+
|
|
56
|
+
/* Product spectrum — @tfrc/product only. Constant L/C, hue varies.
|
|
57
|
+
See decisions/0005-product-spectrum-hue-ceiling.md before adding one. */
|
|
58
|
+
--product-capital: oklch(0.545 0.185 275);
|
|
59
|
+
--product-estate: oklch(0.590 0.130 65);
|
|
60
|
+
--product-lending: oklch(0.545 0.100 195);
|
|
61
|
+
--product-insight: oklch(0.560 0.150 235);
|
|
62
|
+
--product-legal: oklch(0.545 0.200 305);
|
|
63
|
+
--product-market: oklch(0.560 0.185 340);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.dark {
|
|
67
|
+
/* Ramps don't change in dark mode — only the semantic layer in each
|
|
68
|
+
consuming package's tokens.css does. Nothing to override here. */
|
|
69
|
+
}
|