@devfellowship/components 2.0.0 → 3.0.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +470 -385
  2. package/package.json +4 -8
package/dist/cli.js CHANGED
@@ -3,321 +3,10 @@
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/cli/commands/init.ts
7
- import fs from "fs-extra";
8
- import path from "path";
9
- import chalk2 from "chalk";
10
- import ora from "ora";
11
- import prompts from "prompts";
12
-
13
- // src/cli/types/config.ts
14
- import { z } from "zod";
15
- var configSchema = z.object({
16
- typescript: z.boolean().default(true),
17
- aliases: z.object({
18
- components: z.string().default("@/components/dfl"),
19
- hooks: z.string().default("@/hooks"),
20
- providers: z.string().default("@/providers"),
21
- pages: z.string().default("@/pages")
22
- }),
23
- registry: z.string().default("https://raw.githubusercontent.com/taigfs/dfl-components-cli/main/registry")
24
- });
25
-
26
- // src/cli/utils/logger.ts
27
- import chalk from "chalk";
28
- var logger = {
29
- info: (message) => console.log(chalk.blue("info"), message),
30
- success: (message) => console.log(chalk.green("\u2713"), message),
31
- warn: (message) => console.log(chalk.yellow("warn"), message),
32
- error: (message) => console.log(chalk.red("error"), message),
33
- break: () => console.log("")
34
- };
35
-
36
- // src/cli/commands/init.ts
37
- async function init(options) {
38
- const cwd = path.resolve(options.cwd);
39
- logger.info("Initializing dfl-components configuration...");
40
- logger.break();
41
- const configPath = path.join(cwd, "dfl-components.json");
42
- if (await fs.pathExists(configPath)) {
43
- const { overwrite } = await prompts({
44
- type: "confirm",
45
- name: "overwrite",
46
- message: "dfl-components.json already exists. Overwrite?",
47
- initial: false
48
- });
49
- if (!overwrite) {
50
- logger.warn("Aborted.");
51
- return;
52
- }
53
- }
54
- let config;
55
- if (options.yes) {
56
- config = configSchema.parse({
57
- aliases: {}
58
- });
59
- } else {
60
- const answers = await prompts([
61
- {
62
- type: "confirm",
63
- name: "typescript",
64
- message: "Would you like to use TypeScript?",
65
- initial: true
66
- },
67
- {
68
- type: "text",
69
- name: "componentsPath",
70
- message: "Where would you like to install components?",
71
- initial: "@/components/dfl"
72
- },
73
- {
74
- type: "text",
75
- name: "hooksPath",
76
- message: "Where would you like to install hooks?",
77
- initial: "@/hooks"
78
- },
79
- {
80
- type: "text",
81
- name: "providersPath",
82
- message: "Where would you like to install providers?",
83
- initial: "@/providers"
84
- },
85
- {
86
- type: "text",
87
- name: "pagesPath",
88
- message: "Where would you like to install pages?",
89
- initial: "@/pages"
90
- }
91
- ]);
92
- if (!answers.typescript) {
93
- logger.warn("Aborted.");
94
- return;
95
- }
96
- config = configSchema.parse({
97
- typescript: answers.typescript,
98
- aliases: {
99
- components: answers.componentsPath,
100
- hooks: answers.hooksPath,
101
- providers: answers.providersPath,
102
- pages: answers.pagesPath
103
- }
104
- });
105
- }
106
- const spinner = ora("Writing configuration...").start();
107
- try {
108
- await fs.writeJson(configPath, config, { spaces: 2 });
109
- spinner.succeed("Configuration saved to dfl-components.json");
110
- logger.break();
111
- logger.success("Project initialized successfully!");
112
- logger.break();
113
- console.log("You can now add components:");
114
- console.log(chalk2.cyan(" npx dfl-components add button"));
115
- console.log(chalk2.cyan(" npx dfl-components add card input"));
116
- console.log(chalk2.cyan(" npx dfl-components add auth-pages"));
117
- logger.break();
118
- } catch (error) {
119
- spinner.fail("Failed to write configuration");
120
- throw error;
121
- }
122
- }
123
-
124
- // src/cli/commands/add.ts
125
- import fs2 from "fs-extra";
126
- import path2 from "path";
127
- import chalk3 from "chalk";
128
- import ora2 from "ora";
129
- import prompts2 from "prompts";
130
-
131
- // src/cli/utils/get-config.ts
132
- import { cosmiconfig } from "cosmiconfig";
133
- async function getConfig(cwd) {
134
- const explorer = cosmiconfig("dfl-components", {
135
- searchPlaces: ["dfl-components.json"]
136
- });
137
- const result = await explorer.search(cwd);
138
- if (!result) {
139
- return null;
140
- }
141
- return configSchema.parse(result.config);
142
- }
143
-
144
- // src/cli/utils/get-registry.ts
145
- import fetch2 from "node-fetch";
146
- async function getRegistry(registryUrl) {
147
- const response = await fetch2(`${registryUrl}/registry.json`);
148
- if (!response.ok) {
149
- throw new Error(`Failed to fetch registry: ${response.statusText}`);
150
- }
151
- return response.json();
152
- }
153
- function getComponentFolderByCategory(category) {
154
- switch (category) {
155
- case "Hooks":
156
- return "hooks";
157
- case "Providers":
158
- return "providers";
159
- case "Pages":
160
- return "pages";
161
- default:
162
- return "components";
163
- }
164
- }
165
-
166
- // src/cli/utils/resolve-alias.ts
167
- function resolveAlias(aliasPath) {
168
- if (aliasPath.startsWith("@/")) {
169
- return aliasPath.replace("@/", "src/");
170
- }
171
- return aliasPath;
172
- }
173
- function getTargetPath(category, aliases) {
174
- switch (category) {
175
- case "Hooks":
176
- return resolveAlias(aliases.hooks);
177
- case "Providers":
178
- return resolveAlias(aliases.providers);
179
- case "Pages":
180
- return resolveAlias(aliases.pages);
181
- default:
182
- return resolveAlias(aliases.components);
183
- }
184
- }
185
-
186
- // src/cli/commands/add.ts
187
- async function add(components, options) {
188
- const cwd = path2.resolve(options.cwd);
189
- const config = await getConfig(cwd);
190
- if (!config) {
191
- logger.error("No dfl-components.json found.");
192
- console.log("Run", chalk3.cyan("npx dfl-components init"), "first.");
193
- return;
194
- }
195
- const spinner = ora2("Fetching registry...").start();
196
- try {
197
- let resolveDeps2 = function(name) {
198
- if (allComponents.has(name)) return;
199
- allComponents.add(name);
200
- const item = registry.items.find((i) => i.name === name);
201
- if (item?.registryDependencies) {
202
- for (const dep of item.registryDependencies) {
203
- resolveDeps2(dep);
204
- }
205
- }
206
- };
207
- var resolveDeps = resolveDeps2;
208
- const registry = await getRegistry(config.registry);
209
- let toInstall;
210
- if (options.all) {
211
- toInstall = registry.items.map((item) => item.name);
212
- } else if (components.length === 0) {
213
- spinner.stop();
214
- const { selected } = await prompts2({
215
- type: "multiselect",
216
- name: "selected",
217
- message: "Which components would you like to add?",
218
- choices: registry.items.map((item) => ({
219
- title: `${item.title} (${item.category})`,
220
- value: item.name,
221
- description: item.description
222
- })),
223
- min: 1
224
- });
225
- if (!selected || selected.length === 0) {
226
- logger.warn("No components selected.");
227
- return;
228
- }
229
- toInstall = selected;
230
- spinner.start("Fetching components...");
231
- } else {
232
- toInstall = components;
233
- }
234
- const validComponents = [];
235
- for (const name of toInstall) {
236
- const item = registry.items.find((i) => i.name === name);
237
- if (!item) {
238
- logger.warn(`Component "${name}" not found in registry. Skipping.`);
239
- } else {
240
- validComponents.push(name);
241
- }
242
- }
243
- if (validComponents.length === 0) {
244
- spinner.fail("No valid components to install.");
245
- return;
246
- }
247
- const allComponents = /* @__PURE__ */ new Set();
248
- for (const component of validComponents) {
249
- resolveDeps2(component);
250
- }
251
- spinner.text = `Installing ${allComponents.size} component(s)...`;
252
- const installed = [];
253
- const errors = [];
254
- for (const componentName of allComponents) {
255
- const registryItem = registry.items.find((i) => i.name === componentName);
256
- if (!registryItem) continue;
257
- try {
258
- const folder = getComponentFolderByCategory(registryItem.category);
259
- const url = `${config.registry}/${folder}/${componentName}.json`;
260
- const response = await fetch(url);
261
- if (!response.ok) {
262
- throw new Error(`Failed to fetch: ${response.statusText}`);
263
- }
264
- const componentData = await response.json();
265
- for (const file of componentData.files) {
266
- const targetDir = file.target ? path2.dirname(path2.join(cwd, "src", file.target)) : path2.join(cwd, getTargetPath(registryItem.category, config.aliases));
267
- const targetPath = file.target ? path2.join(cwd, "src", file.target) : path2.join(targetDir, file.path);
268
- if (await fs2.pathExists(targetPath)) {
269
- if (!options.overwrite && !options.yes) {
270
- spinner.stop();
271
- const { overwrite } = await prompts2({
272
- type: "confirm",
273
- name: "overwrite",
274
- message: `${path2.relative(cwd, targetPath)} already exists. Overwrite?`,
275
- initial: false
276
- });
277
- if (!overwrite) {
278
- logger.info(`Skipping ${path2.basename(targetPath)}`);
279
- continue;
280
- }
281
- spinner.start();
282
- } else if (!options.overwrite) {
283
- logger.info(`Skipping ${path2.basename(targetPath)} (use --overwrite to replace)`);
284
- continue;
285
- }
286
- }
287
- await fs2.ensureDir(path2.dirname(targetPath));
288
- await fs2.writeFile(targetPath, file.content);
289
- installed.push(targetPath);
290
- }
291
- } catch (error) {
292
- errors.push(`${componentName}: ${error instanceof Error ? error.message : "Unknown error"}`);
293
- }
294
- }
295
- spinner.succeed(`Installed ${installed.length} file(s)`);
296
- if (installed.length > 0) {
297
- logger.break();
298
- console.log(chalk3.green("Files created:"));
299
- for (const file of installed) {
300
- console.log(chalk3.gray(" -"), path2.relative(cwd, file));
301
- }
302
- }
303
- if (errors.length > 0) {
304
- logger.break();
305
- console.log(chalk3.red("Errors:"));
306
- for (const error of errors) {
307
- console.log(chalk3.gray(" -"), error);
308
- }
309
- }
310
- logger.break();
311
- } catch (error) {
312
- spinner.fail("Failed to add components");
313
- throw error;
314
- }
315
- }
316
-
317
6
  // src/cli/ux-paths/commands/init.ts
318
7
  import { mkdirSync, writeFileSync, existsSync } from "fs";
319
8
  import { resolve, basename } from "path";
320
- import chalk4 from "chalk";
9
+ import chalk from "chalk";
321
10
  function registerInit(program2) {
322
11
  program2.command("init").description("Bootstrap a .dfl-ux-paths/ directory in the current cwd.").option("--app-id <id>", "App identifier (defaults to current dir name).").option("--force", "Overwrite an existing flows.json if present.", false).action((opts) => {
323
12
  const cwd = process.cwd();
@@ -326,7 +15,7 @@ function registerInit(program2) {
326
15
  const appId = opts.appId || basename(cwd);
327
16
  if (existsSync(flowsPath) && !opts.force) {
328
17
  console.error(
329
- chalk4.yellow(`${flowsPath} already exists. Use --force to overwrite.`)
18
+ chalk.yellow(`${flowsPath} already exists. Use --force to overwrite.`)
330
19
  );
331
20
  process.exit(1);
332
21
  }
@@ -342,9 +31,9 @@ function registerInit(program2) {
342
31
  flows: []
343
32
  };
344
33
  writeFileSync(flowsPath, JSON.stringify(stub, null, 2) + "\n", "utf8");
345
- console.log(chalk4.green("Created"), flowsPath);
34
+ console.log(chalk.green("Created"), flowsPath);
346
35
  console.log(
347
- chalk4.gray(
36
+ chalk.gray(
348
37
  "Next steps: populate screens/flows, then run `dfl-components ux-paths validate` and `dfl-components ux-paths generate-mermaid`."
349
38
  )
350
39
  );
@@ -356,43 +45,441 @@ import { readFileSync, existsSync as existsSync2 } from "fs";
356
45
  import { resolve as resolve2 } from "path";
357
46
  import Ajv2020 from "ajv/dist/2020.js";
358
47
  import addFormats from "ajv-formats";
359
- import chalk5 from "chalk";
48
+ import chalk2 from "chalk";
49
+
50
+ // src/cli/ux-paths/lib/v1.schema.json
51
+ var v1_schema_default = {
52
+ $schema: "https://json-schema.org/draft/2020-12/schema",
53
+ $id: "https://raw.githubusercontent.com/devfellowship/dfl-ux-paths/main/schema/v1.json",
54
+ title: "DFL UX Paths v1",
55
+ description: "Versioned per-app user-flow JSON schema for migration-gap analysis and dead-code detection. Accepts schema_version 1.0.0, 1.1.0, 1.2.0 and 1.3.0 (all additive \u2014 v1.1 adds optional navigation/prereq/test_metadata fields; v1.2 adds optional source_ref (screen\u2192repo file) and screenshots[] (per platform\xD7orientation), and documents `route` as the canonical shared route nomenclature for 1:1 cross-app mapping; v1.3 adds optional source (build|spec), phases[], and phase/description on screen and flow, so the format can also describe a product that has not been built yet. Backward compatible \u2014 v1.0/v1.1/v1.2 files still validate).",
56
+ $comment: "schema v1.2 \u2014 additive over v1.1. New optional screen fields: source_ref { component_file, file_path_chain? } links a screen to the repo file(s) that render it (Figma-Code-Connect style); screenshots[] { platform, orientation, viewport?, url, captured_at? } captures rendered images per platform\xD7orientation for vision-first comparison. `screen.id` is the STICKY 1:1 JOIN KEY across apps \u2014 comparators pair screens by shared id. `route` is the canonical shared route nomenclature (e.g. studio/project/:id/editor) letting two apps map 1:1 by the same name even when their real URLs differ. Draft 2020-12. The file `$id` is intentionally unchanged across additive bumps so consumers pinned to the canonical URL keep working. || schema v1.3 \u2014 additive over v1.2, and the first bump aimed at files authored BEFORE the code exists. `source: \"spec\"` says so explicitly: routes are proposed nomenclature, app_version carries the zero-sha `init` emits, and the absence of source_ref/screenshots/base_url is correct rather than incomplete. `phases[]` declares the app's delivery-phase vocabulary once; `screen.phase` / `flow.phase` reference a phase id. That pair is what lets a viewer render committed scope differently from work the requirement explicitly parks (`phases[].optional: true`) instead of showing one undifferentiated blob \u2014 the distinction a client reads as 'what am I actually getting'. `screen.description` / `flow.description` carry the prose a spec needs and a generated-from-code file usually does not.",
57
+ type: "object",
58
+ required: ["schema_version", "app_id", "app_version", "screens", "flows"],
59
+ additionalProperties: false,
60
+ properties: {
61
+ schema_version: {
62
+ type: "string",
63
+ enum: ["1.0.0", "1.1.0", "1.2.0", "1.3.0"],
64
+ description: "Version of the dfl-ux-paths schema this file conforms to. v1.1.0 adds optional navigation_path, prerequisites, test_metadata, and richer flow step objects. v1.2.0 adds optional screen.source_ref (screen\u2192repo file link) and screen.screenshots[] (per platform\xD7orientation), and documents `route` as the canonical shared route nomenclature for 1:1 cross-app mapping. v1.3.0 adds optional spec-first authoring fields: top-level `source` (build|spec) and `phases[]`, plus optional `phase`/`description` on screens and flows."
65
+ },
66
+ app_id: {
67
+ type: "string",
68
+ minLength: 1,
69
+ description: "Stable identifier for the app (e.g. 'dfl-learn-mobile')."
70
+ },
71
+ app_version: {
72
+ type: "string",
73
+ pattern: "^\\d{4}-\\d{2}-\\d{2}-[0-9a-f]{4,40}$",
74
+ description: 'App build identifier in the form YYYY-MM-DD-<git-sha-short>. A file authored before any build exists uses the zero-sha form `YYYY-MM-DD-0000000` that `dfl-ux-paths init` already emits, and should also set `source: "spec"`.'
75
+ },
76
+ source: {
77
+ type: "string",
78
+ enum: ["build", "spec"],
79
+ description: "v1.3 \u2014 provenance of this file. 'build' (the assumed default when omitted) means it was derived from a shipped build, so `route` values are real and source_ref/screenshots are meaningful. 'spec' means it was authored from a requirement document before the code exists: routes are PROPOSED nomenclature, app_version carries a zero-sha, and the absence of source_ref/screenshots/test_metadata.base_url is expected rather than a gap. Optional, additive."
80
+ },
81
+ phases: {
82
+ type: "array",
83
+ items: { $ref: "#/$defs/phase" },
84
+ description: "v1.3 \u2014 the app's delivery-phase vocabulary, declared once and referenced by `screen.phase` / `flow.phase`. Phase ids are free-form on purpose (every product names its phases differently). Mark a phase `optional: true` when the requirement explicitly parks it outside committed scope. Optional, additive."
85
+ },
86
+ generated_at: {
87
+ type: "string",
88
+ format: "date-time",
89
+ description: "ISO-8601 timestamp when this snapshot was generated."
90
+ },
91
+ tech_stack: {
92
+ type: "array",
93
+ items: { type: "string" },
94
+ description: "List of tech-stack tags (e.g. 'react-native', 'expo', 'powersync')."
95
+ },
96
+ test_metadata: {
97
+ $ref: "#/$defs/testMetadata",
98
+ description: "v1.1 \u2014 app-level metadata used by e2e-user-persona / Playwright skills to bootstrap a session (auth strategy, default viewport, base URL)."
99
+ },
100
+ screens: {
101
+ type: "array",
102
+ items: { $ref: "#/$defs/screen" }
103
+ },
104
+ flows: {
105
+ type: "array",
106
+ items: { $ref: "#/$defs/flow" }
107
+ },
108
+ dead_code: {
109
+ type: "array",
110
+ items: { $ref: "#/$defs/deadCodeEntry" }
111
+ }
112
+ },
113
+ $defs: {
114
+ phase: {
115
+ type: "object",
116
+ required: ["id"],
117
+ additionalProperties: false,
118
+ description: "v1.3 \u2014 one entry in the app's delivery-phase vocabulary.",
119
+ properties: {
120
+ id: {
121
+ type: "string",
122
+ minLength: 1,
123
+ description: "Stable phase id referenced by `screen.phase` / `flow.phase` (e.g. 'alpha', 'beta', 'mvp', 'optional')."
124
+ },
125
+ label: {
126
+ type: "string",
127
+ description: "Human-readable phase name."
128
+ },
129
+ description: {
130
+ type: "string",
131
+ description: "What this phase covers \u2014 ideally quoting the source requirement rather than paraphrasing it."
132
+ },
133
+ optional: {
134
+ type: "boolean",
135
+ description: "True when the requirement explicitly parks this phase outside committed scope (e.g. 'eventual landing, not in MVP'). Viewers and generated Mermaid MUST render it as visually distinct from in-scope work, so a reader never mistakes parked work for a commitment."
136
+ }
137
+ }
138
+ },
139
+ screen: {
140
+ type: "object",
141
+ required: ["id", "name"],
142
+ additionalProperties: false,
143
+ properties: {
144
+ id: {
145
+ type: "string",
146
+ minLength: 1,
147
+ description: "Stable screen identifier (snake_case recommended). REQUIRED. This is the STICKY 1:1 JOIN KEY across apps: cross-app comparators (web \u2194 mobile, old build \u2194 new build) pair screens by shared `id`. Treat it as a contract \u2014 keep ids stable across versions and identical across apps you intend to map 1:1, or diffs/parity comparisons break."
148
+ },
149
+ name: {
150
+ type: "string",
151
+ minLength: 1,
152
+ description: "Human-readable screen name."
153
+ },
154
+ route: {
155
+ type: "string",
156
+ description: "v1.2 \u2014 canonical SHARED route nomenclature for 1:1 cross-app mapping (e.g. 'studio/project/:id/editor'). Lets two apps (web \u2194 mobile) map by the same route name even when their real URLs/deep-links differ. Also doubles as the screen's route/path/deep-link when the app uses a single URL scheme. Use ':param' for dynamic segments. Optional, additive."
157
+ },
158
+ description: {
159
+ type: "string",
160
+ description: "v1.3 \u2014 free-form prose describing this screen. For a spec-first file the description IS the requirement, so keep it faithful to the source document. Optional, additive."
161
+ },
162
+ phase: {
163
+ type: "string",
164
+ minLength: 1,
165
+ description: "v1.3 \u2014 id of the phase (from top-level `phases[]`) this screen belongs to. Optional, additive."
166
+ },
167
+ source_ref: {
168
+ $ref: "#/$defs/sourceRef",
169
+ description: "v1.2 \u2014 link from this screen to the repo file(s) that render it (route/page/modal component). Figma-Code-Connect style. Optional, additive \u2014 populated by the route\u2192file resolver (bin/resolve-routes.mjs) or by hand."
170
+ },
171
+ screenshots: {
172
+ type: "array",
173
+ items: { $ref: "#/$defs/screenshot" },
174
+ description: "v1.2 \u2014 rendered screenshots of this screen, one entry per platform\xD7orientation(\xD7viewport). URLs are committed (e.g. S3 via supabase-upload) so humans AND agents view the same image for vision-first comparison. Optional, additive."
175
+ },
176
+ components: {
177
+ type: "array",
178
+ items: { type: "string" },
179
+ description: "Component identifiers rendered on this screen."
180
+ },
181
+ api_calls: {
182
+ type: "array",
183
+ items: { type: "string" },
184
+ description: "API endpoints or RPC calls invoked from this screen."
185
+ },
186
+ actions: {
187
+ type: "array",
188
+ items: { $ref: "#/$defs/action" }
189
+ },
190
+ navigation_path: {
191
+ type: "array",
192
+ items: { $ref: "#/$defs/navigationStep" },
193
+ description: "v1.1 \u2014 ordered list of UI actions to navigate TO this screen from the app entrypoint. Used by e2e-user-persona to auto-generate persona scripts instead of LLM-driven discovery. When multiple navigationStep entries are present, the runner tries them in order and the first matching selector wins (useful for resilience across renames)."
194
+ },
195
+ prerequisites: {
196
+ $ref: "#/$defs/prerequisites",
197
+ description: "v1.1 \u2014 preconditions that must hold for this screen to be reachable / functional."
198
+ }
199
+ }
200
+ },
201
+ sourceRef: {
202
+ type: "object",
203
+ required: ["component_file"],
204
+ additionalProperties: false,
205
+ description: "v1.2 \u2014 points a screen at the repo file(s) that render it.",
206
+ properties: {
207
+ component_file: {
208
+ type: "string",
209
+ minLength: 1,
210
+ description: "Repo-relative path to the route/page/modal component that renders this screen (e.g. 'app/studio/[id]/editor.tsx' for Expo Router, 'app/studio/[id]/editor/page.tsx' for Next app-router). This is what the route\u2192file resolver emits."
211
+ },
212
+ file_path_chain: {
213
+ type: "array",
214
+ items: { type: "string" },
215
+ minItems: 1,
216
+ description: "Optional \u2014 ordered repo-relative paths from the app entry (e.g. 'App.tsx' / 'app/_layout.tsx') down to the component, giving the 'tree' from app root to this screen's renderer."
217
+ }
218
+ }
219
+ },
220
+ screenshot: {
221
+ type: "object",
222
+ required: ["platform", "orientation", "url"],
223
+ additionalProperties: false,
224
+ description: "v1.2 \u2014 one rendered screenshot of a screen, keyed by platform\xD7orientation(\xD7viewport).",
225
+ properties: {
226
+ platform: {
227
+ type: "string",
228
+ enum: ["web", "mobile"],
229
+ description: "Rendering platform the screenshot was captured on."
230
+ },
231
+ orientation: {
232
+ type: "string",
233
+ enum: ["portrait", "landscape"],
234
+ description: "Device/viewport orientation."
235
+ },
236
+ viewport: {
237
+ type: "string",
238
+ description: "Optional viewport dimensions string, e.g. '375x812' or '1440x900'."
239
+ },
240
+ url: {
241
+ type: "string",
242
+ format: "uri",
243
+ description: "Public URL of the committed screenshot (e.g. S3 via supabase-upload)."
244
+ },
245
+ captured_at: {
246
+ type: "string",
247
+ format: "date-time",
248
+ description: "Optional ISO-8601 timestamp when the screenshot was captured."
249
+ }
250
+ }
251
+ },
252
+ action: {
253
+ type: "object",
254
+ required: ["id", "label"],
255
+ additionalProperties: false,
256
+ properties: {
257
+ id: {
258
+ type: "string",
259
+ minLength: 1
260
+ },
261
+ label: {
262
+ type: "string",
263
+ minLength: 1
264
+ },
265
+ next_screen: {
266
+ type: "string",
267
+ description: "Screen id the user lands on after this action."
268
+ },
269
+ side_effect: {
270
+ type: "string",
271
+ description: "Free-form description of side effect (mutation, analytics, etc.)."
272
+ }
273
+ }
274
+ },
275
+ flow: {
276
+ type: "object",
277
+ required: ["name", "start", "steps"],
278
+ additionalProperties: false,
279
+ properties: {
280
+ name: {
281
+ type: "string",
282
+ minLength: 1
283
+ },
284
+ description: {
285
+ type: "string",
286
+ description: "v1.3 \u2014 free-form prose describing what this flow accomplishes and why it matters. Optional, additive."
287
+ },
288
+ phase: {
289
+ type: "string",
290
+ minLength: 1,
291
+ description: "v1.3 \u2014 id of the phase (from top-level `phases[]`) this flow belongs to. Optional, additive."
292
+ },
293
+ start: {
294
+ type: "string",
295
+ minLength: 1,
296
+ description: "Screen id where the flow starts."
297
+ },
298
+ steps: {
299
+ type: "array",
300
+ items: {
301
+ oneOf: [
302
+ {
303
+ type: "string",
304
+ description: "v1.0 \u2014 screen id traversed in this flow."
305
+ },
306
+ {
307
+ $ref: "#/$defs/flowStepObject"
308
+ }
309
+ ]
310
+ },
311
+ description: "Ordered list of flow steps. v1.0 accepts plain screen ids (strings); v1.1 additionally accepts step objects with action/selector/target_screen for executable scripts. Mixed arrays are allowed during migration."
312
+ },
313
+ actions: {
314
+ type: "array",
315
+ items: { type: "string" },
316
+ description: "Ordered list of action ids exercised during this flow."
317
+ },
318
+ tested_by: {
319
+ type: "array",
320
+ items: { type: "string" },
321
+ description: "References to tests (file paths, test names) that cover this flow."
322
+ }
323
+ }
324
+ },
325
+ flowStepObject: {
326
+ type: "object",
327
+ required: ["screen"],
328
+ additionalProperties: false,
329
+ properties: {
330
+ screen: {
331
+ type: "string",
332
+ minLength: 1,
333
+ description: "Screen id where this step is executed."
334
+ },
335
+ action: {
336
+ type: "string",
337
+ description: "Action verb (tap, type, scroll, swipe, navigate, wait, assert, etc.)."
338
+ },
339
+ selector: {
340
+ type: "string",
341
+ description: "Playwright-compatible selector (e.g. 'text=Next', 'role=button[name=Save]', 'css=[data-testid=foo]')."
342
+ },
343
+ value: {
344
+ type: "string",
345
+ description: "Optional value to type / select / assert."
346
+ },
347
+ target_screen: {
348
+ type: "string",
349
+ description: "Screen id the user lands on after this step."
350
+ },
351
+ wait_for_target: {
352
+ type: "string",
353
+ description: "Selector / condition to wait for to confirm the target screen rendered."
354
+ },
355
+ note: {
356
+ type: "string",
357
+ description: "Free-form annotation for humans / debugging."
358
+ }
359
+ }
360
+ },
361
+ navigationStep: {
362
+ type: "object",
363
+ required: ["selector", "action"],
364
+ additionalProperties: false,
365
+ properties: {
366
+ selector: {
367
+ type: "string",
368
+ minLength: 1,
369
+ description: "Playwright-compatible selector (e.g. 'text=Studio', 'role=tab[name=Studio]', 'css=[data-testid=studio-tab]')."
370
+ },
371
+ action: {
372
+ type: "string",
373
+ enum: ["tap", "click", "type", "scroll", "swipe", "navigate", "wait", "assert"],
374
+ description: "Interaction primitive to execute."
375
+ },
376
+ value: {
377
+ type: "string",
378
+ description: "Optional value for type/select/assert."
379
+ },
380
+ wait_for: {
381
+ type: "string",
382
+ description: "Selector / condition to wait for after performing the action to confirm success."
383
+ },
384
+ note: {
385
+ type: "string",
386
+ description: "Free-form annotation."
387
+ }
388
+ }
389
+ },
390
+ prerequisites: {
391
+ type: "object",
392
+ additionalProperties: false,
393
+ properties: {
394
+ auth_required: {
395
+ type: "boolean",
396
+ description: "True if a logged-in user session is required to reach this screen."
397
+ },
398
+ auth_role: {
399
+ type: "string",
400
+ description: "Optional \u2014 specific role required (e.g. 'admin', 'instructor'). Omit for any authenticated user."
401
+ },
402
+ data_required: {
403
+ type: "array",
404
+ items: { type: "string" },
405
+ description: "Free-form descriptions of DB/state preconditions (e.g. 'lesson_studio.projects has >= 0 rows')."
406
+ },
407
+ preconditions_in_app: {
408
+ type: "array",
409
+ items: { type: "string" },
410
+ description: "Free-form descriptions of in-app preconditions (feature flag enabled, onboarding complete, etc.)."
411
+ },
412
+ feature_flags: {
413
+ type: "array",
414
+ items: { type: "string" },
415
+ description: "Feature flag identifiers that must be enabled."
416
+ }
417
+ }
418
+ },
419
+ testMetadata: {
420
+ type: "object",
421
+ additionalProperties: false,
422
+ properties: {
423
+ auth_strategy: {
424
+ type: "string",
425
+ description: "Brief description of how to authenticate (e.g. 'supabase-anon + CLAUDE_SMOKE creds', 'magic-link', 'oauth google')."
426
+ },
427
+ default_viewport: {
428
+ type: "string",
429
+ enum: ["mobile", "tablet", "desktop", "responsive"],
430
+ description: "Hint for test runners \u2014 preferred viewport for this app."
431
+ },
432
+ base_url: {
433
+ type: "string",
434
+ format: "uri",
435
+ description: "Production / smoke-target base URL (e.g. 'https://mobile.devfellowship.com/')."
436
+ },
437
+ test_user_secret_path: {
438
+ type: "string",
439
+ description: "Infisical path to credentials suitable for smoke tests (e.g. '/shared/CLAUDE_SMOKE_USERNAME')."
440
+ },
441
+ notes: {
442
+ type: "string",
443
+ description: "Free-form notes for test authors / persona-script generators."
444
+ }
445
+ }
446
+ },
447
+ deadCodeEntry: {
448
+ type: "object",
449
+ required: ["component", "reason"],
450
+ additionalProperties: false,
451
+ properties: {
452
+ component: {
453
+ type: "string",
454
+ minLength: 1
455
+ },
456
+ reason: {
457
+ type: "string",
458
+ minLength: 1
459
+ }
460
+ }
461
+ }
462
+ }
463
+ };
360
464
 
361
465
  // src/cli/ux-paths/lib/load-schema.ts
362
- var SCHEMA_URL = "https://raw.githubusercontent.com/devfellowship/dfl-ux-paths/main/schema/v1.json";
363
- var cached = null;
364
466
  async function loadSchemaV1() {
365
- if (cached) return cached;
366
- let res;
367
- try {
368
- res = await fetch(SCHEMA_URL);
369
- } catch (err) {
370
- throw new Error(
371
- `Unable to fetch the UX Paths schema from ${SCHEMA_URL}: ${err.message}`
372
- );
373
- }
374
- if (!res.ok) {
375
- throw new Error(
376
- `Unable to fetch the UX Paths schema from ${SCHEMA_URL}: HTTP ${res.status} ${res.statusText}`
377
- );
378
- }
379
- cached = await res.json();
380
- return cached;
467
+ return v1_schema_default;
381
468
  }
382
469
 
383
470
  // src/cli/ux-paths/commands/validate.ts
384
471
  function registerValidate(program2) {
385
472
  program2.command("validate [path]").description("Validate a flows.json against the DFL UX Paths v1 schema.").action(async (maybePath) => {
386
- const path3 = resolve2(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
387
- if (!existsSync2(path3)) {
388
- console.error(chalk5.red("File not found:"), path3);
473
+ const path = resolve2(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
474
+ if (!existsSync2(path)) {
475
+ console.error(chalk2.red("File not found:"), path);
389
476
  process.exit(1);
390
477
  }
391
478
  let doc;
392
479
  try {
393
- doc = JSON.parse(readFileSync(path3, "utf8"));
480
+ doc = JSON.parse(readFileSync(path, "utf8"));
394
481
  } catch (err) {
395
- console.error(chalk5.red("Invalid JSON:"), err.message);
482
+ console.error(chalk2.red("Invalid JSON:"), err.message);
396
483
  process.exit(1);
397
484
  }
398
485
  const ajv = new Ajv2020({ allErrors: true, strict: false });
@@ -401,19 +488,19 @@ function registerValidate(program2) {
401
488
  try {
402
489
  schema = await loadSchemaV1();
403
490
  } catch (err) {
404
- console.error(chalk5.red("Schema error:"), err.message);
491
+ console.error(chalk2.red("Schema error:"), err.message);
405
492
  process.exit(1);
406
493
  }
407
494
  const validate = ajv.compile(schema);
408
495
  const ok = validate(doc);
409
496
  if (ok) {
410
- console.log(chalk5.green("OK"), path3, "conforms to schema v1.");
497
+ console.log(chalk2.green("OK"), path, "conforms to schema v1.");
411
498
  process.exit(0);
412
499
  }
413
- console.error(chalk5.red("FAIL"), path3);
500
+ console.error(chalk2.red("FAIL"), path);
414
501
  for (const err of validate.errors ?? []) {
415
502
  console.error(
416
- chalk5.yellow(" -"),
503
+ chalk2.yellow(" -"),
417
504
  err.instancePath || "<root>",
418
505
  err.message,
419
506
  err.params ? JSON.stringify(err.params) : ""
@@ -426,7 +513,7 @@ function registerValidate(program2) {
426
513
  // src/cli/ux-paths/commands/generate-mermaid.ts
427
514
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
428
515
  import { resolve as resolve3, dirname } from "path";
429
- import chalk6 from "chalk";
516
+ import chalk3 from "chalk";
430
517
 
431
518
  // src/cli/ux-paths/lib/json-to-mermaid.ts
432
519
  function jsonToMermaid(doc) {
@@ -516,23 +603,23 @@ function escapeLabel(raw) {
516
603
  var HEADER = "%% AUTO-GENERATED by dfl-components ux-paths \u2014 do not edit by hand.\n";
517
604
  function registerGenerateMermaid(program2) {
518
605
  program2.command("generate-mermaid [path]").description("Generate a Mermaid graph from a flows.json file.").option("-o, --out <file>", "Output path (defaults to <dir>/flows.mmd).").action((maybePath, opts) => {
519
- const path3 = resolve3(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
520
- if (!existsSync3(path3)) {
521
- console.error(chalk6.red("File not found:"), path3);
606
+ const path = resolve3(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
607
+ if (!existsSync3(path)) {
608
+ console.error(chalk3.red("File not found:"), path);
522
609
  process.exit(1);
523
610
  }
524
- const doc = JSON.parse(readFileSync2(path3, "utf8"));
525
- const out = opts.out ? resolve3(process.cwd(), opts.out) : resolve3(dirname(path3), "flows.mmd");
611
+ const doc = JSON.parse(readFileSync2(path, "utf8"));
612
+ const out = opts.out ? resolve3(process.cwd(), opts.out) : resolve3(dirname(path), "flows.mmd");
526
613
  const body = HEADER + jsonToMermaid(doc);
527
614
  writeFileSync2(out, body, "utf8");
528
- console.log(chalk6.green("Wrote"), out);
615
+ console.log(chalk3.green("Wrote"), out);
529
616
  });
530
617
  }
531
618
 
532
619
  // src/cli/ux-paths/commands/diff.ts
533
620
  import { readFileSync as readFileSync3, existsSync as existsSync4 } from "fs";
534
621
  import { resolve as resolve4 } from "path";
535
- import chalk7 from "chalk";
622
+ import chalk4 from "chalk";
536
623
 
537
624
  // src/cli/ux-paths/lib/flows-diff.ts
538
625
  function flowsDiff(a, b) {
@@ -612,15 +699,15 @@ function registerDiff(program2) {
612
699
  console.log(JSON.stringify(result, null, 2));
613
700
  return;
614
701
  }
615
- console.log(chalk7.bold(`Diff ${a.app_id}@${a.app_version} \u2192 ${b.app_id}@${b.app_version}`));
616
- printSection("Screens added", result.screens.added, chalk7.green);
617
- printSection("Screens removed", result.screens.removed, chalk7.red);
618
- printSection("Actions added", result.actions.added, chalk7.green);
619
- printSection("Actions removed", result.actions.removed, chalk7.red);
620
- printSection("Flows added", result.flows.added, chalk7.green);
621
- printSection("Flows removed", result.flows.removed, chalk7.red);
702
+ console.log(chalk4.bold(`Diff ${a.app_id}@${a.app_version} \u2192 ${b.app_id}@${b.app_version}`));
703
+ printSection("Screens added", result.screens.added, chalk4.green);
704
+ printSection("Screens removed", result.screens.removed, chalk4.red);
705
+ printSection("Actions added", result.actions.added, chalk4.green);
706
+ printSection("Actions removed", result.actions.removed, chalk4.red);
707
+ printSection("Flows added", result.flows.added, chalk4.green);
708
+ printSection("Flows removed", result.flows.removed, chalk4.red);
622
709
  if (result.flows.changed.length > 0) {
623
- console.log(chalk7.yellow("\nFlows changed:"));
710
+ console.log(chalk4.yellow("\nFlows changed:"));
624
711
  for (const ch of result.flows.changed) {
625
712
  console.log(` - ${ch.name}: ${ch.reason}`);
626
713
  }
@@ -628,12 +715,12 @@ function registerDiff(program2) {
628
715
  });
629
716
  }
630
717
  function loadDoc(p) {
631
- const path3 = resolve4(process.cwd(), p);
632
- if (!existsSync4(path3)) {
633
- console.error(chalk7.red("File not found:"), path3);
718
+ const path = resolve4(process.cwd(), p);
719
+ if (!existsSync4(path)) {
720
+ console.error(chalk4.red("File not found:"), path);
634
721
  process.exit(1);
635
722
  }
636
- return JSON.parse(readFileSync3(path3, "utf8"));
723
+ return JSON.parse(readFileSync3(path, "utf8"));
637
724
  }
638
725
  function printSection(label, items, color) {
639
726
  if (items.length === 0) return;
@@ -648,7 +735,7 @@ ${label} (${items.length}):`));
648
735
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync5 } from "fs";
649
736
  import { resolve as resolve5 } from "path";
650
737
  import { execSync } from "child_process";
651
- import chalk8 from "chalk";
738
+ import chalk5 from "chalk";
652
739
 
653
740
  // src/cli/ux-paths/lib/preserve-format.ts
654
741
  function replaceTopLevelStringField(raw, key, newValue) {
@@ -678,12 +765,12 @@ function registerStamp(program2) {
678
765
  "--no-preserve-format",
679
766
  "Reformat the whole file (legacy). By default, stamp surgically updates only app_version/generated_at, preserving the existing formatting (minimal diff)."
680
767
  ).action((maybePath, opts) => {
681
- const path3 = resolve5(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
682
- if (!existsSync5(path3)) {
683
- console.error(chalk8.red("File not found:"), path3);
768
+ const path = resolve5(process.cwd(), maybePath || ".dfl-ux-paths/flows.json");
769
+ if (!existsSync5(path)) {
770
+ console.error(chalk5.red("File not found:"), path);
684
771
  process.exit(1);
685
772
  }
686
- const raw = readFileSync4(path3, "utf8");
773
+ const raw = readFileSync4(path, "utf8");
687
774
  const doc = JSON.parse(raw);
688
775
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
689
776
  let sha = "0000000";
@@ -709,10 +796,10 @@ function registerStamp(program2) {
709
796
  };
710
797
  output = JSON.stringify(updated, null, 2) + "\n";
711
798
  }
712
- writeFileSync3(path3, output, "utf8");
713
- console.log(chalk8.green("Stamped"), path3, preserve ? chalk8.gray("(minimal diff)") : "");
714
- console.log(chalk8.gray(` app_version: ${newVersion}`));
715
- console.log(chalk8.gray(` generated_at: ${newGeneratedAt}`));
799
+ writeFileSync3(path, output, "utf8");
800
+ console.log(chalk5.green("Stamped"), path, preserve ? chalk5.gray("(minimal diff)") : "");
801
+ console.log(chalk5.gray(` app_version: ${newVersion}`));
802
+ console.log(chalk5.gray(` generated_at: ${newGeneratedAt}`));
716
803
  });
717
804
  }
718
805
 
@@ -729,7 +816,7 @@ function registerUxPaths(program2) {
729
816
  // src/cli/check-style-imports/index.ts
730
817
  import { resolve as resolve6 } from "path";
731
818
  import { existsSync as existsSync6 } from "fs";
732
- import chalk9 from "chalk";
819
+ import chalk6 from "chalk";
733
820
 
734
821
  // src/cli/check-style-imports/detect.ts
735
822
  import { readdirSync, readFileSync as readFileSync5 } from "fs";
@@ -821,7 +908,7 @@ function registerCheckStyleImports(program2) {
821
908
  ).option("--json", "Emit the raw detection result as JSON instead of a human report.").action((maybeDir, opts) => {
822
909
  const root = resolve6(process.cwd(), maybeDir || ".");
823
910
  if (!existsSync6(root)) {
824
- console.error(chalk9.red("Directory not found:"), root);
911
+ console.error(chalk6.red("Directory not found:"), root);
825
912
  process.exit(2);
826
913
  }
827
914
  const result = detectInDir(root);
@@ -832,33 +919,33 @@ function registerCheckStyleImports(program2) {
832
919
  if (!result.conflict) {
833
920
  if (result.hits.length === 0) {
834
921
  console.log(
835
- chalk9.green("OK"),
922
+ chalk6.green("OK"),
836
923
  "no @devfellowship/components/{styles,shadcn} imports found."
837
924
  );
838
925
  } else {
839
926
  const which = result.styles.length > 0 ? "styles" : "shadcn";
840
927
  console.log(
841
- chalk9.green("OK"),
928
+ chalk6.green("OK"),
842
929
  `app imports only @devfellowship/components/${which} (${result.hits.length} reference${result.hits.length === 1 ? "" : "s"}).`
843
930
  );
844
931
  }
845
932
  process.exit(0);
846
933
  }
847
934
  console.error(
848
- chalk9.red("CONFLICT"),
935
+ chalk6.red("CONFLICT"),
849
936
  "this app imports BOTH @devfellowship/components/styles AND /shadcn."
850
937
  );
851
938
  console.error(
852
- chalk9.yellow(
939
+ chalk6.yellow(
853
940
  "\nThese exports define the SAME CSS vars (--background, --primary, \u2026) in INCOMPATIBLE formats:\n - /styles ships them as HEX (#0A0908)\n - /shadcn ships them as HSL CHANNELS (30 11% 4%)\nImporting both clobbers --background \u2014 e.g. hsl(#0A0908) is invalid CSS \u2192 the\ndeclaration drops \u2192 surfaces render TRANSPARENT (the transparent-dialog bug).\n"
854
941
  )
855
942
  );
856
- console.error(chalk9.bold(" /styles imports:"));
943
+ console.error(chalk6.bold(" /styles imports:"));
857
944
  for (const h of result.styles) console.error(` ${h.file}:${h.line} ${h.text}`);
858
- console.error(chalk9.bold(" /shadcn imports:"));
945
+ console.error(chalk6.bold(" /shadcn imports:"));
859
946
  for (const h of result.shadcn) console.error(` ${h.file}:${h.line} ${h.text}`);
860
947
  console.error(
861
- chalk9.cyan(
948
+ chalk6.cyan(
862
949
  `
863
950
  FIX: keep exactly ONE. A DS-native (hex) app imports /styles only; a
864
951
  shadcn-slate (HSL-channel) app imports /shadcn only. NEVER both.
@@ -872,10 +959,8 @@ See: ${DOC_URL}`
872
959
  // src/cli/index.ts
873
960
  var program = new Command();
874
961
  program.name("dfl-components").description(
875
- "DevFellowship components CLI \u2014 add shared components AND map app UX paths (folds the dfl-ux-paths CLI). Shipped as the `dfl-components` bin of @devfellowship/components."
876
- ).version("1.0.0");
877
- program.command("init").description("Initialize your project with dfl-components configuration").option("-y, --yes", "Skip prompts and use defaults").option("-c, --cwd <path>", "Working directory", process.cwd()).action(init);
878
- program.command("add").description("Add a component to your project").argument("[components...]", "Components to add").option("-y, --yes", "Skip confirmation prompts").option("-o, --overwrite", "Overwrite existing files").option("-c, --cwd <path>", "Working directory", process.cwd()).option("-a, --all", "Add all available components").action(add);
962
+ 'DevFellowship components CLI \u2014 map app UX paths (folds the dfl-ux-paths CLI). Shipped as the `dfl-components` bin of @devfellowship/components. The component set is consumed as a library import (`import { Button } from "@devfellowship/components"`), not scaffolded.'
963
+ ).version("3.0.0");
879
964
  registerUxPaths(program);
880
965
  registerCheckStyleImports(program);
881
966
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devfellowship/components",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "DFL Design System — UI components, hooks, utils and providers",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -43,6 +43,7 @@
43
43
  ],
44
44
  "scripts": {
45
45
  "build": "tsup && tsup --config tsup.cli.config.ts",
46
+ "release": "npm run build && changeset publish",
46
47
  "build:lib": "tsup",
47
48
  "build:cli": "tsup --config tsup.cli.config.ts",
48
49
  "build:watch": "tsup --watch",
@@ -68,6 +69,8 @@
68
69
  }
69
70
  },
70
71
  "devDependencies": {
72
+ "@changesets/changelog-github": "^0.7.0",
73
+ "@changesets/cli": "^2.31.0",
71
74
  "@storybook/addon-a11y": "^9.1.20",
72
75
  "@storybook/addon-themes": "^9.1.20",
73
76
  "@storybook/react": "^9.1.20",
@@ -76,9 +79,7 @@
76
79
  "@tailwindcss/vite": "^4.2.2",
77
80
  "@testing-library/jest-dom": "^6.9.1",
78
81
  "@testing-library/react": "^16.3.2",
79
- "@types/fs-extra": "^11.0.4",
80
82
  "@types/node": "^20.0.0",
81
- "@types/prompts": "^2.4.9",
82
83
  "@types/react": "^18.3.28",
83
84
  "@types/react-dom": "^18.3.7",
84
85
  "@vitejs/plugin-react": "^4.7.0",
@@ -128,16 +129,11 @@
128
129
  "clsx": "^2.1.1",
129
130
  "cmdk": "^1.1.1",
130
131
  "commander": "^12.0.0",
131
- "cosmiconfig": "^9.0.0",
132
132
  "date-fns": "^3.6.0",
133
133
  "embla-carousel-react": "^8.6.0",
134
- "fs-extra": "^11.2.0",
135
134
  "input-otp": "^1.4.2",
136
135
  "lucide-react": "^0.462.0",
137
136
  "next-themes": "^0.4.6",
138
- "node-fetch": "^3.3.2",
139
- "ora": "^8.0.1",
140
- "prompts": "^2.4.2",
141
137
  "react-day-picker": "^8.10.1",
142
138
  "react-hook-form": "^7.72.1",
143
139
  "react-resizable-panels": "^2.1.9",