flet-box 1.0.9 → 1.1.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.
@@ -109,13 +109,54 @@ export const WARN_CODES = new Set([
109
109
  "UNCONSTRAINED_FORM_WIDTH",
110
110
  ]);
111
111
 
112
- const INFO_CODES = new Set([
112
+ export const INFO_CODES = new Set([
113
113
  "SMALL_PRESET",
114
114
  "CROWDED_FORM",
115
115
  "LIST_WITHOUT_STATE",
116
116
  "MULTIPLE_PRIMARY_ACTIONS",
117
117
  ]);
118
118
 
119
+ /**
120
+ * Which design rule each code proves, and the number the finding prints.
121
+ *
122
+ * The gate is the machine half of a contract whose human half is
123
+ * `skills/fletbox-design/SKILL.md`: 25 numbered rules, read by the agent that
124
+ * builds the screen. A finding that says `OFF_SCALE_SPACING` tells the reader the
125
+ * value is wrong; a finding that says `OFF_SCALE_SPACING — rule 07` tells them to
126
+ * open rule 07 and learn *why*, which is the only way the next screen is right
127
+ * without being told twice. `scripts/check-skills.mjs` fails if a code is missing
128
+ * here, if a number here is not a heading in the skill, or if the skill does not
129
+ * name that code inside the rule it is filed under — so this table and that file
130
+ * cannot drift apart.
131
+ */
132
+ export const CODE_RULE = {
133
+ RAW_COLOR: "03",
134
+ OFF_SCALE_SPACING: "07",
135
+ OFF_SCALE_RADIUS: "03",
136
+ OFF_SCALE_TYPE: "08",
137
+ SMALL_TAP_TARGET: "03",
138
+ LOW_CONTRAST: "11",
139
+ UNLABELED_FIELD: "13",
140
+ UNLABELED_TOGGLE: "11",
141
+ DESTRUCTIVE_COLOR: "14",
142
+ UNREADABLE: "00",
143
+ LOW_CONTRAST_SOFT: "11",
144
+ LOW_CONTRAST_UI: "11",
145
+ LOW_CONTRAST_ON_SURFACE: "11",
146
+ HAND_MIXED_COLOR: "03",
147
+ DEAD_ELEVATION: "10",
148
+ IMAGE_NO_ALT: "11",
149
+ ICON_ONLY_CONTROL: "11",
150
+ CENTERED_BODY_TEXT: "08",
151
+ GENERIC_ACTION_LABEL: "14",
152
+ CONFIRM_ORDER: "14",
153
+ UNCONSTRAINED_FORM_WIDTH: "13",
154
+ SMALL_PRESET: "03",
155
+ CROWDED_FORM: "06",
156
+ LIST_WITHOUT_STATE: "12",
157
+ MULTIPLE_PRIMARY_ACTIONS: "04",
158
+ };
159
+
119
160
  /**
120
161
  * The legal home for a hex value is the app's own token file. Anything else in
121
162
  * `src/` that types a color by hand is the drift this rule exists to catch.
@@ -1319,7 +1360,15 @@ export const checkUi = async (argv = [], io = {}) => {
1319
1360
 
1320
1361
  if (asJson) {
1321
1362
  print(
1322
- JSON.stringify({ package: api.pkg.version, scanned: files.length, findings }, null, 2),
1363
+ JSON.stringify(
1364
+ {
1365
+ package: api.pkg.version,
1366
+ scanned: files.length,
1367
+ findings: findings.map((item) => ({ rule: CODE_RULE[item.code] || null, ...item })),
1368
+ },
1369
+ null,
1370
+ 2,
1371
+ ),
1323
1372
  );
1324
1373
  } else {
1325
1374
  print(
@@ -1338,10 +1387,11 @@ export const checkUi = async (argv = [], io = {}) => {
1338
1387
  byCode.get(item.code).push(item);
1339
1388
  }
1340
1389
  for (const [code, list] of byCode) {
1390
+ const rule = CODE_RULE[code];
1341
1391
  print(
1342
1392
  section(
1343
1393
  "•",
1344
- `${code} — ${list.length} finding(s)`,
1394
+ `${code}${rule ? ` — rule ${rule}` : ""} — ${list.length} finding(s)`,
1345
1395
  ERROR_CODES.has(code) ? "#f87171" : INFO_CODES.has(code) ? "#94a3b8" : "#fbbf24",
1346
1396
  ),
1347
1397
  );
@@ -19,8 +19,16 @@ import * as templates from "../utils/templates.js";
19
19
  import { parseGithubRepo } from "../utils/bundleConfig.js";
20
20
  import { checkApi } from "../commands/checkApi.js";
21
21
  import { unpack } from "../commands/unpack.js";
22
+ import { skillNames, readSkill } from "../utils/skills.js";
22
23
  import { c, banner, gradient } from "../utils/colors.js";
23
24
 
25
+ /**
26
+ * Skills that every screen needs, whatever the task. Their Cursor twin rule loads
27
+ * with the file instead of waiting to be picked by description; the rest are
28
+ * read when their description matches what the agent is about to do.
29
+ */
30
+ const ALWAYS_ON_SKILLS = new Set(["fletbox-rules", "fletbox-design"]);
31
+
24
32
  const __filename = fileURLToPath(import.meta.url);
25
33
  const __dirname = path.dirname(__filename);
26
34
 
@@ -216,8 +224,8 @@ export const createProject = async (projectName, rawArgs = []) => {
216
224
  ".fletbox/api",
217
225
  ".fletbox/memory/notes",
218
226
  ".claude",
219
- ".claude/skills/fletbox-clean-ui",
220
- ".qoder/skills/fletbox-clean-ui",
227
+ ".claude/skills",
228
+ ".qoder/skills",
221
229
  ".cursor/rules",
222
230
  ];
223
231
  const extraDirs =
@@ -437,23 +445,36 @@ export const createProject = async (projectName, rawArgs = []) => {
437
445
  templates.cursorRules(),
438
446
  );
439
447
 
440
- // The clean-UI skill. Claude Code loads `.claude/skills/<name>/SKILL.md` by
441
- // description, so the agent picks it up whenever it touches a screen without
442
- // being told. `.qoder/skills/` holds a byte-identical copy because Qoder reads
443
- // the same shape from a different root — one text, two loaders, never two edits.
444
- const cleanUiSkill = templates.cleanUiSkill();
445
- createFile(
446
- path.join(projectPath, ".claude/skills/fletbox-clean-ui/SKILL.md"),
447
- cleanUiSkill,
448
- );
449
- createFile(
450
- path.join(projectPath, ".qoder/skills/fletbox-clean-ui/SKILL.md"),
451
- cleanUiSkill,
452
- );
453
- createFile(
454
- path.join(projectPath, ".cursor/rules/fletbox-clean-ui.mdc"),
455
- templates.cleanUiCursorRule(),
456
- );
448
+ // The agent skills: `skills/` at the package root is the only source, one
449
+ // kebab-case folder per subject (rules, design, motion, memory). The app gets
450
+ // the same text twice over: once as `skills/` at its own root — the folder any
451
+ // assistant or person reads first, and the one AGENTS.md §0 indexes — and once
452
+ // mirrored into `.claude/skills/` and `.qoder/skills/`, because Claude Code and
453
+ // Qoder load a skill by frontmatter description from those two roots and offer it
454
+ // without being told. Cursor has no skills folder, so each skill also ships as a
455
+ // pointer rule in `.cursor/rules/`. One text, three loaders, never three edits.
456
+ const skillList = skillNames();
457
+ if (skillList.length === 0) {
458
+ console.log(
459
+ c(
460
+ "yellow",
461
+ "⚠️ This flet-box package ships no skills/ folder — the app will have no design or motion contract.",
462
+ ),
463
+ );
464
+ }
465
+ for (const name of skillList) {
466
+ const text = readSkill(name);
467
+ createFile(path.join(projectPath, "skills", name, "SKILL.md"), text);
468
+ createFile(
469
+ path.join(projectPath, ".claude/skills", name, "SKILL.md"),
470
+ text,
471
+ );
472
+ createFile(path.join(projectPath, ".qoder/skills", name, "SKILL.md"), text);
473
+ createFile(
474
+ path.join(projectPath, ".cursor/rules", `${name}.mdc`),
475
+ templates.skillCursorRule(name, { alwaysApply: ALWAYS_ON_SKILLS.has(name) }),
476
+ );
477
+ }
457
478
  createFile(
458
479
  path.join(projectPath, "scripts/check-ui.mjs"),
459
480
  templates.checkUiScript(finalName),
@@ -467,6 +488,10 @@ export const createProject = async (projectName, rawArgs = []) => {
467
488
  path.join(projectPath, ".fletbox/memory/notes/baseline.md"),
468
489
  templates.memoryBaselineMd(finalName, template, pkgVersion),
469
490
  );
491
+ createFile(
492
+ path.join(projectPath, ".fletbox/memory/notes/current-status.md"),
493
+ templates.memoryStatusMd(finalName, template),
494
+ );
470
495
  createFile(
471
496
  path.join(projectPath, "scripts/check-app.mjs"),
472
497
  templates.checkAppScript(finalName),
@@ -0,0 +1,59 @@
1
+ // bin/utils/skills.js
2
+ //
3
+ // The `skills/` folder at the package root is the ONLY source for the agent
4
+ // skills. Claude Code and Qoder load `.claude/skills/<name>/SKILL.md` and
5
+ // `.qoder/skills/<name>/SKILL.md` by frontmatter description, so `flet-box create`
6
+ // copies each file into both roots byte-for-byte. Keeping the text in a real
7
+ // markdown file (instead of a 450-line array of strings in templates.js) means an
8
+ // editor, a diff and `scripts/check-skills.mjs` can all read the same bytes the
9
+ // agent will.
10
+ //
11
+ // Node core only: `bin/` may not import a devDependency at module scope.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const packageDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+
19
+ /** Absolute path of the folder that holds every shipped skill. */
20
+ export const skillRoot = () => path.join(packageDir, "skills");
21
+
22
+ /** Kebab-case folder names, sorted, skipping anything without a SKILL.md. */
23
+ export const skillNames = () => {
24
+ let entries = [];
25
+ try {
26
+ entries = fs.readdirSync(skillRoot(), { withFileTypes: true });
27
+ } catch {
28
+ return [];
29
+ }
30
+ return entries
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => entry.name)
33
+ .filter((name) => fs.existsSync(path.join(skillRoot(), name, "SKILL.md")))
34
+ .sort();
35
+ };
36
+
37
+ /**
38
+ * The full skill text, frontmatter included.
39
+ * @throws when the skill does not exist — a missing skill is a build error, not
40
+ * a silent skip, or an app ships with half the contract and no one notices.
41
+ */
42
+ export const readSkill = (name) =>
43
+ fs.readFileSync(path.join(skillRoot(), name, "SKILL.md"), "utf8");
44
+
45
+ /** The `key: value` lines between the two `---` fences. */
46
+ export const readFrontmatter = (name) => {
47
+ const text = readSkill(name);
48
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
49
+ if (!match) throw new Error(`skill "${name}" has no frontmatter block`);
50
+ const fields = {};
51
+ for (const line of match[1].split("\n")) {
52
+ const pair = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
53
+ if (pair) fields[pair[1]] = pair[2];
54
+ }
55
+ return fields;
56
+ };
57
+
58
+ /** One short sentence for a Cursor rule or an index: the description field. */
59
+ export const skillDescription = (name) => readFrontmatter(name).description || name;