@rafters/shared 0.4.4 → 0.5.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/README.md CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  DTCGColorTokenSchema,
25
25
  oklchToDTCG,
26
26
  parseJSDocIntelligence,
27
- parseFillSignature,
27
+ FillValueSchema,
28
28
  ImportPendingSchema,
29
29
  RAFTERS_VERSION,
30
30
  } from '@rafters/shared';
@@ -54,6 +54,14 @@ validate against.
54
54
  - `ComponentIntelligenceSchema`, `ColorIntelligenceSchema`, `IntelligenceSchema`
55
55
  and friends — the shapes the MCP and Studio read when they describe a
56
56
  component or a color.
57
+ - `FillValueSchema` / `FillValue` — a fill recipe, the value of a token in the
58
+ `fill` namespace: background, gradient, mask, backdrop, blend, foreground
59
+ and border, with an optional `dark` block and `hover`, `active`, `focus`
60
+ and `disabled` state blocks. `foreground` is required. Every colour in a
61
+ recipe is a registry reference (`{ family, position }` or
62
+ `TokenReferenceSchema`'s `{ token }`), never a literal colour.
63
+ - `FILL_NAME_EXCLUDES` — the names a fill cannot take: Tailwind's own `bg-*`
64
+ utility names and keywords, without the `bg-` prefix.
57
65
  - `COMPUTED` — a sentinel symbol. Set a token to it and the system reverts that
58
66
  token to its computed value instead of a hardcoded override.
59
67
 
@@ -73,19 +81,6 @@ it back out.
73
81
  for what's missing or malformed.
74
82
  - `toDisplayName(name)` — turn a component id into its display name.
75
83
 
76
- ### Fill signatures (`fill-signature.ts`)
77
-
78
- `fill=` is a compact color vocabulary, not a styling grammar. `fill="muted/50"`
79
- is the muted color at 50% alpha; `fill="barbie-pink-to-ken-brown"` is a two-stop
80
- gradient. `to`, `via`, and `from` are reserved segments.
81
-
82
- - `parseFillSignature(input)` — parse a fill string into stops and alpha.
83
- - `validateFillSignature(signature, hasWord)` — check every stop word against a
84
- vocabulary predicate; returns the first unresolvable word, or `null`.
85
- - `expandFillSignature(signature, context, hasWord?)` — expand a signature into
86
- the classes it sets, for the given context (`surface` or `text`).
87
- - `foregroundWordFor(...)` — pick the readable foreground word for a surface fill.
88
-
89
84
  ### Import pending (`import-pending.ts`)
90
85
 
91
86
  The schema for tokens found during `rafters init` detection and left awaiting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafters/shared",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Shared W3C DTCG schemas, base types, and utilities for Rafters design system",
5
5
  "homepage": "https://rafters.studio",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export * from './component-intelligence';
2
- export * from './fill-signature';
3
2
  export * from './import-pending';
4
3
  export * from './types';
5
4
  export * from './version';
package/src/types.ts CHANGED
@@ -70,16 +70,32 @@ export const DTCGTokenBaseSchema = z.object({
70
70
  export type DTCGTokenBase = z.infer<typeof DTCGTokenBaseSchema>;
71
71
 
72
72
  /**
73
- * W3C DTCG Color Value (OKLCH format)
73
+ * W3C DTCG Color Value (Design Tokens Color Module 2025.10, section 4.1)
74
+ * https://www.designtokens.org/TR/2025.10/color/
74
75
  */
75
76
  export const DTCGColorValueSchema = z.object({
76
- colorSpace: z.literal('oklch'),
77
- channels: z.tuple([
78
- z.number().min(0).max(1), // Lightness
79
- z.number().min(0), // Chroma
80
- z.number().min(0).max(360), // Hue
77
+ colorSpace: z.enum([
78
+ 'srgb',
79
+ 'srgb-linear',
80
+ 'hsl',
81
+ 'hwb',
82
+ 'lab',
83
+ 'lch',
84
+ 'oklab',
85
+ 'oklch',
86
+ 'display-p3',
87
+ 'a98-rgb',
88
+ 'prophoto-rgb',
89
+ 'rec2020',
90
+ 'xyz-d65',
91
+ 'xyz-d50',
81
92
  ]),
82
- alpha: z.number().min(0).max(1).optional().default(1),
93
+ components: z.array(z.union([z.number(), z.literal('none')])),
94
+ alpha: z.number().min(0).max(1).optional(),
95
+ hex: z
96
+ .string()
97
+ .regex(/^#[0-9a-fA-F]{6}$/)
98
+ .optional(),
83
99
  });
84
100
 
85
101
  export type DTCGColorValue = z.infer<typeof DTCGColorValueSchema>;
@@ -126,26 +142,27 @@ export const DTCGFileSchema = z.record(z.string(), z.union([DTCGTokenBaseSchema,
126
142
  export type DTCGFile = z.infer<typeof DTCGFileSchema>;
127
143
 
128
144
  /**
129
- * Helper: Convert OKLCH to DTCG Color Value
145
+ * Helper: Convert OKLCH to DTCG Color Value.
146
+ * `alpha` is present only when it is below 1 (Color Module 2025.10 defaults it to 1).
130
147
  */
131
148
  export function oklchToDTCG(oklch: OKLCH): DTCGColorValue {
132
- return {
133
- colorSpace: 'oklch',
134
- channels: [oklch.l, oklch.c, oklch.h],
135
- alpha: oklch.alpha ?? 1,
136
- };
149
+ const value: DTCGColorValue = { colorSpace: 'oklch', components: [oklch.l, oklch.c, oklch.h] };
150
+ if (oklch.alpha !== undefined && oklch.alpha < 1) value.alpha = oklch.alpha;
151
+ return value;
137
152
  }
138
153
 
139
154
  /**
140
- * Helper: Convert DTCG Color Value to OKLCH
155
+ * Helper: Convert an oklch DTCG Color Value to OKLCH. A `none` component reads as 0.
141
156
  */
142
157
  export function dtcgToOKLCH(dtcg: DTCGColorValue): OKLCH {
143
- return {
144
- l: dtcg.channels[0],
145
- c: dtcg.channels[1],
146
- h: dtcg.channels[2],
147
- alpha: dtcg.alpha ?? 1,
158
+ if (dtcg.colorSpace !== 'oklch') {
159
+ throw new Error(`dtcgToOKLCH: colorSpace "${dtcg.colorSpace}" is not oklch`);
160
+ }
161
+ const component = (i: number): number => {
162
+ const c = dtcg.components[i];
163
+ return typeof c === 'number' ? c : 0;
148
164
  };
165
+ return { l: component(0), c: component(1), h: component(2), alpha: dtcg.alpha ?? 1 };
149
166
  }
150
167
 
151
168
  /**
@@ -371,6 +388,25 @@ export type PerceptualWeight = z.infer<typeof PerceptualWeightSchema>;
371
388
  export const SEMANTIC_STATUS_ROLES = ['destructive', 'success', 'warning', 'info'] as const;
372
389
  export type SemanticStatusRole = (typeof SEMANTIC_STATUS_ROLES)[number];
373
390
 
391
+ // Family roles: the semantic tokens a designer assigns a palette to. Every
392
+ // other semantic colour belongs to one of these (its owner), and the CSS
393
+ // exporter emits one 50-950 scale per role so a role change moves everything
394
+ // it owns.
395
+ export const SEMANTIC_FAMILY_ROLES = [
396
+ 'primary',
397
+ 'secondary',
398
+ 'tertiary',
399
+ 'accent',
400
+ 'highlight',
401
+ 'neutral',
402
+ 'muted',
403
+ 'success',
404
+ 'warning',
405
+ 'destructive',
406
+ 'info',
407
+ ] as const;
408
+ export type SemanticFamilyRole = (typeof SEMANTIC_FAMILY_ROLES)[number];
409
+
374
410
  // Semantic Color Suggestions Schema.
375
411
  // 'danger' is a deprecated mirror of 'destructive', kept while apps/api (which
376
412
  // asserts .danger) migrates to platform. Inputs with only the legacy key parse
@@ -416,6 +452,18 @@ export const ColorValueSchema = z.object({
416
452
 
417
453
  // Unique token ID for quick color lookups (e.g., "color-0.500-0.120-240.0")
418
454
  tokenId: z.string().optional(),
455
+
456
+ // The gamut each scale step sits in, index-aligned with `scale`. Generated
457
+ // steps are 'srgb' or 'p3'; only an imported seed pinned as typed can be 'out'.
458
+ gamut: z.array(z.enum(['srgb', 'p3', 'out'])).optional(),
459
+ // Present when the family was built from a different seed than requested
460
+ seedAdjusted: z
461
+ .object({
462
+ from: OKLCHSchema,
463
+ to: OKLCHSchema,
464
+ reason: z.enum(['lightness-bounds', 'gamut']),
465
+ })
466
+ .optional(),
419
467
  });
420
468
 
421
469
  export type ColorValue = z.infer<typeof ColorValueSchema>;
@@ -428,6 +476,169 @@ export const ColorReferenceSchema = z.object({
428
476
 
429
477
  export type ColorReference = z.infer<typeof ColorReferenceSchema>;
430
478
 
479
+ // Token reference: names a role or semantic colour token ("card", "primary")
480
+ // and follows that token through the cascade and the mode (FR-RAF-FILL-001).
481
+ export const TokenReferenceSchema = z.object({ token: z.string().min(1) }).strict();
482
+
483
+ export type TokenReference = z.infer<typeof TokenReferenceSchema>;
484
+
485
+ // Fills (FR-RAF-FILL-001): a designer-authored recipe of stock Tailwind parts.
486
+ // Every colour is a registry reference, never a literal.
487
+ const FillColourSchema = z.union([ColorReferenceSchema.strict(), TokenReferenceSchema], {
488
+ error: 'must be a registry reference ({family, position} or {token}), not a literal colour',
489
+ });
490
+ const PercentSchema = z.number().min(0).max(100);
491
+ const FillStopSchema = z
492
+ .object({
493
+ color: FillColourSchema,
494
+ alpha: PercentSchema.optional(),
495
+ position: PercentSchema.optional(),
496
+ })
497
+ .strict();
498
+ const FillPartsSchema = z
499
+ .object({
500
+ background: z
501
+ .object({ color: FillColourSchema, alpha: PercentSchema.optional() })
502
+ .strict()
503
+ .optional(),
504
+ gradient: z
505
+ .object({
506
+ type: z.enum(['linear', 'radial', 'conic']),
507
+ direction: z
508
+ .enum(['to-t', 'to-tr', 'to-r', 'to-br', 'to-b', 'to-bl', 'to-l', 'to-tl'])
509
+ .optional(),
510
+ angle: z.number().optional(),
511
+ from: FillStopSchema.optional(),
512
+ via: FillStopSchema.optional(),
513
+ to: FillStopSchema.optional(),
514
+ interpolation: z
515
+ .enum(['srgb', 'hsl', 'oklab', 'oklch', 'longer', 'shorter', 'increasing', 'decreasing'])
516
+ .optional(),
517
+ })
518
+ .strict()
519
+ .optional(),
520
+ mask: z
521
+ .object({
522
+ type: z.enum(['linear', 'radial', 'conic', 't', 'r', 'b', 'l', 'x', 'y']),
523
+ from: PercentSchema.optional(),
524
+ to: PercentSchema.optional(),
525
+ })
526
+ .strict()
527
+ .optional(),
528
+ backdrop: z
529
+ .object({
530
+ blur: z.enum(['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl']).optional(),
531
+ brightness: z.number().min(0).optional(),
532
+ contrast: z.number().min(0).optional(),
533
+ saturate: z.number().min(0).optional(),
534
+ grayscale: PercentSchema.optional(),
535
+ opacity: PercentSchema.optional(),
536
+ })
537
+ .strict()
538
+ .optional(),
539
+ blend: z
540
+ .enum([
541
+ 'normal',
542
+ 'multiply',
543
+ 'screen',
544
+ 'overlay',
545
+ 'darken',
546
+ 'lighten',
547
+ 'color-dodge',
548
+ 'color-burn',
549
+ 'hard-light',
550
+ 'soft-light',
551
+ 'difference',
552
+ 'exclusion',
553
+ 'hue',
554
+ 'saturation',
555
+ 'color',
556
+ 'luminosity',
557
+ ])
558
+ .optional(),
559
+ foreground: FillColourSchema.optional(),
560
+ border: FillColourSchema.optional(),
561
+ })
562
+ .strict();
563
+ const FillStateSchema = FillPartsSchema.extend({ dark: FillPartsSchema.optional() }).strict();
564
+
565
+ export const FillValueSchema = FillPartsSchema.extend({
566
+ foreground: FillColourSchema,
567
+ dark: FillPartsSchema.optional(),
568
+ hover: FillStateSchema.optional(),
569
+ active: FillStateSchema.optional(),
570
+ focus: FillStateSchema.optional(),
571
+ disabled: FillStateSchema.optional(),
572
+ }).strict();
573
+
574
+ export type FillValue = z.infer<typeof FillValueSchema>;
575
+
576
+ // Names a fill cannot take (FR-RAF-FILL-001): Tailwind's static bg-* utilities
577
+ // (45), the legacy ones it still emits (12) and the bg- keywords, each without
578
+ // its `bg-` prefix. Studio keeps the designer-facing list.
579
+ export const FILL_NAME_EXCLUDES: readonly string[] = [
580
+ 'auto',
581
+ 'blend-color',
582
+ 'blend-color-burn',
583
+ 'blend-color-dodge',
584
+ 'blend-darken',
585
+ 'blend-difference',
586
+ 'blend-exclusion',
587
+ 'blend-hard-light',
588
+ 'blend-hue',
589
+ 'blend-lighten',
590
+ 'blend-luminosity',
591
+ 'blend-multiply',
592
+ 'blend-normal',
593
+ 'blend-overlay',
594
+ 'blend-saturation',
595
+ 'blend-screen',
596
+ 'blend-soft-light',
597
+ 'bottom',
598
+ 'bottom-left',
599
+ 'bottom-right',
600
+ 'center',
601
+ 'clip-border',
602
+ 'clip-content',
603
+ 'clip-padding',
604
+ 'clip-text',
605
+ 'contain',
606
+ 'cover',
607
+ 'fixed',
608
+ 'left',
609
+ 'local',
610
+ 'no-repeat',
611
+ 'none',
612
+ 'origin-border',
613
+ 'origin-content',
614
+ 'origin-padding',
615
+ 'repeat',
616
+ 'repeat-round',
617
+ 'repeat-space',
618
+ 'repeat-x',
619
+ 'repeat-y',
620
+ 'right',
621
+ 'scroll',
622
+ 'top',
623
+ 'top-left',
624
+ 'top-right',
625
+ 'left-top',
626
+ 'right-top',
627
+ 'left-bottom',
628
+ 'right-bottom',
629
+ 'gradient-to-t',
630
+ 'gradient-to-tr',
631
+ 'gradient-to-r',
632
+ 'gradient-to-br',
633
+ 'gradient-to-b',
634
+ 'gradient-to-bl',
635
+ 'gradient-to-l',
636
+ 'gradient-to-tl',
637
+ 'inherit',
638
+ 'current',
639
+ 'transparent',
640
+ ];
641
+
431
642
  // Progression system labels stamped on tokens to record which math system
432
643
  // produced them. Used only by TokenSchema below; the math-utils package
433
644
  // carries the actual ratio definitions in DEFAULT_RATIOS. This array will be
@@ -469,7 +680,7 @@ export type OverrideKind = z.infer<typeof OverrideKindSchema>;
469
680
  export const TokenSchema = z.object({
470
681
  // Core token data
471
682
  name: z.string(),
472
- value: z.union([z.string(), ColorValueSchema, ColorReferenceSchema]), // String, ColorValue for families, or ColorReference for semantic
683
+ value: z.union([z.string(), ColorValueSchema, ColorReferenceSchema, FillValueSchema]), // String, ColorValue for families, ColorReference for semantic, FillValue for fills
473
684
  category: z.string(),
474
685
  namespace: z.string(),
475
686
  binding: BindingSchema.optional(), // Plugin binding for cascade-driven values
@@ -502,7 +713,7 @@ export const TokenSchema = z.object({
502
713
  userOverride: z
503
714
  .object({
504
715
  // Value before override (for undo)
505
- previousValue: z.union([z.string(), ColorValueSchema, ColorReferenceSchema]),
716
+ previousValue: z.union([z.string(), ColorValueSchema, ColorReferenceSchema, FillValueSchema]),
506
717
  // Why was this overridden
507
718
  reason: z.string(),
508
719
  // Additional context (e.g. "Q1 marketing campaign", "accessibility audit")
@@ -518,7 +729,9 @@ export const TokenSchema = z.object({
518
729
 
519
730
  // Computed value from generation rule (before any override)
520
731
  // Stored so agents can see what the system WOULD produce vs what human chose
521
- computedValue: z.union([z.string(), ColorValueSchema, ColorReferenceSchema]).optional(),
732
+ computedValue: z
733
+ .union([z.string(), ColorValueSchema, ColorReferenceSchema, FillValueSchema])
734
+ .optional(),
522
735
 
523
736
  // Dependency tracking for automatic regeneration
524
737
  dependsOn: z.array(z.string()).optional(), // Parent token(s) - empty = root token
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Written by packages/cli/scripts/sync-version.ts from the CLI's own
5
5
  * package.json version at build time -- do not edit by hand.
6
6
  */
7
- export const RAFTERS_VERSION = '0.4.4';
7
+ export const RAFTERS_VERSION = '0.5.0';
@@ -1,217 +0,0 @@
1
- /**
2
- * Fill signature (#1637).
3
- *
4
- * A fill is a SIGNATURE: a compact form that sets namespaced classes and
5
- * existing Tailwind utilities on things. Not a grammar, not an adjective
6
- * system. The phrase is color vocabulary only:
7
- *
8
- * word solid fill="barbie-pink"
9
- * word/alpha with opacity fill="muted/50" (Tailwind's slash, verbatim)
10
- * word-to-word 2-stop gradient fill="barbie-pink-to-ken-brown"
11
- *
12
- * '-to-' is RESERVED in the color namer (generated names can never contain
13
- * it), so splitting at the first '-to-' is deterministic without vocabulary.
14
- * With a vocabulary present, every word must resolve or the signature is
15
- * rejected naming the unresolvable word (the #1632 lesson: hyphens are
16
- * overloaded in generated names; split only at reserved separators and
17
- * validate every side).
18
- *
19
- * Everything else -- blur, blend, direction -- is already Tailwind's
20
- * namespace; our tokens set the VALUES behind those words (what
21
- * backdrop-blur-sm ends up as). Those words are spoken as plain utilities
22
- * where class lists are legitimate. Not fill's job.
23
- *
24
- * Dark contract: falls out of which utility is emitted. Semantic words
25
- * compile to utilities over flipping vars (bg-primary follows .dark);
26
- * family words compile to literal scale utilities (never flip).
27
- */
28
-
29
- export interface FillStop {
30
- /** A vocabulary word: semantic role, family, or family-position. */
31
- word: string;
32
- /** Tailwind slash-opacity, integer 0-100. Absent = opaque. */
33
- alpha?: number;
34
- }
35
-
36
- export interface FillSignature {
37
- /** One stop = solid; two stops = linear gradient (to-b). */
38
- stops: [FillStop] | [FillStop, FillStop];
39
- }
40
-
41
- export type FillParseResult =
42
- | { ok: true; signature: FillSignature }
43
- | { ok: false; word: string; reason: string };
44
-
45
- export type FillContext = 'surface' | 'text';
46
-
47
- /**
48
- * Hyphen segments reserved across the system (#1637). The fill parser
49
- * splits at '-to-'; 'via' and 'from' are Tailwind's other gradient
50
- * connectors and are reserved alongside so they can join the signature
51
- * without a namer migration. The color namer's bank invariant test
52
- * imports this list -- one copy of the reservation.
53
- */
54
- export const RESERVED_FILL_SEGMENTS = ['to', 'via', 'from'] as const;
55
-
56
- const SEPARATOR = `-${RESERVED_FILL_SEGMENTS[0]}-`;
57
- const ALPHA_PATTERN = /^(100|[1-9]?[0-9])$/;
58
- const WORD_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
59
-
60
- function parseStop(term: string): FillStop | { word: string; reason: string } {
61
- const slash = term.indexOf('/');
62
- const word = slash === -1 ? term : term.slice(0, slash);
63
- if (!WORD_PATTERN.test(word)) {
64
- return { word: word || term, reason: 'not a vocabulary word' };
65
- }
66
- if (slash === -1) return { word };
67
- const alphaRaw = term.slice(slash + 1);
68
- if (!ALPHA_PATTERN.test(alphaRaw)) {
69
- return { word, reason: `invalid opacity "/${alphaRaw}" (integer 0-100)` };
70
- }
71
- return { word, alpha: Number(alphaRaw) };
72
- }
73
-
74
- /**
75
- * Parse a fill signature. Syntax only -- vocabulary validation is the
76
- * caller's job via `validateFillSignature` wherever a registry exists
77
- * (build, tests, MCP). Runtime components expand optimistically; the
78
- * build-time safelist pass is the strict gate.
79
- */
80
- export function parseFillSignature(input: string): FillParseResult {
81
- const trimmed = input.trim();
82
- if (trimmed.length === 0) {
83
- return { ok: false, word: input, reason: 'empty fill' };
84
- }
85
- if (trimmed.includes(' ')) {
86
- return {
87
- ok: false,
88
- word: trimmed,
89
- reason:
90
- 'fill is a single signature -- other utilities are plain Tailwind words in the class list',
91
- };
92
- }
93
-
94
- const split = trimmed.indexOf(SEPARATOR);
95
- if (split === -1) {
96
- const stop = parseStop(trimmed);
97
- if ('reason' in stop) return { ok: false, ...stop };
98
- return { ok: true, signature: { stops: [stop] } };
99
- }
100
-
101
- const left = trimmed.slice(0, split);
102
- const right = trimmed.slice(split + SEPARATOR.length);
103
- if (right.includes(SEPARATOR)) {
104
- return {
105
- ok: false,
106
- word: trimmed,
107
- reason: 'three-stop gradients are not supported (two stops only)',
108
- };
109
- }
110
- const from = parseStop(left);
111
- if ('reason' in from) return { ok: false, ...from };
112
- const to = parseStop(right);
113
- if ('reason' in to) return { ok: false, ...to };
114
- return { ok: true, signature: { stops: [from, to] } };
115
- }
116
-
117
- /**
118
- * Validate every word in a parsed signature against a vocabulary.
119
- * Returns null when valid, else the first unresolvable word.
120
- */
121
- export function validateFillSignature(
122
- signature: FillSignature,
123
- hasWord: (word: string) => boolean,
124
- ): string | null {
125
- for (const stop of signature.stops) {
126
- if (!hasWord(stop.word)) return stop.word;
127
- }
128
- return null;
129
- }
130
-
131
- /**
132
- * The surface roles whose `{word}-foreground` pair is guaranteed by the
133
- * frozen contract (shadcn drop-in names plus rafters role extensions --
134
- * doctrine: contract names are frozen interface). Pairing is membership
135
- * here, never spelling: family words (barbie-pink, blue-300) and unknown
136
- * words never pair, so no phantom text-*-foreground class ever ships.
137
- */
138
- const PAIRED_SURFACE_ROLES = new Set([
139
- 'background',
140
- 'card',
141
- 'panel',
142
- 'popover',
143
- 'surface',
144
- 'primary',
145
- 'secondary',
146
- 'muted',
147
- 'accent',
148
- 'destructive',
149
- 'success',
150
- 'warning',
151
- 'info',
152
- 'alert',
153
- 'highlight',
154
- 'selection',
155
- 'sidebar',
156
- 'nav',
157
- 'tooltip',
158
- 'overlay',
159
- 'table',
160
- 'table-header',
161
- 'code',
162
- 'badge',
163
- 'avatar',
164
- 'input',
165
- ]);
166
-
167
- /**
168
- * The paired foreground word for a surface word, or null when pairing
169
- * does not apply. `background` pairs irregularly with `foreground`.
170
- * With a vocabulary present (`hasWord`), any word whose `-foreground`
171
- * pair resolves may pair; without one, only the frozen role set does.
172
- */
173
- export function foregroundWordFor(
174
- word: string,
175
- hasWord?: (word: string) => boolean,
176
- ): string | null {
177
- const pair = word === 'background' ? 'foreground' : `${word}-foreground`;
178
- if (hasWord) return hasWord(pair) ? pair : null;
179
- return PAIRED_SURFACE_ROLES.has(word) ? pair : null;
180
- }
181
-
182
- function stopClass(prefix: string, stop: FillStop): string {
183
- return stop.alpha === undefined
184
- ? `${prefix}-${stop.word}`
185
- : `${prefix}-${stop.word}/${stop.alpha}`;
186
- }
187
-
188
- /**
189
- * Expand a signature to its class words. Surface context emits the
190
- * background (and the paired foreground when one exists in the
191
- * vocabulary); text context emits text color, or gradient text via
192
- * bg-clip-text. Gradients emit Tailwind v4 utilities (bg-linear-to-b --
193
- * bg-gradient-to-* is a deprecated v3 alias and is never emitted).
194
- */
195
- export function expandFillSignature(
196
- signature: FillSignature,
197
- context: FillContext,
198
- hasWord?: (word: string) => boolean,
199
- ): string {
200
- const [first, second] = signature.stops;
201
-
202
- if (!second) {
203
- if (context === 'text') return stopClass('text', first);
204
- const parts = [stopClass('bg', first)];
205
- const fg = foregroundWordFor(first.word, hasWord);
206
- if (fg) parts.push(`text-${fg}`);
207
- return parts.join(' ');
208
- }
209
-
210
- const gradient = ['bg-linear-to-b', stopClass('from', first), stopClass('to', second)];
211
- if (context === 'text') {
212
- return [...gradient, 'bg-clip-text', 'text-transparent'].join(' ');
213
- }
214
- const fg = foregroundWordFor(first.word, hasWord);
215
- if (fg) gradient.push(`text-${fg}`);
216
- return gradient.join(' ');
217
- }