@rafters/shared 0.2.3

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.
@@ -0,0 +1,217 @@
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
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Import Pending Schema
3
+ *
4
+ * Stores tokens detected during `rafters init --onboard` or `rafters import`
5
+ * that are awaiting user review. Written to `.rafters/import-pending.json`
6
+ * after detection, consumed by CLI prompts and Studio review UI.
7
+ *
8
+ * Each entry captures:
9
+ * - The original source variable (name, value, file location)
10
+ * - The proposed Rafters token after mapping
11
+ * - User decision (pending, accepted, rejected, modified)
12
+ * - Confidence and rationale so the user understands the mapping
13
+ */
14
+
15
+ import { z } from 'zod';
16
+ import { TokenSchema } from './types';
17
+
18
+ /**
19
+ * Decision state for a pending import token.
20
+ * Defaults to "pending" until the user reviews.
21
+ */
22
+ export const ImportDecisionSchema = z.enum(['pending', 'accepted', 'rejected', 'modified']);
23
+
24
+ /**
25
+ * Original source variable as parsed from the user's project
26
+ */
27
+ export const ImportOriginalSchema = z
28
+ .object({
29
+ /** Original variable name (e.g., "--color-primary") */
30
+ name: z.string(),
31
+ /** Original value as written in source (e.g., "oklch(0.7 0.15 250)") */
32
+ value: z.string(),
33
+ /** Source file path relative to project root */
34
+ source: z.string(),
35
+ /** Line number where the variable was declared */
36
+ line: z.number().int().positive().optional(),
37
+ /** Column number where the variable was declared */
38
+ column: z.number().int().positive().optional(),
39
+ })
40
+ .strict();
41
+
42
+ /**
43
+ * User modifications to a proposed token.
44
+ * Must change at least one field -- empty modifications are rejected.
45
+ */
46
+ export const ImportModificationsSchema = z
47
+ .object({
48
+ name: z.string().optional(),
49
+ value: z.string().optional(),
50
+ category: z.string().optional(),
51
+ namespace: z.string().optional(),
52
+ })
53
+ .strict()
54
+ .refine((m) => Object.values(m).some((v) => v !== undefined), {
55
+ message: 'modifications must change at least one field',
56
+ });
57
+
58
+ /**
59
+ * A single token pending user review.
60
+ *
61
+ * Invariants enforced:
62
+ * - decision === 'modified' <=> modifications is present
63
+ * - modifications must change at least one field (see ImportModificationsSchema)
64
+ */
65
+ export const PendingTokenSchema = z
66
+ .object({
67
+ /** Original token from source */
68
+ original: ImportOriginalSchema,
69
+
70
+ /** Proposed Rafters token (full Token schema) */
71
+ proposed: TokenSchema,
72
+
73
+ /** User decision -- pending until reviewed */
74
+ decision: ImportDecisionSchema.default('pending'),
75
+
76
+ /** User edits when decision is "modified" */
77
+ modifications: ImportModificationsSchema.optional(),
78
+
79
+ /** Detection confidence 0-1 */
80
+ confidence: z.number().min(0).max(1),
81
+
82
+ /** Why this mapping was proposed (e.g., "Tailwind --color-primary maps to primary-500") */
83
+ rationale: z.string().optional(),
84
+ })
85
+ .strict()
86
+ .superRefine((t, ctx) => {
87
+ if (t.decision === 'modified' && !t.modifications) {
88
+ ctx.addIssue({
89
+ code: 'custom',
90
+ message: "decision is 'modified' but modifications are missing",
91
+ path: ['modifications'],
92
+ });
93
+ }
94
+ if (t.decision !== 'modified' && t.modifications) {
95
+ ctx.addIssue({
96
+ code: 'custom',
97
+ message: `modifications only allowed when decision is 'modified' (got '${t.decision}')`,
98
+ path: ['modifications'],
99
+ });
100
+ }
101
+ });
102
+
103
+ /**
104
+ * A single step in a detected color palette.
105
+ *
106
+ * Each step carries both the original source variable (so the user can audit
107
+ * where the value came from) and the proposed Rafters token (so accepting the
108
+ * palette can materialize tokens directly). Position is the Tailwind scale
109
+ * stop the variable occupies in its source CSS.
110
+ */
111
+ export const PendingPaletteStepSchema = z
112
+ .object({
113
+ /** Tailwind scale position: "50", "100", ..., "900", "950" */
114
+ position: z.string(),
115
+
116
+ /** Original source variable */
117
+ original: ImportOriginalSchema,
118
+
119
+ /** Proposed Rafters token for this step */
120
+ proposed: TokenSchema,
121
+ })
122
+ .strict();
123
+
124
+ /**
125
+ * A color palette recovered by the ramp detector. Replaces what would
126
+ * otherwise be N flat PendingTokens for the same family.
127
+ *
128
+ * Invariants enforced:
129
+ * - decision === 'modified' is not allowed at the palette level (#1402
130
+ * leaves per-step editing to the existing tokens[] flow)
131
+ */
132
+ export const PendingPaletteSchema = z
133
+ .object({
134
+ /** Palette family name (e.g. "empire") -- the var-name prefix shared by every step */
135
+ name: z.string(),
136
+
137
+ /** Detected scale type. Only Tailwind is supported in #1402. */
138
+ scale: z.literal('tailwind'),
139
+
140
+ /** Source file path relative to project root */
141
+ source: z.string(),
142
+
143
+ /** Ramp steps ordered ascending by Tailwind position */
144
+ steps: z.array(PendingPaletteStepSchema).min(2),
145
+
146
+ /** User decision -- pending until reviewed. Applies to the whole palette. */
147
+ decision: z.enum(['pending', 'accepted', 'rejected']).default('pending'),
148
+
149
+ /** Detection confidence 0-1 */
150
+ confidence: z.number().min(0).max(1),
151
+
152
+ /** Optional rationale for why this was grouped as a palette */
153
+ rationale: z.string().optional(),
154
+ })
155
+ .strict();
156
+
157
+ /**
158
+ * Import-pending document written to `.rafters/import-pending.json`
159
+ */
160
+ export const ImportPendingSchema = z
161
+ .object({
162
+ /** Schema version for future migrations */
163
+ version: z.literal('1.0'),
164
+
165
+ /** ISO-8601 timestamp when the import was detected */
166
+ createdAt: z.string().datetime(),
167
+
168
+ /** Which importer produced this pending list (e.g., "tailwind-v4", "shadcn") */
169
+ detectedSystem: z.string(),
170
+
171
+ /** Confidence that the detected system is correct */
172
+ systemConfidence: z.number().min(0).max(1),
173
+
174
+ /** Primary source file path (relative to project root) */
175
+ source: z.string(),
176
+
177
+ /** Additional source paths if more than one file was imported */
178
+ additionalSources: z.array(z.string()).optional(),
179
+
180
+ /** Warnings from the import process (parse issues, dedup skips, etc.) */
181
+ warnings: z
182
+ .array(
183
+ z
184
+ .object({
185
+ level: z.enum(['info', 'warning', 'error']),
186
+ message: z.string(),
187
+ })
188
+ .strict(),
189
+ )
190
+ .optional(),
191
+
192
+ /** Tokens awaiting review */
193
+ tokens: z.array(PendingTokenSchema),
194
+
195
+ /**
196
+ * Color palettes recovered from CSS ramps (e.g. --empire-50 ... --empire-950).
197
+ * Tokens emitted as part of a palette do NOT appear in `tokens` -- the
198
+ * palette is the source of truth for the family.
199
+ */
200
+ palettes: z.array(PendingPaletteSchema).optional(),
201
+ })
202
+ .strict();
203
+
204
+ export type ImportDecision = z.infer<typeof ImportDecisionSchema>;
205
+ export type ImportOriginal = z.infer<typeof ImportOriginalSchema>;
206
+ export type ImportModifications = z.infer<typeof ImportModificationsSchema>;
207
+ export type PendingToken = z.infer<typeof PendingTokenSchema>;
208
+ export type PendingPaletteStep = z.infer<typeof PendingPaletteStepSchema>;
209
+ export type PendingPalette = z.infer<typeof PendingPaletteSchema>;
210
+ export type ImportPending = z.infer<typeof ImportPendingSchema>;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './component-intelligence';
2
+ export * from './fill-signature';
3
+ export * from './import-pending';
4
+ export * from './types';
5
+ export * from './version';