handoff-app 1.2.2-6 → 1.2.2

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,75 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//Users/domagojgojak/.claude/skills/to-prd/**)",
5
+ "Read(//Users/domagojgojak/.claude/skills/**)",
6
+ "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('name:',d.get\\('name'\\)\\); print\\('version:',d.get\\('version'\\)\\); print\\('bin:',json.dumps\\(d.get\\('bin'\\),indent=2\\)\\); print\\('main:',d.get\\('main'\\)\\); print\\('scripts:',json.dumps\\(d.get\\('scripts'\\),indent=2\\)\\)\")",
7
+ "Bash(python3 -c \"import json,sys;d=json.load\\(sys.stdin\\);dd={**d.get\\('dependencies',{}\\),**d.get\\('devDependencies',{}\\)};print\\([k for k in dd if any\\(t in k for t in ['jest','vitest','playwright','mocha','supertest','@testing']\\)] or 'none'\\)\")",
8
+ "Bash(python3 -c \"import json,sys;d=json.load\\(sys.stdin\\);print\\(d.get\\('files','\\(no files field\\)'\\)\\)\")",
9
+ "Bash(npx tsc *)",
10
+ "Bash(npx next *)",
11
+ "Bash(node *)",
12
+ "Read(//Users/domagojgojak/source/repos/convertiv/handoff-demo/**)",
13
+ "Bash(npm run *)",
14
+ "Bash(git -C /Users/domagojgojak/source/repos/convertiv/handoff-app status --short)",
15
+ "Bash(git -C /Users/domagojgojak/source/repos/convertiv/handoff-app diff --stat)",
16
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/badge.css)",
17
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/badge-primary.html)",
18
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/badge.json)",
19
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/main.js)",
20
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/pattern/demo.html)",
21
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/assets/script-l0sNRNKZ.js)",
22
+ "Bash(curl -s -w ' <-HTTP %{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component/../components.json)",
23
+ "Bash(curl -s -w ' <-HTTP %{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component/%2E%2E/secret)",
24
+ "Bash(curl -s -w ' <-HTTP %{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component/nope.css)",
25
+ "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} CT %{content_type}\\\\n' http://localhost:3987/api/docs/artifacts/component/probe.txt)",
26
+ "Bash(curl -s --path-as-is -o /dev/null -w '%{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/%2E%2E/%2E%2E/secret)",
27
+ "Bash(curl -s --path-as-is -o /dev/null -w '%{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component/%2E%2E)",
28
+ "Bash(curl -s --path-as-is -o /dev/null -w '%{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component%2F..%2Fcomponents.json)",
29
+ "Bash(curl -s --path-as-is http://localhost:3987/api/docs/artifacts/component/nope.css)",
30
+ "Bash(curl -s --path-as-is -o /dev/null -w '%{http_code}\\\\n' http://localhost:3987/api/docs/artifacts/component/../../components.json)",
31
+ "Bash(curl -s -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/previews/primary)",
32
+ "Bash(curl -s -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/inspect/primary)",
33
+ "Bash(curl -s -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/assets/style)",
34
+ "Bash(curl -s -w ' %{http_code}\\\\n' http://localhost:3987/api/docs/components/badge/assets/client)",
35
+ "Bash(curl -s -w ' %{http_code}\\\\n' http://localhost:3987/api/docs/components/badge/assets/bogus)",
36
+ "Bash(curl -sL -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/previews/primary/)",
37
+ "Bash(curl -sL -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/inspect/primary/)",
38
+ "Bash(curl -sL -o /dev/null -w '%{http_code} %{content_type}\\\\n' http://localhost:3987/api/docs/components/badge/assets/style/)",
39
+ "Bash(curl -sL -w ' %{http_code}\\\\n' http://localhost:3987/api/docs/components/badge/assets/client/)",
40
+ "Bash(curl -sL -w ' %{http_code}\\\\n' http://localhost:3987/api/docs/components/badge/assets/bogus/)",
41
+ "Bash(curl -s http://localhost:3987/api/docs/components.json)",
42
+ "Bash(grep -v \"deprecated\\\\|create-next-app\\\\|migrate\\\\|codemod\\\\|^$\\\\|Disabling\\\\|disable some\\\\|Learn more\\\\|ESLint CLI\")",
43
+ "Bash(grep -rn \"['\\\\\"\\\\`].*api/\\\\\\(component\\\\|pattern\\\\|components\\\\|patterns\\\\\\)\" src/app src/templates)",
44
+ "Bash(echo \"EXIT:$?\")",
45
+ "Bash(git log *)",
46
+ "Bash(echo \"EXIT: $?\")",
47
+ "WebSearch",
48
+ "Bash(python3 -m json.tool)",
49
+ "Bash(find . -maxdepth 3 -name 'next-server.js')",
50
+ "Bash(find . -maxdepth 2 -name 'node_modules' -type d)",
51
+ "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('basePath:',d.get\\('basePath'\\)\\); print\\('dynamicRoutes:',[r['page'] for r in d.get\\('dynamicRoutes',[]\\)]\\); print\\('staticRoutes:',[r['page'] for r in d.get\\('staticRoutes',[]\\)][:20]\\)\")",
52
+ "WebFetch(domain:raw.githubusercontent.com)",
53
+ "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('runtime:', json.dumps\\(d.get\\('runtime'\\)\\)\\)\")",
54
+ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('appDir/relativeAppDir:', d.get\\('relativeAppDir'\\)\\); print\\('config.distDir:', d['config'].get\\('distDir'\\)\\); print\\('outputFileTracingRoot:', d['config'].get\\('outputFileTracingRoot'\\)\\)\")",
55
+ "Bash(HOME=/tmp node -e \"try { require\\('./index.js'\\); console.log\\('LOADED OK'\\); } catch\\(e\\){ console.log\\('COLD-START CRASH:', e.code||'', e.message.split\\('\\\\n'\\)[0]\\); }\")",
56
+ "Bash(cp tsconfig.json /tmp/_tsconfig.bak)",
57
+ "Bash(python3 -c ' *)",
58
+ "Bash(npm pack *)",
59
+ "Bash(python3 -c \"import json; print\\(json.dumps\\(json.load\\(open\\('package.json'\\)\\).get\\('scripts',{}\\),indent=2\\)\\)\")",
60
+ "Bash(python3 -)",
61
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)",
62
+ "Bash(pkill -f \"handoff-app dev\")",
63
+ "Bash(pkill -f \"next dev\")",
64
+ "Bash(rm -f /tmp/demo_*.html /tmp/handoff-demo-dev.log)",
65
+ "Bash(grep -vE \"postman|package-lock|package.json$\")",
66
+ "Bash(mv ./handoff-app-2.0.0.tgz ../handoff-demo/packages/)",
67
+ "Bash(rm -rf node_modules/handoff-app node_modules/.cache)",
68
+ "Bash(npm install *)",
69
+ "Bash(echo \"exit: $?\")",
70
+ "Bash(echo \"---exit $?---\")",
71
+ "Bash(git config *)",
72
+ "Bash(echo \"---exit: $?\")"
73
+ ]
74
+ }
75
+ }
package/Changelog.md CHANGED
@@ -6,19 +6,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to
7
7
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
- ## \[1.2.2] - 2026-04-09
9
+ ## \[1.2.2] - TBD
10
10
 
11
- This patch release clears ESLint failures in the Next.js app pages and aligns
12
- several code paths with the compiler target so production builds succeed without
13
- enabling `--downlevelIteration`.
11
+ This patch release improves watch-mode reliability for patterns, broadens slot
12
+ default typing, clears downstream lint/build compatibility issues, and fixes a
13
+ few release-branch UI and dependency issues.
14
14
 
15
- ### Change
15
+ ### Added
16
16
 
17
- * Altered the avaliable types to the SlotMetadata so the default can handle
18
- a wide variaty of types - `default?: string | number | boolean | object | any[] | null;`
17
+ * Added `watchPatternDirectories` so newly created pattern subdirectories are
18
+ detected during `start` without restarting the dev server.
19
+ * Added a shared `NotFound` component and reused it for stale pattern URLs and
20
+ missing markdown pages.
19
21
 
20
- ### Lint and code quality
22
+ ### Changed
21
23
 
24
+ * Broadened `SlotMetadata.default` so slot defaults can be strings, numbers,
25
+ booleans, objects, arrays, or `null`.
26
+ * Refactored component and pattern directory watching through a shared
27
+ `watchEntityDirectories` helper.
28
+ * Downgraded incomplete or mid-edit pattern declarations from hard parse errors
29
+ to warnings, allowing watch mode to retry automatically after the file is
30
+ saved with valid content.
31
+ * Removed unneeded theme declarations.
32
+
33
+ ### Fixed
34
+
35
+ * Fixed watch-mode rebuilds for newly added patterns by rebuilding referenced
36
+ component previews before composing the new pattern.
37
+ * Fixed pattern watcher edge cases where declaration IDs differ from directory
38
+ names, and where an initially incomplete pattern config later becomes valid.
39
+ * Fixed `.handoff.ts` declaration files not being detected by the new-entity
40
+ config-file gate.
41
+ * Extended component build-cache inputs to include pattern config files that
42
+ reference the component, preventing stale preview output after pattern changes.
43
+ * Fixed select/list handling when a component group or category value is empty.
44
+ * Fixed CSS typing issues and app build issues found during the release branch.
22
45
  * Resolved `no-else-return`, `no-unused-vars`, `camelcase`, `prefer-const`,
23
46
  `eqeqeq`, `no-nested-ternary`, `no-lonely-if`, and `no-use-before-define`
24
47
  across static pages (catch-all docs, assets, foundations, home, system, and
@@ -26,17 +49,16 @@ enabling `--downlevelIteration`.
26
49
  * Removed or refactored unused `getStaticProps` context parameters, props, and
27
50
  locals; normalized naming (e.g. font machine keys); simplified icon detail
28
51
  routing query handling.
29
- * Reordered helper components (token color/effect/typography tables, component
30
- token previews) ahead of page components where required for declaration order.
52
+ * Reordered helper components ahead of page components where required for
53
+ declaration order.
31
54
  * Converted `getComponentPreviews` to a hoisted `function` declaration and
32
55
  refactored the component token table for clearer control flow.
56
+ * Avoided iterating `Map`, `Set`, `Iterable`, and `matchAll` results directly in
57
+ `for...of` where the compile target requires `--downlevelIteration`.
33
58
 
34
- ### Build / TypeScript compatibility
59
+ ### Security
35
60
 
36
- * Avoid iterating `Map` / `Set` / `Iterable` / `matchAll` results directly in
37
- `for...of` where the compile target requires it: use `Array.from(...)` or
38
- equivalent helpers in the config-diff registry, snapshot diff, CSF import
39
- parsing, and component artifact sync.
61
+ * Updated Vite to address a security issue.
40
62
 
41
63
  ## \[1.2.1] - 2026-04-08
42
64
 
@@ -196,11 +196,13 @@ const watchApp = (handoff) => __awaiter(void 0, void 0, void 0, function* () {
196
196
  runtimeComponentsWatcher: null,
197
197
  runtimeConfigurationWatcher: null,
198
198
  componentDirectoriesWatcher: null,
199
+ patternDirectoriesWatcher: null,
199
200
  };
200
201
  (0, watchers_1.watchPublicDirectory)(handoff, wss, state, chokidarConfig);
201
202
  (0, watchers_1.watchRuntimeComponents)(handoff, state, (0, watchers_1.getRuntimeComponentsPathsToWatch)(handoff));
202
203
  (0, watchers_1.watchRuntimeConfiguration)(handoff, state);
203
204
  (0, watchers_1.watchComponentDirectories)(handoff, state, chokidarConfig);
205
+ (0, watchers_1.watchPatternDirectories)(handoff, state, chokidarConfig);
204
206
  (0, watchers_1.watchGlobalEntries)(handoff, state, chokidarConfig);
205
207
  (0, watchers_1.watchPages)(handoff, chokidarConfig);
206
208
  });
@@ -5,6 +5,8 @@ export interface WatcherState {
5
5
  runtimeComponentsWatcher: chokidar.FSWatcher | null;
6
6
  runtimeConfigurationWatcher: chokidar.FSWatcher | null;
7
7
  componentDirectoriesWatcher: chokidar.FSWatcher | null;
8
+ /** Watches entries.patterns parent directories for newly created pattern subdirectories. */
9
+ patternDirectoriesWatcher: chokidar.FSWatcher | null;
8
10
  }
9
11
  /**
10
12
  * Schedules a handler for execution with mutual exclusion and per-source
@@ -36,9 +36,19 @@ export declare const watchRuntimeComponents: (handoff: Handoff, state: WatcherSt
36
36
  */
37
37
  export declare const watchRuntimeConfiguration: (handoff: Handoff, state: WatcherState) => void;
38
38
  /**
39
- * Watches the parent component directories from config.entries.components
40
- * for new components being added. When a new config file (e.g. button.json)
41
- * appears in a new subdirectory, reloads the runtime config and restarts
42
- * the component/configuration watchers so the new component is picked up.
39
+ * Watches the parent component directories from config.entries.components for
40
+ * new components being added. When a new config file appears in a new
41
+ * subdirectory, reloads the runtime config, restarts the component and
42
+ * configuration watchers, then builds the new component and any patterns that
43
+ * reference it.
43
44
  */
44
45
  export declare const watchComponentDirectories: (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => void;
46
+ /**
47
+ * Watches the parent pattern directories from config.entries.patterns for new
48
+ * patterns being added. The rebuild sequence intentionally differs from
49
+ * watchComponentDirectories because the dependency arrow is reversed: a new
50
+ * pattern references existing components, so we rebuild only the preview
51
+ * segment of those components (to produce the __pattern_* HTML files that
52
+ * injectPatternPreviews registered) before composing the pattern itself.
53
+ */
54
+ export declare const watchPatternDirectories: (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => void;
@@ -35,10 +35,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  return (mod && mod.__esModule) ? mod : { "default": mod };
36
36
  };
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.watchComponentDirectories = exports.watchRuntimeConfiguration = exports.watchRuntimeComponents = exports.watchGlobalEntries = exports.watchPages = exports.watchAppSource = exports.watchPublicDirectory = exports.mapEntryTypeToSegment = exports.getRuntimeComponentsPathsToWatch = void 0;
38
+ exports.watchPatternDirectories = exports.watchComponentDirectories = exports.watchRuntimeConfiguration = exports.watchRuntimeComponents = exports.watchGlobalEntries = exports.watchPages = exports.watchAppSource = exports.watchPublicDirectory = exports.mapEntryTypeToSegment = exports.getRuntimeComponentsPathsToWatch = void 0;
39
39
  const chokidar_1 = __importDefault(require("chokidar"));
40
40
  const fs_extra_1 = __importDefault(require("fs-extra"));
41
41
  const path_1 = __importDefault(require("path"));
42
+ const patterns_1 = require("../pipeline/patterns");
42
43
  const builder_1 = __importStar(require("../transformers/preview/component/builder"));
43
44
  const css_1 = require("../transformers/preview/component/css");
44
45
  const javascript_1 = require("../transformers/preview/component/javascript");
@@ -226,6 +227,15 @@ const watchRuntimeComponents = (handoff, state, runtimeComponentPathsToWatch) =>
226
227
  }
227
228
  };
228
229
  exports.watchRuntimeComponents = watchRuntimeComponents;
230
+ const rebuildPatternComponentPreviews = (handoff, patternId) => __awaiter(void 0, void 0, void 0, function* () {
231
+ var _a, _b, _c, _d;
232
+ const pattern = (_c = (_b = (_a = handoff.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.patterns) === null || _c === void 0 ? void 0 : _c[patternId];
233
+ if (!((_d = pattern === null || pattern === void 0 ? void 0 : pattern.components) === null || _d === void 0 ? void 0 : _d.length))
234
+ return;
235
+ for (const ref of pattern.components) {
236
+ yield (0, builder_1.default)(handoff, ref.id, builder_1.ComponentSegment.Previews);
237
+ }
238
+ });
229
239
  /**
230
240
  * Watches the runtime configuration for changes.
231
241
  *
@@ -269,6 +279,9 @@ const watchRuntimeConfiguration = (handoff, state) => {
269
279
  else if (handle) {
270
280
  yield handle.apply(handoff, entryAfter === null || entryAfter === void 0 ? void 0 : entryAfter.entityId);
271
281
  }
282
+ else if ((entryAfter === null || entryAfter === void 0 ? void 0 : entryAfter.kind) === 'pattern') {
283
+ yield rebuildPatternComponentPreviews(handoff, entryAfter.entityId);
284
+ }
272
285
  else {
273
286
  const effectiveId = (_a = entryAfter === null || entryAfter === void 0 ? void 0 : entryAfter.entityId) !== null && _a !== void 0 ? _a : path_1.default.basename(path_1.default.dirname(changedFile));
274
287
  yield (0, builder_1.default)(handoff, effectiveId);
@@ -286,56 +299,138 @@ const watchRuntimeConfiguration = (handoff, state) => {
286
299
  };
287
300
  exports.watchRuntimeConfiguration = watchRuntimeConfiguration;
288
301
  /**
289
- * Watches the parent component directories from config.entries.components
290
- * for new components being added. When a new config file (e.g. button.json)
291
- * appears in a new subdirectory, reloads the runtime config and restarts
292
- * the component/configuration watchers so the new component is picked up.
302
+ * Shared factory for watching a parent directory for newly created entity
303
+ * subdirectories (components or patterns). Handles the common scaffolding so
304
+ * that callers only describe what differs for their entity kind:
305
+ *
306
+ * - which config-entry paths to watch
307
+ * - how to read the current known ids after a reload
308
+ * - which WatcherState slot to use
309
+ * - a log label for the entity kind
310
+ * - the rebuild work to run once the runtime config is fresh
293
311
  */
294
- const watchComponentDirectories = (handoff, state, chokidarConfig) => {
295
- var _a, _b, _c, _d, _e;
296
- if (state.componentDirectoriesWatcher) {
297
- state.componentDirectoriesWatcher.close();
312
+ const watchEntityDirectories = (handoff, state, chokidarConfig, options) => {
313
+ const { getConfigPaths, getKnownIds, getWatcher, setWatcher, scheduleKeyPrefix, entityLabel, onDetected } = options;
314
+ const existingWatcher = getWatcher(state);
315
+ if (existingWatcher) {
316
+ existingWatcher.close();
298
317
  }
299
- const componentPaths = (_b = (_a = handoff.config.entries) === null || _a === void 0 ? void 0 : _a.components) !== null && _b !== void 0 ? _b : [];
300
- if (componentPaths.length === 0)
318
+ const configPaths = getConfigPaths(handoff);
319
+ if (configPaths.length === 0)
301
320
  return;
302
321
  const dirsToWatch = [];
303
- for (const componentPath of componentPaths) {
304
- const resolved = path_1.default.resolve(handoff.workingPath, componentPath);
322
+ for (const configPath of configPaths) {
323
+ const resolved = path_1.default.resolve(handoff.workingPath, configPath);
305
324
  if (fs_extra_1.default.existsSync(resolved) && fs_extra_1.default.statSync(resolved).isDirectory()) {
306
325
  dirsToWatch.push(resolved);
307
326
  }
308
327
  }
309
328
  if (dirsToWatch.length === 0)
310
329
  return;
311
- const knownComponents = new Set(Object.keys((_e = (_d = (_c = handoff.runtimeConfig) === null || _c === void 0 ? void 0 : _c.entries) === null || _d === void 0 ? void 0 : _d.components) !== null && _e !== void 0 ? _e : {}));
312
- state.componentDirectoriesWatcher = chokidar_1.default.watch(dirsToWatch, Object.assign(Object.assign({}, chokidarConfig), { depth: 1 }));
313
- state.componentDirectoriesWatcher.on('add', (file) => __awaiter(void 0, void 0, void 0, function* () {
330
+ // Snapshot known ids at watch startup so we only react to genuinely new
331
+ // entities, not to file changes inside already-known directories.
332
+ const knownIds = new Set(getKnownIds(handoff));
333
+ const watcher = chokidar_1.default.watch(dirsToWatch, Object.assign(Object.assign({}, chokidarConfig), { depth: 1 }));
334
+ setWatcher(state, watcher);
335
+ watcher.on('add', (file) => __awaiter(void 0, void 0, void 0, function* () {
314
336
  const basename = path_1.default.basename(file);
315
337
  const dirName = path_1.default.basename(path_1.default.dirname(file));
316
- const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs');
317
- const isNewComponent = isConfigFile && basename.startsWith(dirName) && !knownComponents.has(dirName);
318
- if (!isNewComponent)
338
+ // Only react to the primary declaration file for the directory (e.g.
339
+ // button.json, button.js, or button.handoff.ts inside a button/ subdir).
340
+ // The basename.startsWith(dirName) guard below ensures .ts here only
341
+ // ever matches files named after their parent directory, not arbitrary
342
+ // TypeScript source files.
343
+ const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs') || basename.endsWith('.ts');
344
+ const isNewEntity = isConfigFile && basename.startsWith(dirName) && !knownIds.has(dirName);
345
+ if (!isNewEntity)
319
346
  return;
320
- yield (0, utils_1.scheduleHandler)(state, `newComponent:${dirName}`, () => __awaiter(void 0, void 0, void 0, function* () {
321
- var _a, _b, _c;
347
+ yield (0, utils_1.scheduleHandler)(state, `${scheduleKeyPrefix}:${dirName}`, () => __awaiter(void 0, void 0, void 0, function* () {
322
348
  try {
323
- logger_1.Logger.warn(`New component detected: ${dirName}. Reloading configuration...`);
349
+ logger_1.Logger.warn(`New ${entityLabel} detected: ${dirName}. Reloading configuration...`);
350
+ // Snapshot ids before reload so we can tell whether the new entity
351
+ // was successfully registered afterwards.
352
+ const idsBefore = new Set(getKnownIds(handoff));
324
353
  handoff.reload();
325
- knownComponents.add(dirName);
326
- for (const id of Object.keys((_c = (_b = (_a = handoff.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.components) !== null && _c !== void 0 ? _c : {})) {
327
- knownComponents.add(id);
354
+ // Refresh from the post-reload runtime config so any ids that were
355
+ // discovered alongside the new entity are also marked as known.
356
+ const idsAfter = getKnownIds(handoff);
357
+ const addedIds = idsAfter.filter((id) => !idsBefore.has(id));
358
+ for (const id of idsAfter) {
359
+ knownIds.add(id);
328
360
  }
329
361
  yield (0, client_config_1.persistClientConfig)(handoff);
362
+ // Re-register file watchers so the new config file and any runtime
363
+ // files it introduces are covered going forward. This must happen even
364
+ // when the file failed to parse so that watchRuntimeConfiguration can
365
+ // trigger a retry the moment the file is saved with valid content.
330
366
  (0, exports.watchRuntimeComponents)(handoff, state, (0, component_1.getRuntimeComponentsPathsToWatch)(handoff));
331
367
  (0, exports.watchRuntimeConfiguration)(handoff, state);
332
- yield (0, builder_1.default)(handoff, dirName);
333
- yield (0, config_diff_1.runAllFinalizers)(handoff, { patternRebuildComponentIds: [dirName] });
368
+ // If no new ids appeared after reload the file was empty or invalid.
369
+ // Skip the build — watchRuntimeConfiguration will fire when it is saved.
370
+ if (addedIds.length === 0) {
371
+ logger_1.Logger.warn(`${entityLabel} "${dirName}" config is empty or incomplete — build will run automatically once the file is saved.`);
372
+ return;
373
+ }
374
+ yield onDetected(handoff, { dirName, addedIds });
334
375
  }
335
376
  catch (e) {
336
- logger_1.Logger.error('Error processing new component:', e);
377
+ logger_1.Logger.error(`Error processing new ${entityLabel}:`, e);
337
378
  }
338
379
  }));
339
380
  }));
340
381
  };
382
+ /**
383
+ * Watches the parent component directories from config.entries.components for
384
+ * new components being added. When a new config file appears in a new
385
+ * subdirectory, reloads the runtime config, restarts the component and
386
+ * configuration watchers, then builds the new component and any patterns that
387
+ * reference it.
388
+ */
389
+ const watchComponentDirectories = (handoff, state, chokidarConfig) => {
390
+ watchEntityDirectories(handoff, state, chokidarConfig, {
391
+ getConfigPaths: (h) => { var _a, _b; return (_b = (_a = h.config.entries) === null || _a === void 0 ? void 0 : _a.components) !== null && _b !== void 0 ? _b : []; },
392
+ getKnownIds: (h) => { var _a, _b, _c; return Object.keys((_c = (_b = (_a = h.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.components) !== null && _c !== void 0 ? _c : {}); },
393
+ getWatcher: (s) => s.componentDirectoriesWatcher,
394
+ setWatcher: (s, w) => { s.componentDirectoriesWatcher = w; },
395
+ scheduleKeyPrefix: 'newComponent',
396
+ entityLabel: 'component',
397
+ onDetected: (handoff_1, _a) => __awaiter(void 0, [handoff_1, _a], void 0, function* (handoff, { addedIds }) {
398
+ for (const componentId of addedIds) {
399
+ yield (0, builder_1.default)(handoff, componentId);
400
+ yield (0, config_diff_1.runAllFinalizers)(handoff, { patternRebuildComponentIds: [componentId] });
401
+ }
402
+ }),
403
+ });
404
+ };
341
405
  exports.watchComponentDirectories = watchComponentDirectories;
406
+ /**
407
+ * Watches the parent pattern directories from config.entries.patterns for new
408
+ * patterns being added. The rebuild sequence intentionally differs from
409
+ * watchComponentDirectories because the dependency arrow is reversed: a new
410
+ * pattern references existing components, so we rebuild only the preview
411
+ * segment of those components (to produce the __pattern_* HTML files that
412
+ * injectPatternPreviews registered) before composing the pattern itself.
413
+ */
414
+ const watchPatternDirectories = (handoff, state, chokidarConfig) => {
415
+ watchEntityDirectories(handoff, state, chokidarConfig, {
416
+ getConfigPaths: (h) => { var _a, _b; return (_b = (_a = h.config.entries) === null || _a === void 0 ? void 0 : _a.patterns) !== null && _b !== void 0 ? _b : []; },
417
+ getKnownIds: (h) => { var _a, _b, _c; return Object.keys((_c = (_b = (_a = h.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.patterns) !== null && _c !== void 0 ? _c : {}); },
418
+ getWatcher: (s) => s.patternDirectoriesWatcher,
419
+ setWatcher: (s, w) => { s.patternDirectoriesWatcher = w; },
420
+ scheduleKeyPrefix: 'newPattern',
421
+ entityLabel: 'pattern',
422
+ onDetected: (handoff_1, _a) => __awaiter(void 0, [handoff_1, _a], void 0, function* (handoff, { addedIds }) {
423
+ // Rebuild only the previews segment for each component the new pattern
424
+ // references. Their JS/CSS/structure are unchanged — only the new
425
+ // synthetic preview HTML files need to be generated so that buildPatterns
426
+ // can assemble the pattern from them.
427
+ for (const patternId of addedIds) {
428
+ yield rebuildPatternComponentPreviews(handoff, patternId);
429
+ }
430
+ // Build only the new pattern directly rather than going through
431
+ // runAllFinalizers, which would rebuild all patterns unnecessarily.
432
+ yield (0, patterns_1.buildPatterns)(handoff, { onlyPatternIds: new Set(addedIds) });
433
+ }),
434
+ });
435
+ };
436
+ exports.watchPatternDirectories = watchPatternDirectories;
@@ -136,7 +136,7 @@ function haveGlobalDepsChanged(cached, current) {
136
136
  * Gets all file paths that should be tracked for a component
137
137
  */
138
138
  function getComponentFilePaths(handoff, componentId) {
139
- var _a, _b, _c;
139
+ var _a, _b, _c, _d, _e, _f, _g;
140
140
  const runtimeComponent = (_c = (_b = (_a = handoff.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.components) === null || _c === void 0 ? void 0 : _c[componentId];
141
141
  if (!runtimeComponent) {
142
142
  return { files: [] };
@@ -169,7 +169,7 @@ function getComponentFilePaths(handoff, componentId) {
169
169
  componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(templatePath)));
170
170
  }
171
171
  }
172
- catch (_d) {
172
+ catch (_h) {
173
173
  // File doesn't exist, still add to track
174
174
  files.push(templatePath);
175
175
  componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(templatePath)));
@@ -202,6 +202,24 @@ function getComponentFilePaths(handoff, componentId) {
202
202
  if (matchingConfigPath) {
203
203
  files.push(matchingConfigPath);
204
204
  }
205
+ // Include every pattern config file whose declaration references this component.
206
+ // When a pattern is added or modified, injectPatternPreviews injects new
207
+ // __pattern_* synthetic preview keys onto the referenced component, changing
208
+ // what processComponents must render. Without tracking these files here, the
209
+ // cache would incorrectly consider the component unchanged and skip the preview
210
+ // rebuild, leaving buildPatterns unable to find the required HTML fragments.
211
+ const runtimePatterns = (_f = (_e = (_d = handoff.runtimeConfig) === null || _d === void 0 ? void 0 : _d.entries) === null || _e === void 0 ? void 0 : _e.patterns) !== null && _f !== void 0 ? _f : {};
212
+ for (const configPath of configPaths) {
213
+ const entry = handoff.getConfigFileEntry(configPath);
214
+ if ((entry === null || entry === void 0 ? void 0 : entry.kind) !== 'pattern')
215
+ continue;
216
+ const pattern = runtimePatterns[entry.entityId];
217
+ if (!pattern)
218
+ continue;
219
+ if ((_g = pattern.components) === null || _g === void 0 ? void 0 : _g.some((ref) => ref.id === componentId)) {
220
+ files.push(configPath);
221
+ }
222
+ }
205
223
  return { files, templateDir };
206
224
  }
207
225
  /**
@@ -220,7 +220,12 @@ const initRuntimeConfig = (handoff) => {
220
220
  });
221
221
  }
222
222
  catch (err) {
223
- logger_1.Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err);
223
+ // Treat as a warning rather than an error: the file may be empty or
224
+ // mid-edit. It is already tracked in configFiles above, so
225
+ // watchRuntimeConfiguration will pick it up once the file is saved
226
+ // with valid content.
227
+ logger_1.Logger.warn(`Pattern config skipped (incomplete or invalid) — will retry on next save: ${resolvedPatternConfigPath}`);
228
+ logger_1.Logger.debug(`Pattern parse detail:`, err);
224
229
  continue;
225
230
  }
226
231
  configFileIndex.set((0, path_2.normalizePathForCompare)(resolvedPatternConfigPath), { kind: 'pattern', entityId: pattern.id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "1.2.2-6",
3
+ "version": "1.2.2",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -76,114 +76,114 @@ export const ComponentList = ({
76
76
  return (
77
77
  <TooltipProvider delayDuration={0}>
78
78
  <div className="mx-auto w-full mb-4">
79
- <div className="flex justify-between bg-accent rounded-xl mb-8">
80
- <div className="mr-auto flex items-center gap-3 bg-accent p-2.5 rounded-md">
81
- <div className="relative">
82
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
83
- <Input
84
- type="text"
85
- placeholder="Search components..."
86
- className="rounded-md pl-9 pr-3 py-1 text-sm border-none shadow-none"
87
- onChange={e => setSearch(e.currentTarget.value)}
88
- aria-label="Search components"
89
- style={{ minWidth: 200 }}
90
-
91
- />
92
- </div>
79
+ <div className="flex justify-between bg-accent rounded-xl mb-8">
80
+ <div className="mr-auto flex items-center gap-3 bg-accent p-2.5 rounded-md">
81
+ <div className="relative">
82
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
83
+ <Input
84
+ type="text"
85
+ placeholder="Search components..."
86
+ className="rounded-md pl-9 pr-3 py-1 text-sm border-none shadow-none"
87
+ onChange={e => setSearch(e.currentTarget.value)}
88
+ aria-label="Search components"
89
+ style={{ minWidth: 200 }}
90
+
91
+ />
92
+ </div>
93
93
 
94
- </div>
95
- <div className="flex justify-end p-2.5 gap-2.5">
96
- <div className="flex items-center gap-0.5 border border-input bg-background shadow-xs hover:text-accent-foreground p-0.5 rounded-md">
97
- <Select
98
- value={category}
99
- onValueChange={val => {
100
- setCategory(val);
101
- }}
102
- aria-label="Filter by category"
103
- >
104
- <SelectTrigger className="rounded-md [&_span]:text-xs [&_svg]:ml-2.5 [&_svg]:size-3 py-1 px-2 pl-3 text-sm border-none shadow-none bg-transparent focus:border-blue-400 focus:outline-none">
105
- <SelectValue placeholder="All Categories" />
106
- </SelectTrigger>
107
- <SelectContent>
108
- <SelectItem value="all">All Categories</SelectItem>
109
- {categories.map((category) => (
110
- <SelectItem key={category} value={category}>
111
- {category}
112
- </SelectItem>
113
- ))}
114
- </SelectContent>
115
- </Select>
116
-
117
- <hr className="h-7 w-px bg-input" />
118
-
119
- <Tooltip>
120
- <TooltipTrigger asChild>
121
- <Button variant="ghost" className={cn("w-9", groupBy && 'bg-accent text-accent-foreground')} onClick={() => setGroupBy(!groupBy)}>
122
- <Rows className="size-3.5" strokeWidth={1.5} />
123
- <span className="sr-only">Group by category</span>
124
- </Button>
125
- </TooltipTrigger>
126
- <TooltipContent>
127
- Group by category
128
- </TooltipContent>
129
- </Tooltip>
130
94
  </div>
131
- <ToggleGroup className='flex gap-0.5 border border-input bg-background shadow-xs hover:text-accent-foreground p-0.5 rounded-md' type="single" value={layout} onValueChange={(value) => storeLayout(value)}>
132
- <Tooltip>
133
- <TooltipTrigger asChild>
134
- <ToggleGroupItem className={cn("size-9 py-2 px-2 flex items-center justify-center hover:bg-accent rounded-md", layout === 'grid' && 'bg-accent text-accent-foreground')} value="grid" aria-label="Grid layout" >
135
- <LayoutGrid className="size-3.5" strokeWidth={1.5} />
136
- </ToggleGroupItem>
137
- </TooltipTrigger>
138
- <TooltipContent>
139
- Grid layout
140
- </TooltipContent>
141
- </Tooltip>
142
- <Tooltip>
143
- <TooltipTrigger asChild>
144
- <ToggleGroupItem className={cn("size-9 py-2 px-2 flex items-center justify-center hover:bg-accent rounded-md", layout === 'single' && 'bg-accent text-accent-foreground')} value="single" aria-label="Single column layout" >
145
- <AlignJustify className="size-3.5" strokeWidth={1.5} />
146
- </ToggleGroupItem>
147
- </TooltipTrigger>
148
- <TooltipContent>
149
- Column layout
150
- </TooltipContent>
151
- </Tooltip>
152
- </ToggleGroup>
95
+ <div className="flex justify-end p-2.5 gap-2.5">
96
+ <div className="flex items-center gap-0.5 border border-input bg-background shadow-xs hover:text-accent-foreground p-0.5 rounded-md">
97
+ <Select
98
+ value={category}
99
+ onValueChange={val => {
100
+ setCategory(val);
101
+ }}
102
+ aria-label="Filter by category"
103
+ >
104
+ <SelectTrigger className="rounded-md [&_span]:text-xs [&_svg]:ml-2.5 [&_svg]:size-3 py-1 px-2 pl-3 text-sm border-none shadow-none bg-transparent focus:border-blue-400 focus:outline-none">
105
+ <SelectValue placeholder="All Categories" />
106
+ </SelectTrigger>
107
+ <SelectContent>
108
+ <SelectItem value="all">All Categories</SelectItem>
109
+ {categories.filter((category) => category !== '').map((category) => (
110
+ <SelectItem key={category} value={category}>
111
+ {category}
112
+ </SelectItem>
113
+ ))}
114
+ </SelectContent>
115
+ </Select>
116
+
117
+ <hr className="h-7 w-px bg-input" />
118
+
119
+ <Tooltip>
120
+ <TooltipTrigger asChild>
121
+ <Button variant="ghost" className={cn("w-9", groupBy && 'bg-accent text-accent-foreground')} onClick={() => setGroupBy(!groupBy)}>
122
+ <Rows className="size-3.5" strokeWidth={1.5} />
123
+ <span className="sr-only">Group by category</span>
124
+ </Button>
125
+ </TooltipTrigger>
126
+ <TooltipContent>
127
+ Group by category
128
+ </TooltipContent>
129
+ </Tooltip>
130
+ </div>
131
+ <ToggleGroup className='flex gap-0.5 border border-input bg-background shadow-xs hover:text-accent-foreground p-0.5 rounded-md' type="single" value={layout} onValueChange={(value) => storeLayout(value)}>
132
+ <Tooltip>
133
+ <TooltipTrigger asChild>
134
+ <ToggleGroupItem className={cn("size-9 py-2 px-2 flex items-center justify-center hover:bg-accent rounded-md", layout === 'grid' && 'bg-accent text-accent-foreground')} value="grid" aria-label="Grid layout" >
135
+ <LayoutGrid className="size-3.5" strokeWidth={1.5} />
136
+ </ToggleGroupItem>
137
+ </TooltipTrigger>
138
+ <TooltipContent>
139
+ Grid layout
140
+ </TooltipContent>
141
+ </Tooltip>
142
+ <Tooltip>
143
+ <TooltipTrigger asChild>
144
+ <ToggleGroupItem className={cn("size-9 py-2 px-2 flex items-center justify-center hover:bg-accent rounded-md", layout === 'single' && 'bg-accent text-accent-foreground')} value="single" aria-label="Single column layout" >
145
+ <AlignJustify className="size-3.5" strokeWidth={1.5} />
146
+ </ToggleGroupItem>
147
+ </TooltipTrigger>
148
+ <TooltipContent>
149
+ Column layout
150
+ </TooltipContent>
151
+ </Tooltip>
152
+ </ToggleGroup>
153
+ </div>
153
154
  </div>
154
- </div>
155
155
 
156
- {groupBy ? (
157
- <>
158
- {Object.keys(groupedList).map((group) => {
159
- return (
160
- <div key={`group-${group}`} className="mb-8">
161
- <HeadersType.H3 className="mb-4 text-lg font-medium border-b border-gray-200 pb-2">{group}</HeadersType.H3>
162
- <div
163
- className={cn(
164
- 'grid',
165
- layout === 'grid' ? 'grid-cols-1 gap-10 min-[800px]:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4' : 'grid-cols-1 gap-2'
166
- )}
167
- >
168
- {groupedList[group].map((component) => {
169
- return <ComponentsPageCard key={`component-${component.id}`} component={component} layout={layout} />;
170
- })}
156
+ {groupBy ? (
157
+ <>
158
+ {Object.keys(groupedList).map((group) => {
159
+ return (
160
+ <div key={`group-${group}`} className="mb-8">
161
+ <HeadersType.H3 className="mb-4 text-lg font-medium border-b border-gray-200 pb-2">{group}</HeadersType.H3>
162
+ <div
163
+ className={cn(
164
+ 'grid',
165
+ layout === 'grid' ? 'grid-cols-1 gap-10 min-[800px]:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4' : 'grid-cols-1 gap-2'
166
+ )}
167
+ >
168
+ {groupedList[group].map((component) => {
169
+ return <ComponentsPageCard key={`component-${component.id}`} component={component} layout={layout} />;
170
+ })}
171
+ </div>
171
172
  </div>
172
- </div>
173
- );
173
+ );
174
+ })}
175
+ </>
176
+ ) : (
177
+ <div
178
+ className={cn(
179
+ 'grid',
180
+ layout === 'grid' ? 'grid-cols-1 gap-10 min-[800px]:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4' : 'grid-cols-1 gap-2'
181
+ )}
182
+ > {list.map((component) => {
183
+ return <ComponentsPageCard key={`component-${component.id}`} component={component} layout={layout} />;
174
184
  })}
175
- </>
176
- ) : (
177
- <div
178
- className={cn(
179
- 'grid',
180
- layout === 'grid' ? 'grid-cols-1 gap-10 min-[800px]:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4' : 'grid-cols-1 gap-2'
181
- )}
182
- > {list.map((component) => {
183
- return <ComponentsPageCard key={`component-${component.id}`} component={component} layout={layout} />;
184
- })}
185
- </div>
186
- )}
185
+ </div>
186
+ )}
187
187
  </div>
188
188
  </TooltipProvider>
189
189
  );
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+
3
+ interface NotFoundProps {
4
+ title?: string;
5
+ description?: React.ReactNode;
6
+ linkHref?: string;
7
+ linkLabel?: string;
8
+ }
9
+
10
+ const NotFound: React.FC<NotFoundProps> = () => {
11
+ return (
12
+ <div className="flex flex-col items-center">
13
+ <div className="mb-2 text-7xl font-bold text-gray-800 dark:text-white">404</div>
14
+ <h1 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-300">Oops! Page not found.</h1>
15
+ <p className="mb-6 max-w-md text-center text-gray-500 dark:text-gray-400">
16
+ This page doesn&rsquo;t exist or has been moved.
17
+ <br />
18
+ Please check the URL or return to the homepage.
19
+ </p>
20
+ </div>
21
+ );
22
+ };
23
+
24
+ export default NotFound;
@@ -1,7 +1,7 @@
1
1
  import { GetStaticProps } from 'next';
2
2
  import Head from 'next/head';
3
- import Link from 'next/link';
4
3
  import { useRef } from 'react';
4
+ import NotFound from '../../components/NotFound';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import rehypeRaw from 'rehype-raw';
7
7
  import remarkGfm from 'remark-gfm';
@@ -48,21 +48,7 @@ export default function DocCatchAllPage({ content, menu, metadata, current, conf
48
48
  <title>404 - Page Not Found</title>
49
49
  <meta name="description" content="Page Not Found" />
50
50
  </Head>
51
- <div className="flex flex-col items-center">
52
- <div className="mb-2 text-7xl font-bold text-gray-800 dark:text-white">404</div>
53
- <h1 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-300">Oops! Page not found.</h1>
54
- <p className="mb-6 max-w-md text-center text-gray-500 dark:text-gray-400">
55
- Sorry, the page you are looking for does not exist or has been moved.
56
- <br />
57
- Please check the URL or return to the homepage.
58
- </p>
59
- <Link
60
- href="/"
61
- className="rounded-md bg-blue-600 px-6 py-2 font-medium text-white shadow-md transition-colors duration-200 hover:bg-blue-700"
62
- >
63
- Go Home
64
- </Link>
65
- </div>
51
+ <NotFound />
66
52
  </div>
67
53
  );
68
54
  }
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useState } from 'react';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import rehypeRaw from 'rehype-raw';
7
7
  import Layout from '../../../../components/Layout/Main';
8
+ import NotFound from '../../../../components/NotFound';
8
9
  import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents';
9
10
  import PrevNextNav from '../../../../components/Navigation/PrevNextNav';
10
11
  import HeadersType from '../../../../components/Typography/Headers';
@@ -130,6 +131,7 @@ export const getStaticProps = async (context: { params: IParams }) => {
130
131
  const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nextPattern }: PatternPageProps) => {
131
132
  const [pattern, setPattern] = useState<PatternListObject | undefined>(undefined);
132
133
  const [fetchError, setFetchError] = useState<string | undefined>(undefined);
134
+ const [patternNotFound, setPatternNotFound] = useState(false);
133
135
  const [iframeHeight, setIframeHeight] = useState('400px');
134
136
  const iframeRef = React.useRef<HTMLIFrameElement>(null);
135
137
 
@@ -145,13 +147,20 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
145
147
  const controller = new AbortController();
146
148
  setPattern(undefined);
147
149
  setFetchError(undefined);
150
+ setPatternNotFound(false);
148
151
 
149
152
  fetch(`${normalizedBasePath}/api/pattern/${id}.json`, { signal: controller.signal })
150
153
  .then((res) => {
154
+ if (res.status === 404) {
155
+ setPatternNotFound(true);
156
+ return null;
157
+ }
151
158
  if (!res.ok) throw new Error(`Failed to load pattern: ${res.status} ${res.statusText}`);
152
159
  return res.json();
153
160
  })
154
- .then((data) => setPattern(data as PatternListObject))
161
+ .then((data) => {
162
+ if (data) setPattern(data as PatternListObject);
163
+ })
155
164
  .catch((err) => {
156
165
  if (err.name !== 'AbortError') {
157
166
  setFetchError(err instanceof Error ? err.message : 'Failed to load pattern data.');
@@ -167,7 +176,27 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
167
176
  }
168
177
  }, []);
169
178
 
170
- if (fetchError) return <p className="p-4 text-red-500">{fetchError}</p>;
179
+ if (patternNotFound) {
180
+ return (
181
+ <Layout config={config} menu={menu} current={current} metadata={{ ...metadata, title: '404 - Page Not Found', metaTitle: '404 - Page Not Found' }}>
182
+ <div className="flex min-h-[60vh] flex-col items-center justify-center">
183
+ <NotFound />
184
+ </div>
185
+ </Layout>
186
+ );
187
+ }
188
+
189
+ if (fetchError) {
190
+ return (
191
+ <Layout config={config} menu={menu} current={current} metadata={metadata}>
192
+ <div className="flex min-h-[60vh] flex-col items-center justify-center gap-2">
193
+ <p className="text-lg font-semibold text-gray-700 dark:text-gray-300">Something went wrong</p>
194
+ <p className="text-sm text-red-500">{fetchError}</p>
195
+ </div>
196
+ </Layout>
197
+ );
198
+ }
199
+
171
200
  if (!pattern) return <p>Loading...</p>;
172
201
 
173
202
  return (
@@ -17,6 +17,7 @@ import {
17
17
  watchComponentDirectories,
18
18
  watchGlobalEntries,
19
19
  watchPages,
20
+ watchPatternDirectories,
20
21
  watchPublicDirectory,
21
22
  watchRuntimeComponents,
22
23
  watchRuntimeConfiguration,
@@ -224,12 +225,14 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
224
225
  runtimeComponentsWatcher: null,
225
226
  runtimeConfigurationWatcher: null,
226
227
  componentDirectoriesWatcher: null,
228
+ patternDirectoriesWatcher: null,
227
229
  };
228
230
 
229
231
  watchPublicDirectory(handoff, wss, state, chokidarConfig);
230
232
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
231
233
  watchRuntimeConfiguration(handoff, state);
232
234
  watchComponentDirectories(handoff, state, chokidarConfig);
235
+ watchPatternDirectories(handoff, state, chokidarConfig);
233
236
  watchGlobalEntries(handoff, state, chokidarConfig);
234
237
  watchPages(handoff, chokidarConfig);
235
238
  };
@@ -7,6 +7,8 @@ export interface WatcherState {
7
7
  runtimeComponentsWatcher: chokidar.FSWatcher | null;
8
8
  runtimeConfigurationWatcher: chokidar.FSWatcher | null;
9
9
  componentDirectoriesWatcher: chokidar.FSWatcher | null;
10
+ /** Watches entries.patterns parent directories for newly created pattern subdirectories. */
11
+ patternDirectoriesWatcher: chokidar.FSWatcher | null;
10
12
  }
11
13
 
12
14
  /**
@@ -2,6 +2,7 @@ import chokidar from 'chokidar';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import Handoff from '..';
5
+ import { buildPatterns } from '../pipeline/patterns';
5
6
  import processComponents, { ComponentSegment } from '../transformers/preview/component/builder';
6
7
  import { buildMainCss } from '../transformers/preview/component/css';
7
8
  import { buildMainJS } from '../transformers/preview/component/javascript';
@@ -209,6 +210,15 @@ export const watchRuntimeComponents = (
209
210
  }
210
211
  };
211
212
 
213
+ const rebuildPatternComponentPreviews = async (handoff: Handoff, patternId: string) => {
214
+ const pattern = handoff.runtimeConfig?.entries?.patterns?.[patternId];
215
+ if (!pattern?.components?.length) return;
216
+
217
+ for (const ref of pattern.components) {
218
+ await processComponents(handoff, ref.id, ComponentSegment.Previews);
219
+ }
220
+ };
221
+
212
222
  /**
213
223
  * Watches the runtime configuration for changes.
214
224
  *
@@ -256,6 +266,8 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState)
256
266
  await processComponents(handoff);
257
267
  } else if (handle) {
258
268
  await handle.apply(handoff, entryAfter?.entityId);
269
+ } else if (entryAfter?.kind === 'pattern') {
270
+ await rebuildPatternComponentPreviews(handoff, entryAfter.entityId);
259
271
  } else {
260
272
  const effectiveId = entryAfter?.entityId ?? path.basename(path.dirname(changedFile));
261
273
  await processComponents(handoff, effectiveId);
@@ -273,22 +285,50 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState)
273
285
  };
274
286
 
275
287
  /**
276
- * Watches the parent component directories from config.entries.components
277
- * for new components being added. When a new config file (e.g. button.json)
278
- * appears in a new subdirectory, reloads the runtime config and restarts
279
- * the component/configuration watchers so the new component is picked up.
288
+ * Shared factory for watching a parent directory for newly created entity
289
+ * subdirectories (components or patterns). Handles the common scaffolding so
290
+ * that callers only describe what differs for their entity kind:
291
+ *
292
+ * - which config-entry paths to watch
293
+ * - how to read the current known ids after a reload
294
+ * - which WatcherState slot to use
295
+ * - a log label for the entity kind
296
+ * - the rebuild work to run once the runtime config is fresh
280
297
  */
281
- export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => {
282
- if (state.componentDirectoriesWatcher) {
283
- state.componentDirectoriesWatcher.close();
298
+ const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions, options: {
299
+ /** Config paths to watch, e.g. handoff.config.entries?.components */
300
+ getConfigPaths: (handoff: Handoff) => string[];
301
+ /** Current known entity ids from runtime config, called again after reload to refresh the set */
302
+ getKnownIds: (handoff: Handoff) => string[];
303
+ /** Read the active FSWatcher from state */
304
+ getWatcher: (state: WatcherState) => chokidar.FSWatcher | null;
305
+ /** Write the active FSWatcher back into state */
306
+ setWatcher: (state: WatcherState, watcher: chokidar.FSWatcher) => void;
307
+ /** Prefix for the scheduleHandler key, e.g. 'newComponent' or 'newPattern' */
308
+ scheduleKeyPrefix: string;
309
+ /** Human-readable label used in log messages, e.g. 'component' or 'pattern' */
310
+ entityLabel: string;
311
+ /**
312
+ * Entity-specific rebuild work, called after reload + persistClientConfig +
313
+ * watcher re-registration. Receives the handoff instance (with fresh runtime
314
+ * config), the triggering directory name, and actual entity ids discovered
315
+ * after reload.
316
+ */
317
+ onDetected: (handoff: Handoff, context: { dirName: string; addedIds: string[] }) => Promise<void>;
318
+ }) => {
319
+ const { getConfigPaths, getKnownIds, getWatcher, setWatcher, scheduleKeyPrefix, entityLabel, onDetected } = options;
320
+
321
+ const existingWatcher = getWatcher(state);
322
+ if (existingWatcher) {
323
+ existingWatcher.close();
284
324
  }
285
325
 
286
- const componentPaths = handoff.config.entries?.components ?? [];
287
- if (componentPaths.length === 0) return;
326
+ const configPaths = getConfigPaths(handoff);
327
+ if (configPaths.length === 0) return;
288
328
 
289
329
  const dirsToWatch: string[] = [];
290
- for (const componentPath of componentPaths) {
291
- const resolved = path.resolve(handoff.workingPath, componentPath);
330
+ for (const configPath of configPaths) {
331
+ const resolved = path.resolve(handoff.workingPath, configPath);
292
332
  if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
293
333
  dirsToWatch.push(resolved);
294
334
  }
@@ -296,40 +336,124 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState,
296
336
 
297
337
  if (dirsToWatch.length === 0) return;
298
338
 
299
- const knownComponents = new Set(Object.keys(handoff.runtimeConfig?.entries?.components ?? {}));
339
+ // Snapshot known ids at watch startup so we only react to genuinely new
340
+ // entities, not to file changes inside already-known directories.
341
+ const knownIds = new Set(getKnownIds(handoff));
300
342
 
301
- state.componentDirectoriesWatcher = chokidar.watch(dirsToWatch, {
343
+ const watcher = chokidar.watch(dirsToWatch, {
302
344
  ...chokidarConfig,
303
345
  depth: 1,
304
346
  });
347
+ setWatcher(state, watcher);
305
348
 
306
- state.componentDirectoriesWatcher.on('add', async (file) => {
349
+ watcher.on('add', async (file) => {
307
350
  const basename = path.basename(file);
308
351
  const dirName = path.basename(path.dirname(file));
309
352
 
310
- const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs');
311
- const isNewComponent = isConfigFile && basename.startsWith(dirName) && !knownComponents.has(dirName);
353
+ // Only react to the primary declaration file for the directory (e.g.
354
+ // button.json, button.js, or button.handoff.ts inside a button/ subdir).
355
+ // The basename.startsWith(dirName) guard below ensures .ts here only
356
+ // ever matches files named after their parent directory, not arbitrary
357
+ // TypeScript source files.
358
+ const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs') || basename.endsWith('.ts');
359
+ const isNewEntity = isConfigFile && basename.startsWith(dirName) && !knownIds.has(dirName);
312
360
 
313
- if (!isNewComponent) return;
361
+ if (!isNewEntity) return;
314
362
 
315
- await scheduleHandler(state, `newComponent:${dirName}`, async () => {
363
+ await scheduleHandler(state, `${scheduleKeyPrefix}:${dirName}`, async () => {
316
364
  try {
317
- Logger.warn(`New component detected: ${dirName}. Reloading configuration...`);
365
+ Logger.warn(`New ${entityLabel} detected: ${dirName}. Reloading configuration...`);
366
+
367
+ // Snapshot ids before reload so we can tell whether the new entity
368
+ // was successfully registered afterwards.
369
+ const idsBefore = new Set(getKnownIds(handoff));
370
+
318
371
  handoff.reload();
319
- knownComponents.add(dirName);
320
372
 
321
- for (const id of Object.keys(handoff.runtimeConfig?.entries?.components ?? {})) {
322
- knownComponents.add(id);
373
+ // Refresh from the post-reload runtime config so any ids that were
374
+ // discovered alongside the new entity are also marked as known.
375
+ const idsAfter = getKnownIds(handoff);
376
+ const addedIds = idsAfter.filter((id) => !idsBefore.has(id));
377
+ for (const id of idsAfter) {
378
+ knownIds.add(id);
323
379
  }
324
380
 
325
381
  await persistClientConfig(handoff);
382
+
383
+ // Re-register file watchers so the new config file and any runtime
384
+ // files it introduces are covered going forward. This must happen even
385
+ // when the file failed to parse so that watchRuntimeConfiguration can
386
+ // trigger a retry the moment the file is saved with valid content.
326
387
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
327
388
  watchRuntimeConfiguration(handoff, state);
328
- await processComponents(handoff, dirName);
329
- await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] });
389
+
390
+ // If no new ids appeared after reload the file was empty or invalid.
391
+ // Skip the build — watchRuntimeConfiguration will fire when it is saved.
392
+ if (addedIds.length === 0) {
393
+ Logger.warn(`${entityLabel} "${dirName}" config is empty or incomplete — build will run automatically once the file is saved.`);
394
+ return;
395
+ }
396
+
397
+ await onDetected(handoff, { dirName, addedIds });
330
398
  } catch (e) {
331
- Logger.error('Error processing new component:', e);
399
+ Logger.error(`Error processing new ${entityLabel}:`, e);
332
400
  }
333
401
  });
334
402
  });
335
403
  };
404
+
405
+ /**
406
+ * Watches the parent component directories from config.entries.components for
407
+ * new components being added. When a new config file appears in a new
408
+ * subdirectory, reloads the runtime config, restarts the component and
409
+ * configuration watchers, then builds the new component and any patterns that
410
+ * reference it.
411
+ */
412
+ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => {
413
+ watchEntityDirectories(handoff, state, chokidarConfig, {
414
+ getConfigPaths: (h) => h.config.entries?.components ?? [],
415
+ getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.components ?? {}),
416
+ getWatcher: (s) => s.componentDirectoriesWatcher,
417
+ setWatcher: (s, w) => { s.componentDirectoriesWatcher = w; },
418
+ scheduleKeyPrefix: 'newComponent',
419
+ entityLabel: 'component',
420
+ onDetected: async (handoff, { addedIds }) => {
421
+ for (const componentId of addedIds) {
422
+ await processComponents(handoff, componentId);
423
+ await runAllFinalizers(handoff, { patternRebuildComponentIds: [componentId] });
424
+ }
425
+ },
426
+ });
427
+ };
428
+
429
+ /**
430
+ * Watches the parent pattern directories from config.entries.patterns for new
431
+ * patterns being added. The rebuild sequence intentionally differs from
432
+ * watchComponentDirectories because the dependency arrow is reversed: a new
433
+ * pattern references existing components, so we rebuild only the preview
434
+ * segment of those components (to produce the __pattern_* HTML files that
435
+ * injectPatternPreviews registered) before composing the pattern itself.
436
+ */
437
+ export const watchPatternDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => {
438
+ watchEntityDirectories(handoff, state, chokidarConfig, {
439
+ getConfigPaths: (h) => h.config.entries?.patterns ?? [],
440
+ getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.patterns ?? {}),
441
+ getWatcher: (s) => s.patternDirectoriesWatcher,
442
+ setWatcher: (s, w) => { s.patternDirectoriesWatcher = w; },
443
+ scheduleKeyPrefix: 'newPattern',
444
+ entityLabel: 'pattern',
445
+ onDetected: async (handoff, { addedIds }) => {
446
+ // Rebuild only the previews segment for each component the new pattern
447
+ // references. Their JS/CSS/structure are unchanged — only the new
448
+ // synthetic preview HTML files need to be generated so that buildPatterns
449
+ // can assemble the pattern from them.
450
+ for (const patternId of addedIds) {
451
+ await rebuildPatternComponentPreviews(handoff, patternId);
452
+ }
453
+
454
+ // Build only the new pattern directly rather than going through
455
+ // runAllFinalizers, which would rebuild all patterns unnecessarily.
456
+ await buildPatterns(handoff, { onlyPatternIds: new Set(addedIds) });
457
+ },
458
+ });
459
+ };
@@ -226,6 +226,23 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): {
226
226
  files.push(matchingConfigPath);
227
227
  }
228
228
 
229
+ // Include every pattern config file whose declaration references this component.
230
+ // When a pattern is added or modified, injectPatternPreviews injects new
231
+ // __pattern_* synthetic preview keys onto the referenced component, changing
232
+ // what processComponents must render. Without tracking these files here, the
233
+ // cache would incorrectly consider the component unchanged and skip the preview
234
+ // rebuild, leaving buildPatterns unable to find the required HTML fragments.
235
+ const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {};
236
+ for (const configPath of configPaths) {
237
+ const entry = handoff.getConfigFileEntry(configPath);
238
+ if (entry?.kind !== 'pattern') continue;
239
+ const pattern = runtimePatterns[entry.entityId];
240
+ if (!pattern) continue;
241
+ if (pattern.components?.some((ref) => ref.id === componentId)) {
242
+ files.push(configPath);
243
+ }
244
+ }
245
+
229
246
  return { files, templateDir };
230
247
  }
231
248
 
@@ -284,7 +284,12 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
284
284
  fallbackId: patternBaseName,
285
285
  });
286
286
  } catch (err) {
287
- Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err);
287
+ // Treat as a warning rather than an error: the file may be empty or
288
+ // mid-edit. It is already tracked in configFiles above, so
289
+ // watchRuntimeConfiguration will pick it up once the file is saved
290
+ // with valid content.
291
+ Logger.warn(`Pattern config skipped (incomplete or invalid) — will retry on next save: ${resolvedPatternConfigPath}`);
292
+ Logger.debug(`Pattern parse detail:`, err);
288
293
  continue;
289
294
  }
290
295
 
package/handoff.config.js DELETED
@@ -1,90 +0,0 @@
1
- /** @type {import('handoff-app').Config} */
2
- const path = require('path');
3
- module.exports = {
4
- // Set your Figma project ID here or in the environment variable HANDOFF_FIGMA_PROJECT_ID
5
- figma_project_id: null,
6
- // Set the output directory for the exported components
7
- exportsOutputDirectory: 'exported',
8
- // Set the output directory for the sites
9
- sitesOutputDirectory: 'out',
10
- // These settings control the behavior of the application
11
- app: {
12
- // Set the theme for the application
13
- theme: 'default',
14
- // Set the title for the application
15
- title: 'Handoff Design System',
16
- // Set the client for the application, used in the footer
17
- client: 'Handoff Technologies',
18
- // Set the Google Tag Manager ID to add GTM scripts
19
- google_tag_manager: null,
20
- // Set the attribution for the application
21
- attribution: true, // Should we remove this?
22
- // Set the type copy for the typeography page
23
- type_copy: 'Almost before we knew it, we had left the ground.',
24
- // Set the type sort for the typeography page
25
- type_sort: [
26
- 'Heading 1',
27
- 'Heading 2',
28
- 'Heading 3',
29
- 'Heading 4',
30
- 'Heading 5',
31
- 'Heading 6',
32
- 'Paragraph',
33
- 'Subheading',
34
- 'Blockquote',
35
- 'Input Labels',
36
- 'Link',
37
- ],
38
- // Set the color sort for the color page
39
- color_sort: ['primary', 'secondary', 'extra', 'system'],
40
- // Set the component sort for the component page
41
- component_sort: ['primary', 'secondary', 'transparent'],
42
- // Set the base path for the application
43
- base_path: '',
44
- // Set the breakpoints for the application
45
- breakpoints: {
46
- sm: { size: 576, name: 'Small' },
47
- md: { size: 768, name: 'Medium' },
48
- lg: { size: 992, name: 'Large' },
49
- xl: { size: 1200, name: 'Extra Large' },
50
- },
51
- // Set the ports for the application
52
- ports: { app: 3000, websocket: 3001 },
53
- },
54
- // These are the entry points used to compile the target application
55
- entries: {
56
- scss: './sass/main.scss',
57
- js: './js/main.js',
58
- bundle: './js/main.js',
59
- //components: ['./test_components'],
60
- //patterns: ['./test_patterns'],
61
- },
62
- // These are the asset zip file download links
63
- assets_zip_links: {
64
- icons: null,
65
- logos: null,
66
- },
67
- // Hooks allow you to override the default behavior of the application
68
- hooks: {
69
- // Alter the vite css build config
70
- cssBuildConfig: (config) => {
71
- return config;
72
- },
73
- // Validate each component before it is exported
74
- validateComponent: (component) => {
75
- return {};
76
- },
77
- // Alter the vite client build config
78
- clientBuildConfig: (config) => {
79
- return config;
80
- },
81
- // Alter the vite html build config
82
- htmlBuildConfig: (config) => {
83
- return config;
84
- },
85
- // Alter the vite js build config
86
- jsBuildConfig: (config) => {
87
- return config;
88
- },
89
- },
90
- };