@codepress/design-kit 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * `design-kit init`: sets a design kit up in a repository. It looks at the
3
3
  * project (Storybook, framework, package manager, dev script, Playwright,
4
4
  * theme) and writes design-kit.config.json, a daily GitHub Actions workflow
5
- * that uploads the kit, a design-kit skill for the repository's agents and
6
- * one line in AGENTS.md or CLAUDE.md. It never overwrites a file without
7
- * --force and never writes a secret.
5
+ * that rebuilds the kit when the watched UI files change and uploads it, a
6
+ * design-kit skill for the repository's agents and one line in AGENTS.md or
7
+ * CLAUDE.md. It never overwrites a file without --force and never writes a
8
+ * secret. `--workflow-only` rewrites just the workflow.
8
9
  */
9
10
  import {
10
11
  existsSync,
@@ -20,6 +21,7 @@ import {
20
21
  } from "node:fs";
21
22
  import { dirname, join, relative, resolve, sep } from "node:path";
22
23
 
24
+ import { DEFAULT_MAX_AGE_DAYS } from "./ci.mjs";
23
25
  import { CONFIG_NAME } from "./config.mjs";
24
26
 
25
27
  const PACKAGE = JSON.parse(
@@ -34,6 +36,8 @@ export const WORKFLOW_PATH = ".github/workflows/design-kit.yml";
34
36
  export const SKILL_DIR = ".claude/skills/design-kit";
35
37
  export const AGENTS_SKILL_DIR = ".agents/skills/design-kit";
36
38
  const TODO = "TODO";
39
+ /** How long a kit artifact is kept: the most a public repository allows. */
40
+ export const RETENTION_DAYS = 90;
37
41
 
38
42
  const IGNORED_DIRS = new Set([
39
43
  "node_modules",
@@ -549,12 +553,117 @@ function posix(path) {
549
553
  return path.split(sep).join("/");
550
554
  }
551
555
 
556
+ /** Folders at a package root that hold what the UI renders or loads. */
557
+ const ROOT_UI_DIRS = new Set([
558
+ ".storybook",
559
+ "app",
560
+ "assets",
561
+ "components",
562
+ "design-tokens",
563
+ "lib",
564
+ "pages",
565
+ "public",
566
+ "src",
567
+ "stories",
568
+ "styles",
569
+ "theme",
570
+ "tokens",
571
+ ]);
572
+ /** Global styles and tokens that can sit at the repository root. */
573
+ const ROOT_STYLE_DIRS = new Set(["design-tokens", "styles", "theme", "tokens"]);
574
+ const UI_CONFIG_FILE =
575
+ /^(tailwind|postcss|vite|next|astro|remix|svelte|nuxt|uno|panda)\.config\.[cm]?[jt]s$/;
576
+
577
+ /**
578
+ * What to watch for one package folder: the folder itself, or, when the
579
+ * package is the repository root, its UI folders and config files (watching
580
+ * the whole root would rebuild on every commit).
581
+ */
582
+ function packageWatch(dir, repoRoot) {
583
+ if (dir !== repoRoot) return [posix(relative(repoRoot, dir))];
584
+ const paths = [];
585
+ for (const entry of safeReaddir(dir)) {
586
+ if (entry.isDirectory() && ROOT_UI_DIRS.has(entry.name))
587
+ paths.push(entry.name);
588
+ else if (
589
+ entry.isFile() &&
590
+ (UI_CONFIG_FILE.test(entry.name) ||
591
+ entry.name === "package.json" ||
592
+ entry.name === "index.html")
593
+ )
594
+ paths.push(entry.name);
595
+ }
596
+ return paths.sort();
597
+ }
598
+
599
+ /** The workspace packages `dirs` depend on, directly or through each other. */
600
+ function workspaceDependencies(dirs, found) {
601
+ const all = [
602
+ ...new Set([...workspacePackages(found.repoRoot), ...found.workspaces]),
603
+ ];
604
+ const byName = new Map();
605
+ for (const dir of all) {
606
+ const name = readJson(join(dir, "package.json"))?.name;
607
+ if (name && !byName.has(name)) byName.set(name, dir);
608
+ }
609
+ const seen = new Set(dirs);
610
+ const queue = [...dirs];
611
+ const dependencies = [];
612
+ while (queue.length) {
613
+ const dir = queue.shift();
614
+ for (const name of Object.keys(deps(readJson(join(dir, "package.json"))))) {
615
+ const dep = byName.get(name);
616
+ if (dep && !seen.has(dep)) {
617
+ seen.add(dep);
618
+ dependencies.push(dep);
619
+ queue.push(dep);
620
+ }
621
+ }
622
+ }
623
+ return dependencies;
624
+ }
625
+
626
+ /**
627
+ * The config's `watch` list: the paths, relative to the repository root,
628
+ * whose change rebuilds the kit. The Storybook and app folders, the
629
+ * workspace packages they depend on (shared UI usually lives there), global
630
+ * styles and tokens at the root, the config, the lockfile and the workflow.
631
+ */
632
+ export function watchDefaults(found) {
633
+ const rel = (path) => posix(relative(found.repoRoot, path));
634
+ const roots = [
635
+ ...(found.storybook ? [found.storybook.dir] : []),
636
+ found.framework?.dir ?? found.project,
637
+ ];
638
+ const packages = [...new Set(roots)];
639
+ const paths = [];
640
+ for (const dir of [...packages, ...workspaceDependencies(packages, found)])
641
+ paths.push(...packageWatch(dir, found.repoRoot));
642
+ for (const entry of safeReaddir(found.repoRoot))
643
+ if (
644
+ (entry.isDirectory() && ROOT_STYLE_DIRS.has(entry.name)) ||
645
+ (entry.isFile() && UI_CONFIG_FILE.test(entry.name))
646
+ )
647
+ paths.push(entry.name);
648
+ paths.push(rel(join(found.project, CONFIG_NAME)));
649
+ const pm = found.packageManager;
650
+ if (pm.lockfile) paths.push(rel(join(pm.installDir, pm.lockfile)));
651
+ paths.push(WORKFLOW_PATH);
652
+ const unique = [...new Set(paths.filter(Boolean))];
653
+ // A path inside another watched folder adds nothing.
654
+ return unique.filter(
655
+ (path) =>
656
+ !unique.some((other) => other !== path && path.startsWith(`${other}/`)),
657
+ );
658
+ }
659
+
552
660
  /** design-kit.config.json, as an object. */
553
661
  export function buildConfig(found) {
554
662
  const config = {
555
663
  $schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
556
664
  version: 1,
557
665
  out: "design-kit",
666
+ watch: watchDefaults(found),
558
667
  theme: {
559
668
  attribute: found.theme.attribute,
560
669
  dark: found.theme.dark,
@@ -634,13 +743,18 @@ export function buildWorkflow(found) {
634
743
 
635
744
  return `name: Design kit
636
745
 
637
- # Captures this repository's design kit once a day: ${found.storybook ? "every Storybook story and " : ""}the
638
- # pages listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts
639
- # they load. It uploads the result as the \`design-kit\` artifact (kept 30
640
- # days), which agents download to draw mockups from the app's real markup.
641
- # A day with no new commits since the newest kit builds nothing.
746
+ # Captures this repository's design kit: ${found.storybook ? "every Storybook story and " : ""}the pages
747
+ # listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts they
748
+ # load. It uploads the result as the \`design-kit\` artifact, which agents
749
+ # download to draw mockups from the app's real markup.
750
+ #
751
+ # It runs daily, but builds only when a path in the config's "watch" list
752
+ # changed since the newest kit's commit, when there is no kit yet, or when
753
+ # the newest kit is DESIGN_KIT_MAX_AGE_DAYS old, so a kit never expires
754
+ # unreplaced. After an upload it deletes the branch's older kits.
642
755
  #
643
- # Written by \`design-kit init\` (${PACKAGE.name}). Edit it freely.
756
+ # Written by \`design-kit init\` (${PACKAGE.name}); \`init --workflow-only
757
+ # --force\` rewrites just this file. Edit it freely.
644
758
 
645
759
  on:
646
760
  schedule:
@@ -648,11 +762,11 @@ on:
648
762
  workflow_dispatch:
649
763
  inputs:
650
764
  force:
651
- description: Build even when the newest kit is from this commit
765
+ description: Build even when no watched file changed since the newest kit
652
766
  type: boolean
653
767
  default: false
654
768
  package:
655
- description: "The ${PACKAGE.name} to install, as an npm package spec (default: the pinned version below)"
769
+ description: "The ${PACKAGE.name} to build with, as an npm package spec (default: the pinned version below). Always builds."
656
770
  type: string
657
771
  default: ""
658
772
 
@@ -667,30 +781,46 @@ env:
667
781
  DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
668
782
  PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
669
783
  CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
784
+ # Rebuild a kit this many days old even with no UI change. Kits are kept
785
+ # ${RETENTION_DAYS} days (retention-days below), so this refreshes the kit before it
786
+ # expires.
787
+ DESIGN_KIT_MAX_AGE_DAYS: "${DEFAULT_MAX_AGE_DAYS}"
670
788
 
671
789
  jobs:
672
790
  check:
673
- name: Any commits since the newest kit?
791
+ name: Did the watched files change?
674
792
  runs-on: ubuntu-latest
675
- timeout-minutes: 5
793
+ timeout-minutes: 10
676
794
  permissions:
795
+ contents: read
677
796
  actions: read
678
797
  outputs:
679
- build: \${{ steps.last.outputs.build }}
798
+ build: \${{ steps.check.outputs.build }}
680
799
  steps:
681
- - id: last
800
+ # Only the config is checked out and no file contents are fetched: the
801
+ # check compares the git tree and blob ids of the watched paths at the
802
+ # newest kit's commit and at this one.
803
+ - uses: actions/checkout@v5
804
+ with:
805
+ fetch-depth: 1
806
+ filter: blob:none
807
+ sparse-checkout: ${at(CONFIG_NAME)}
808
+ sparse-checkout-cone-mode: false
809
+
810
+ - uses: actions/setup-node@v5
811
+ with:
812
+ node-version: "22"
813
+
814
+ - id: check
682
815
  env:
683
816
  GH_TOKEN: \${{ github.token }}
684
817
  FORCE: \${{ inputs.force || 'false' }}
818
+ PACKAGE_INPUT: \${{ inputs.package || '' }}
685
819
  run: |
686
- last=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=design-kit&per_page=100" \\
687
- --jq "[.artifacts[] | select((.expired | not) and .workflow_run.head_branch == \\"$GITHUB_REF_NAME\\")] | sort_by(.created_at) | last | .workflow_run.head_sha // \\"\\"") || last=""
688
- if [ "$FORCE" != "true" ] && [ -n "$last" ] && [ "$last" = "$GITHUB_SHA" ]; then
689
- echo "The newest design kit is from $GITHUB_SHA and there are no commits since: nothing to build." | tee -a "$GITHUB_STEP_SUMMARY"
690
- echo "build=false" >> "$GITHUB_OUTPUT"
691
- else
692
- echo "build=true" >> "$GITHUB_OUTPUT"
693
- fi
820
+ args=()
821
+ if [ "$FORCE" = "true" ]; then args+=(--force); fi
822
+ if [ -n "$PACKAGE_INPUT" ]; then args+=(--package "$PACKAGE_INPUT"); fi
823
+ npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-check --config "$CONFIG" "\${args[@]}"
694
824
 
695
825
  build:
696
826
  name: Build the design kit
@@ -698,6 +828,8 @@ jobs:
698
828
  if: needs.check.outputs.build == 'true'
699
829
  runs-on: ubuntu-latest
700
830
  timeout-minutes: 60
831
+ outputs:
832
+ artifact-id: \${{ steps.upload.outputs.artifact-id }}
701
833
  steps:
702
834
  - uses: actions/checkout@v5
703
835
  with:
@@ -745,13 +877,37 @@ ${storiesStep}
745
877
  run: |
746
878
  echo "::warning title=Design kit::Nothing to capture yet, so no kit was uploaded: ${at(CONFIG_NAME)} has no stories and its pages still have ${TODO} settings. Fill in the pages (or add Storybook) and run this workflow again."
747
879
 
748
- - if: hashFiles('${at("design-kit")}/manifest.json') != ''
880
+ - id: upload
881
+ if: hashFiles('${at("design-kit")}/manifest.json') != ''
749
882
  uses: actions/upload-artifact@v5
750
883
  with:
751
884
  name: design-kit
752
885
  path: ${at("design-kit")}
753
- retention-days: 30
886
+ # The most a public repository allows. A private repository may
887
+ # raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
888
+ retention-days: ${RETENTION_DAYS}
754
889
  if-no-files-found: error
890
+
891
+ # Deletes this branch's older kits once the new one is uploaded. The only
892
+ # job that may delete artifacts; a failed delete is a warning.
893
+ cleanup:
894
+ name: Delete older kits
895
+ needs: build
896
+ if: needs.build.outputs.artifact-id != ''
897
+ runs-on: ubuntu-latest
898
+ timeout-minutes: 5
899
+ permissions:
900
+ actions: write
901
+ steps:
902
+ - uses: actions/setup-node@v5
903
+ with:
904
+ node-version: "22"
905
+
906
+ - continue-on-error: true
907
+ env:
908
+ GH_TOKEN: \${{ github.token }}
909
+ KEEP: \${{ needs.build.outputs.artifact-id }}
910
+ run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
755
911
  `;
756
912
  }
757
913
 
@@ -759,19 +915,11 @@ ${storiesStep}
759
915
  export function buildSkill(found) {
760
916
  const pm = found.packageManager;
761
917
  const projectDir = posix(relative(found.repoRoot, found.project)) || ".";
762
- const watched = found.storybook
763
- ? [...new Set([found.storybook.dir, found.framework?.dir ?? found.project])]
764
- : [found.framework?.dir ?? found.project];
765
- const watchedPaths =
766
- watched
767
- .map((dir) => posix(relative(found.repoRoot, dir)) || ".")
768
- .join(" ") || ".";
769
918
  const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
770
919
  const values = {
771
920
  CLI: cli,
772
921
  CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
773
922
  PROJECT_DIR: projectDir,
774
- WATCHED_PATHS: watchedPaths,
775
923
  STORIES_LINE: found.storybook
776
924
  ? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
777
925
  : `# (no Storybook: this kit has pages only)`,
@@ -902,6 +1050,7 @@ function describe(found) {
902
1050
  ? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
903
1051
  : "none (the workflow installs its own)",
904
1052
  ],
1053
+ ["Rebuilds on", watchDefaults(found).join(", ")],
905
1054
  [
906
1055
  "Theme",
907
1056
  found.theme.evidence
@@ -916,11 +1065,11 @@ function todo(found) {
916
1065
  const sb = found.storybook;
917
1066
  if (!sb)
918
1067
  items.push(
919
- "No Storybook: the config captures pages only. Decide whether to add Storybook with stories for the components the main screens use, capture real pages, or both.",
1068
+ "No Storybook: the config captures pages only. Decide whether to add Storybook with stories for the components the main screens use (`npx --yes @codepress/design-kit storybook-guide` prints a guide for that), capture real pages, or both.",
920
1069
  );
921
- else if (sb.major && (sb.major < 7 || sb.major > 8))
1070
+ else if (sb.major && (sb.major < 7 || sb.major > 9))
922
1071
  items.push(
923
- `Storybook ${sb.version}: captures are supported for Storybook 7 and 8 static builds; check that \`capture stories\` works, or use page capture.`,
1072
+ `Storybook ${sb.version}: captures are supported for Storybook 7, 8 and 9 static builds; check that \`capture stories\` works, or use page capture.`,
924
1073
  );
925
1074
  items.push(
926
1075
  `Pages: replace the ${TODO} selectors in ${CONFIG_NAME} (pages[].ready and slots.main), add the app's main screens, and check startCommand and waitUrl. Until then the workflow skips page capture.`,
@@ -942,10 +1091,10 @@ function todo(found) {
942
1091
  return items;
943
1092
  }
944
1093
 
945
- function summary(found, plan, { dryRun }) {
1094
+ function summary(found, plan, { dryRun, workflowOnly, notes }) {
946
1095
  const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
947
1096
  const lines = [
948
- `design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
1097
+ `design-kit init${workflowOnly ? " --workflow-only" : ""}${dryRun ? " --dry-run" : ""}: ${found.project}`,
949
1098
  "",
950
1099
  "Found",
951
1100
  ];
@@ -968,24 +1117,58 @@ function summary(found, plan, { dryRun }) {
968
1117
  lines.push(` ${rel(write.path)}${note}`);
969
1118
  }
970
1119
  lines.push("", "Still to decide");
971
- for (const item of todo(found)) lines.push(` - ${item}`);
1120
+ for (const item of notes ?? todo(found)) lines.push(` - ${item}`);
972
1121
  return lines.join("\n");
973
1122
  }
974
1123
 
975
1124
  // ── the command ─────────────────────────────────────────────────────────────
976
1125
 
1126
+ /**
1127
+ * `init --workflow-only`: rewrites .github/workflows/design-kit.yml from
1128
+ * this version and nothing else. The config is left alone; when it has no
1129
+ * `watch` list, the summary prints the one init would write.
1130
+ */
1131
+ function initWorkflowOnly(found, { dryRun, force, log }) {
1132
+ const configFile = join(found.project, CONFIG_NAME);
1133
+ if (!exists(configFile))
1134
+ throw new InitError(
1135
+ `${configFile} does not exist, so there is no kit to build yet. Run init without --workflow-only.`,
1136
+ );
1137
+ const path = join(found.repoRoot, WORKFLOW_PATH);
1138
+ if (!force && exists(path))
1139
+ throw new InitError(
1140
+ `${WORKFLOW_PATH} already exists. Nothing was written; pass --force with --workflow-only to replace it.`,
1141
+ );
1142
+ const plan = {
1143
+ writes: [{ path, kind: "file", content: buildWorkflow(found) }],
1144
+ };
1145
+ if (!dryRun) apply(plan.writes[0]);
1146
+ const notes = [];
1147
+ const raw = readJson(configFile);
1148
+ if (!Array.isArray(raw?.watch) || !raw.watch.length)
1149
+ notes.push(
1150
+ `${CONFIG_NAME} has no "watch" list, so the kit rebuilds after any commit. To rebuild only when the UI changes, add:\n "watch": ${JSON.stringify(watchDefaults(found))}`,
1151
+ );
1152
+ else notes.push(`Nothing: ${CONFIG_NAME} already has a "watch" list.`);
1153
+ log(summary(found, plan, { dryRun, workflowOnly: true, notes }));
1154
+ return 0;
1155
+ }
1156
+
977
1157
  /**
978
1158
  * Runs init. Options: `root` (the project folder, default the working
979
- * directory), `dryRun`, `force`, `log`. Returns the exit code: 0 done, 1
980
- * already set up or another file in the way.
1159
+ * directory), `dryRun`, `force`, `workflowOnly` (write just the workflow, for
1160
+ * a repository set up by an older version), `log`. Returns the exit code: 0
1161
+ * done, 1 already set up or another file in the way.
981
1162
  */
982
1163
  export function init({
983
1164
  root = ".",
984
1165
  dryRun = false,
985
1166
  force = false,
1167
+ workflowOnly = false,
986
1168
  log = console.log,
987
1169
  } = {}) {
988
1170
  const found = detectProject(root);
1171
+ if (workflowOnly) return initWorkflowOnly(found, { dryRun, force, log });
989
1172
  const plan = planInit(found);
990
1173
  if (!force) {
991
1174
  const config = join(found.project, CONFIG_NAME);
@@ -16,12 +16,19 @@ The kit is configured in `{{CONFIG}}` and built by the `Design kit` workflow
16
16
  (`.github/workflows/design-kit.yml`). The format and every command are in the
17
17
  `{{PACKAGE}}` README.
18
18
 
19
+ When this repository has Storybook and you add a component or change what
20
+ one renders, add or update its story in the same pull request, covering its
21
+ default, every variant and size, and the empty, loading, error and long-text
22
+ states that apply, so the next kit captures it. A component without a story
23
+ is missing from every kit until it gets one.
24
+
19
25
  ## 1. Fetch the newest kit
20
26
 
21
27
  The workflow runs once a day and on demand (`workflow_dispatch`), and uploads
22
- the `design-kit` artifact (kept 30 days). A day with no new commits skips and
23
- uploads nothing, yet still succeeds, so look up the newest artifact rather than
24
- the newest run, then download it once per kit commit:
28
+ the `design-kit` artifact (kept 90 days; older kits are deleted). A day when
29
+ none of the config's `watch` paths changed skips and uploads nothing, yet
30
+ still succeeds, so look up the newest artifact rather than the newest run,
31
+ then download it once per kit commit:
25
32
 
26
33
  ```bash
27
34
  REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
@@ -45,11 +52,15 @@ working files in a scratch folder, never in the repository.
45
52
  ## 2. Check staleness
46
53
 
47
54
  The kit is a snapshot of `manifest.sha`, up to a day behind. Check whether UI
48
- code changed since:
55
+ code changed since, over the same `watch` paths that make the workflow
56
+ rebuild (read from the config, so the two always agree):
49
57
 
50
58
  ```bash
51
59
  KIT_SHA=$(node -p 'require(process.argv[1]).sha' "$KIT/manifest.json")
52
- git fetch -q origin "$BRANCH" && git log --oneline "$KIT_SHA..origin/$BRANCH" -- {{WATCHED_PATHS}}
60
+ TOP=$(git rev-parse --show-toplevel)
61
+ git fetch -q origin "$BRANCH" &&
62
+ node -e 'const w = require(process.argv[1]).watch; process.stdout.write((w?.length ? w : ["."]).map((p) => `:(top)${p}`).join("\0"))' "$TOP/{{CONFIG}}" |
63
+ xargs -0 git log --oneline "$KIT_SHA..origin/$BRANCH" --
53
64
  ```
54
65
 
55
66
  When the list is not empty, say so on the mockup: "Kit from `<sha7>`; N UI