@njeybe/nativ 2.6.0 → 2.7.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +71 -1
  3. package/dist/commands/design-check.d.ts +4 -0
  4. package/dist/commands/design-check.js +76 -0
  5. package/dist/commands/init.d.ts +1 -0
  6. package/dist/commands/init.js +53 -1
  7. package/dist/commands/update.js +46 -0
  8. package/dist/core/design-rules.d.ts +23 -0
  9. package/dist/core/design-rules.js +115 -0
  10. package/dist/core/harness-integrity.js +26 -7
  11. package/dist/index.js +11 -0
  12. package/dist/server/studio-ui/pages/benchmarks/styles.js +8 -5
  13. package/dist/server/studio-ui/pages/database/browser.js +1 -1
  14. package/dist/server/studio-ui/pages/database/connection.js +1 -1
  15. package/dist/server/studio-ui/pages/database/data-shell.js +5 -5
  16. package/dist/server/studio-ui/pages/database/drift.js +1 -1
  17. package/dist/server/studio-ui/pages/database/explorer.js +2 -2
  18. package/dist/server/studio-ui/pages/database/styles.js +5 -5
  19. package/dist/server/studio-ui/pages/flow/styles.js +3 -3
  20. package/dist/server/studio-ui/pages/home/script.js +110 -91
  21. package/dist/server/studio-ui/pages/home/styles.js +33 -45
  22. package/dist/server/studio-ui/pages/tasks/script.js +150 -3
  23. package/dist/server/studio-ui/pages/tasks/styles.js +63 -6
  24. package/dist/server/studio-ui/pages/team/script.js +49 -52
  25. package/dist/server/studio-ui/pages/team/styles.js +21 -20
  26. package/dist/server/studio-ui/pages/worktrees/script.js +47 -35
  27. package/dist/server/studio-ui/pages/worktrees/styles.js +12 -10
  28. package/dist/server/studio-ui/shell/brand.d.ts +4 -0
  29. package/dist/server/studio-ui/shell/brand.js +16 -0
  30. package/dist/server/studio-ui/shell/console.js +61 -2
  31. package/dist/server/studio-ui/shell/core.js +8 -8
  32. package/dist/server/studio-ui/shell/detail-view.js +1 -1
  33. package/dist/server/studio-ui/shell/dispatch.js +18 -0
  34. package/dist/server/studio-ui/shell/events-tools.js +5 -1
  35. package/dist/server/studio-ui/shell/format.js +5 -6
  36. package/dist/server/studio-ui/shell/icons.js +23 -15
  37. package/dist/server/studio-ui/shell/markup-head.js +11 -6
  38. package/dist/server/studio-ui/shell/markup-tools.js +19 -10
  39. package/dist/server/studio-ui/shell/paint.js +3 -0
  40. package/dist/server/studio-ui/shell/pipeline-state.js +1 -1
  41. package/dist/server/studio-ui/shell/proposals.js +6 -2
  42. package/dist/server/studio-ui/shell/pulse.d.ts +1 -0
  43. package/dist/server/studio-ui/shell/pulse.js +67 -0
  44. package/dist/server/studio-ui/shell/router.js +25 -0
  45. package/dist/server/studio-ui/shell/sidebar-view.js +6 -3
  46. package/dist/server/studio-ui/shell/toasts-format.js +4 -4
  47. package/dist/server/studio-ui/styles/console.js +33 -13
  48. package/dist/server/studio-ui/styles/dialogs.js +4 -4
  49. package/dist/server/studio-ui/styles/responsive.js +8 -0
  50. package/dist/server/studio-ui/styles/shell.js +24 -5
  51. package/dist/server/studio-ui/styles/strategist.js +10 -10
  52. package/dist/server/studio-ui/styles/tokens-base.js +23 -23
  53. package/dist/server/studio-ui.js +2 -0
  54. package/package.json +1 -1
  55. package/plugin/.claude-plugin/plugin.json +1 -1
  56. package/plugin/agents/architect.md +10 -11
  57. package/templates/GEMINI.md +3 -2
  58. package/templates/claude-agents/architect.md +10 -11
  59. package/templates/design/taste/LICENSE +21 -0
  60. package/templates/design/taste/SOURCE.md +28 -0
  61. package/templates/design/taste/v1/DIGEST.md +57 -0
  62. package/templates/design/taste/v1/SKILL.md +226 -0
  63. package/templates/design/taste/v2/DIGEST.md +62 -0
  64. package/templates/design/taste/v2/SKILL.md +1206 -0
  65. package/templates/dot-ai/subagents/architect.md +6 -0
  66. package/templates/dot-ai/subagents/flutter-developer.md +2 -11
  67. package/templates/dot-ai/subagents/frontend.md +47 -44
  68. package/templates/dot-ai/ui_specs.md +15 -36
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0
4
+
5
+ ### Added
6
+
7
+ - **Design profiles.** The Design Profile in `.ai/ui_specs.md` picks `taste-v1` or `taste-v2`. Workers follow the chosen profile's rules before they build UI. Native mobile and terminal UI get no profile and follow only their own spec; an unset profile makes frontend workers escalate.
8
+ - **Bundled taste-skill v1 and v2.** init and update install `.ai/design/taste/` with each version's `SKILL.md` and a short `DIGEST.md` that workers read first. Untouched copies refresh on update. Credit: Leonxlnx, MIT License, pinned at commit `717446e`.
9
+ - **`nativ init --design`.** Choose the design profile when scaffolding a project.
10
+ - **`nativ design check`.** Scans UI files with eight rules for emoji, em and en dashes, `h-screen`, scroll listeners, pure black, Google Fonts links, hand-rolled icons and custom cursors. Exits 1 on errors; warnings never fail. Supports `--profile` and `--json`.
11
+ - **Frontend pre-flight.** The frontend guide runs `nativ design check` on the task's UI files before a worker finishes.
12
+ - **Doctor findings.** `nativ doctor` reports the design profile (`specs:design-profile`) and the bundle (`design:taste-bundle`).
13
+
14
+ ### Changed
15
+
16
+ - **Studio redesign.** Studio follows the taste-v1 design profile in a monochrome "Ink" direction. It has light and dark themes, and color is used only for status.
17
+ - **New wordmark.** The nativ wordmark replaces the old letter logo and follows the theme.
18
+ - **Phosphor icons.** Studio icons are now Phosphor (MIT). See Third-party notices in the README.
19
+ - **Pulse strip.** A strip under the top bar shows milestone progress and running, blocked and decision counts on every page.
20
+ - **Dispatch button.** The top bar has a "Dispatch `<next task>`" button.
21
+ - **Inspector.** The console drawer is now a right-side inspector. On narrow screens it is a sheet.
22
+ - **Home is a "now" page.** It shows decisions, running tasks, up next, milestone progress and recent activity.
23
+ - **Tasks list.** Tasks defaults to a list grouped by milestone, with filters and a Board toggle.
24
+ - **Team roster.** Team is a roster table.
25
+ - **Worktrees.** Worktree changes open in the inspector.
26
+ - **Restyled pages.** Flow, Benchmarks, Database and dialogs are restyled.
27
+
28
+ ### Removed
29
+
30
+ - The `nativ` design profile and every nativ-authored design rule. `nativ init --design` and `--profile` accept only `taste-v1` or `taste-v2`; `nativ design check` exits 1 until a profile is set or passed.
31
+ - The Anti-Generic Checklist (Web, Marketing pages, Mobile) from `ui_specs.md`, and the Transitions and Active state lines of its Accessibility section.
32
+ - Frontend guide: Visual style, Anti-generic rules and Build-time details, plus the `nativ` fallback and the style and Marketing pages checks in Before you finish.
33
+ - Flutter guide: Anti-generic rules.
34
+ - Architect guides: the `nativ` profile choice, the Inter typeface rule and the Anti-Generic and Marketing pages checks.
35
+ - Doctor finding `specs:marketing`.
36
+ - `nativ update` does not change an existing project's `.ai/ui_specs.md`, so projects keep their own text. Their frontend workers escalate UI tasks until the architect fills the Design Profile.
37
+
3
38
  ## 2.6.0
4
39
 
5
40
  ### Improved: token usage and autonomy
package/README.md CHANGED
@@ -26,6 +26,7 @@ cd your-project && nativ init # scaffold .ai/ and the Claude Code configurati
26
26
  | | [Database](#database) | [Troubleshooting](#troubleshooting) |
27
27
  | | [Worktrees](#worktrees) | [For maintainers](#for-maintainers) |
28
28
  | | [Studio, tests and benchmarks](#studio-tests-and-benchmarks) | |
29
+ | | | [Design profiles](#design-profiles) |
29
30
 
30
31
  ---
31
32
 
@@ -140,7 +141,8 @@ Every command takes an optional `[targetDir]` (default: the current directory) a
140
141
 
141
142
  | Command | What it does | Who |
142
143
  | :--- | :--- | :--- |
143
- | [`init`](#nativ-init) | Scaffold `.ai/` and the Claude Code configuration | You |
144
+ | [`init`](#nativ-init) | Scaffold `.ai/` and the Claude Code configuration; `--design` picks a design profile | You |
145
+ | [`design check`](#nativ-design-check) | Scan UI files for design-rule violations | You, Agent |
144
146
  | [`setup`](#nativ-setup) | Write or merge the Claude Code configuration | You |
145
147
  | [`profile`](#nativ-profile) | Inspect or switch ceremony profile (`prototype`, `solo`, `enterprise`) | You, Agent |
146
148
  | [`doctor`](#nativ-doctor) | Check the integration and the harness; `--fix` repairs | You, Agent (read-only) |
@@ -190,6 +192,20 @@ nativ init [targetDir] [-f, --force]
190
192
  | Option | Description |
191
193
  | :--- | :--- |
192
194
  | `-f, --force` | Overwrite existing specification and directive files |
195
+ | `--design <taste-v1|taste-v2>` | Set the design profile in `.ai/ui_specs.md` and install the taste-skill bundle |
196
+
197
+ #### `nativ design check`
198
+
199
+ Scan UI source files for design-rule violations. Exits 1 on any error.
200
+
201
+ ```bash
202
+ nativ design check [paths...] [--profile <id>] [--json]
203
+ ```
204
+
205
+ | Option | Description |
206
+ | :--- | :--- |
207
+ | `--profile <id>` | `taste-v1` or `taste-v2`; default is the profile in `.ai/ui_specs.md`. With neither, the command exits 1 |
208
+ | `--json` | Print findings as JSON |
193
209
 
194
210
  #### `nativ setup`
195
211
 
@@ -656,6 +672,29 @@ Run the MCP server over stdio. `setup` registers it; see [MCP server](#mcp-serve
656
672
 
657
673
  ---
658
674
 
675
+ ## Design profiles
676
+
677
+ nativ can hold web UI work to a design profile. Pick one per web project; it is recorded in the
678
+ Design Profile section of `.ai/ui_specs.md`.
679
+
680
+ | Profile | Use for |
681
+ | :--- | :--- |
682
+ | `taste-v2` | Landing sites, portfolios, editorial pages and web redesigns |
683
+ | `taste-v1` | Web apps, SaaS screens and dashboards |
684
+
685
+ Native mobile (Flutter) and terminal UI get no profile. They follow only their own spec.
686
+
687
+ Set it with `nativ init --design taste-v2` (or `taste-v1`), or let the architect pick one during
688
+ planning. An unset profile is a spec gap: frontend workers escalate, and `nativ design check`
689
+ exits 1 until a profile is set or passed with `--profile`. An architect must pick one. Init and update install the bundle in `.ai/design/taste/`: the
690
+ LICENSE, SOURCE.md, and `v1/` and `v2/` folders, each with `SKILL.md` and a short `DIGEST.md`
691
+ that workers read first. Run `nativ design check` to scan UI files for rule violations.
692
+
693
+ Credit: the taste-skill prompts are by Leonxlnx, [github.com/Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill),
694
+ MIT License, pinned at commit `717446e`.
695
+
696
+ ---
697
+
659
698
  ## Configuration
660
699
 
661
700
  ### `.nativ/config.json`
@@ -907,3 +946,34 @@ npm run sync-plugin # after changing a template or the setup generator; a t
907
946
  ## License
908
947
 
909
948
  MIT (c) JB Natividad. See [LICENSE](LICENSE).
949
+
950
+ ## Third-party notices
951
+
952
+ - **Phosphor Icons** (`@phosphor-icons/core` 2.1.1) is vendored into Studio under the MIT license:
953
+
954
+ ```
955
+ MIT License
956
+
957
+ Copyright (c) 2023 Phosphor Icons
958
+
959
+ Permission is hereby granted, free of charge, to any person obtaining a copy
960
+ of this software and associated documentation files (the "Software"), to deal
961
+ in the Software without restriction, including without limitation the rights
962
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
963
+ copies of the Software, and to permit persons to whom the Software is
964
+ furnished to do so, subject to the following conditions:
965
+
966
+ The above copyright notice and this permission notice shall be included in all
967
+ copies or substantial portions of the Software.
968
+
969
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
970
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
971
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
972
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
973
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
974
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
975
+ SOFTWARE.
976
+ ```
977
+
978
+ - **taste-skill** by Leonxlnx (MIT). The bundled copy keeps its own LICENSE under
979
+ `templates/design/taste/`.
@@ -0,0 +1,4 @@
1
+ export declare function runDesignCheck(paths: string[] | undefined, options: {
2
+ profile?: string;
3
+ json?: boolean;
4
+ }): void;
@@ -0,0 +1,76 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { DESIGN_PROFILES, SCAN_EXTENSIONS, checkText, readProfile, } from '../core/design-rules.js';
4
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.ai']);
5
+ let jsonMode = false;
6
+ function fail(msg) {
7
+ if (jsonMode)
8
+ console.log(JSON.stringify({ error: msg }));
9
+ else
10
+ console.error(msg);
11
+ process.exit(1);
12
+ }
13
+ function isScannable(name) {
14
+ if (/\.(test|spec)\./.test(name))
15
+ return false;
16
+ return SCAN_EXTENSIONS.includes(path.extname(name));
17
+ }
18
+ function collect(target, out) {
19
+ const stat = fs.statSync(target);
20
+ if (stat.isFile()) {
21
+ if (isScannable(path.basename(target)))
22
+ out.push(target);
23
+ return;
24
+ }
25
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
26
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name))
27
+ continue;
28
+ collect(path.join(target, entry.name), out);
29
+ }
30
+ }
31
+ function resolveProfile(opt, cwd) {
32
+ if (opt !== undefined) {
33
+ if (!DESIGN_PROFILES.includes(opt)) {
34
+ fail(`Unknown profile "${opt}". Use one of: ${DESIGN_PROFILES.join(', ')}`);
35
+ }
36
+ return opt;
37
+ }
38
+ const specs = path.join(cwd, '.ai', 'ui_specs.md');
39
+ const found = fs.existsSync(specs) ? readProfile(fs.readFileSync(specs, 'utf8')) : null;
40
+ if (found)
41
+ return found;
42
+ return fail('No design profile is set. Pass --profile taste-v1|taste-v2, or ask the architect '
43
+ + 'to fill the Design Profile in .ai/ui_specs.md.');
44
+ }
45
+ export function runDesignCheck(paths, options) {
46
+ jsonMode = !!options.json;
47
+ const cwd = process.cwd();
48
+ const profile = resolveProfile(options.profile, cwd);
49
+ const files = [];
50
+ for (const p of paths && paths.length ? paths : ['src']) {
51
+ const abs = path.resolve(cwd, p);
52
+ if (!fs.existsSync(abs))
53
+ fail(`Path not found: ${p}`);
54
+ collect(abs, files);
55
+ }
56
+ const findings = [];
57
+ for (const abs of files) {
58
+ const file = path.relative(cwd, abs).split(path.sep).join('/');
59
+ const text = fs.readFileSync(abs, 'utf8');
60
+ for (const f of checkText(text, path.extname(abs), profile))
61
+ findings.push({ file, ...f });
62
+ }
63
+ const errors = findings.filter((f) => f.severity === 'error').length;
64
+ const warnings = findings.length - errors;
65
+ if (options.json) {
66
+ const out = { profile, filesScanned: files.length, findings, errors, warnings };
67
+ console.log(JSON.stringify(out, null, 2));
68
+ }
69
+ else {
70
+ for (const f of findings) {
71
+ console.log(`${f.file}:${f.line}:${f.column} ${f.rule} ${f.message}`);
72
+ }
73
+ console.log(`${files.length} files scanned (${profile}): ${errors} errors, ${warnings} warnings`);
74
+ }
75
+ process.exit(errors > 0 ? 1 : 0);
76
+ }
@@ -1,3 +1,4 @@
1
1
  export declare function runInit(targetDirArg?: string, options?: {
2
2
  force?: boolean;
3
+ design?: string;
3
4
  }): Promise<void>;
@@ -1,11 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { createHash } from 'node:crypto';
4
5
  import pc from 'picocolors';
5
6
  import { detectProject } from '../scanner/detector.js';
6
7
  import { buildContextContent } from '../scanner/context-builder.js';
7
8
  import { runSetup, printSetupResult } from './setup.js';
8
9
  import { stampManaged } from '../core/setup-assets.js';
10
+ import { DESIGN_PROFILES } from '../core/design-rules.js';
9
11
  const __filename = fileURLToPath(import.meta.url);
10
12
  const __dirname = path.dirname(__filename);
11
13
  function getTemplatesDir() {
@@ -16,7 +18,55 @@ function getTemplatesDir() {
16
18
  // Fallback for direct development: src/../templates
17
19
  return path.resolve(__dirname, '../templates');
18
20
  }
21
+ // Sets the Profile line, and the Digest line for taste profiles, in the ui_specs template.
22
+ function applyDesignProfile(spec, profile) {
23
+ let out = spec.replace(/^(- \*\*Profile:\*\*).*$/m, `$1 ${profile}`);
24
+ if (profile.startsWith('taste-')) {
25
+ const digest = `.ai/design/taste/${profile.slice('taste-'.length)}/DIGEST.md`;
26
+ out = out.replace(/^(- \*\*Digest:\*\*).*$/m, `$1 ${digest}`);
27
+ }
28
+ return out;
29
+ }
30
+ function listBundle(dir, base = dir, out = []) {
31
+ if (!fs.existsSync(dir))
32
+ return out;
33
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ const full = path.join(dir, e.name);
35
+ if (e.isDirectory())
36
+ listBundle(full, base, out);
37
+ else
38
+ out.push(path.relative(base, full));
39
+ }
40
+ return out;
41
+ }
42
+ function installTasteBundle(templatesDir, aiDir, force) {
43
+ const src = path.join(templatesDir, 'design', 'taste');
44
+ const manifestPath = path.join(aiDir, 'design', 'taste', '.manifest.json');
45
+ let manifest = {};
46
+ try {
47
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
48
+ }
49
+ catch { /* none yet */ }
50
+ for (const rel of listBundle(src)) {
51
+ const dest = path.join(aiDir, 'design', 'taste', rel);
52
+ if (fs.existsSync(dest) && !force)
53
+ continue;
54
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
55
+ fs.copyFileSync(path.join(src, rel), dest);
56
+ const text = fs.readFileSync(dest, 'utf8').replace(/\r/g, '');
57
+ manifest[rel.split(path.sep).join('/')] = createHash('sha256').update(text).digest('hex');
58
+ }
59
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
60
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
61
+ console.log(pc.green('✔ Installed design bundle .ai/design/taste/'));
62
+ }
19
63
  export async function runInit(targetDirArg, options = {}) {
64
+ if (options.design !== undefined && !DESIGN_PROFILES.includes(options.design)) {
65
+ const list = DESIGN_PROFILES.join(', ');
66
+ console.error(pc.red(`✖ Unknown design profile "${options.design}". Use one of: ${list}`));
67
+ process.exitCode = 1;
68
+ return;
69
+ }
20
70
  const targetDir = path.resolve(targetDirArg || process.cwd());
21
71
  const templatesDir = getTemplatesDir();
22
72
  console.log(pc.bold(pc.cyan('\nNativ: Initializing Multi-Tier Pipeline...')));
@@ -81,9 +131,11 @@ export async function runInit(targetDirArg, options = {}) {
81
131
  const uiTemplate = fs.readFileSync(path.join(templatesDir, 'dot-ai/ui_specs.md'), 'utf8');
82
132
  const uiPath = path.join(aiDir, 'ui_specs.md');
83
133
  if (!fs.existsSync(uiPath) || options.force) {
84
- fs.writeFileSync(uiPath, uiTemplate, 'utf8');
134
+ const uiContent = options.design ? applyDesignProfile(uiTemplate, options.design) : uiTemplate;
135
+ fs.writeFileSync(uiPath, uiContent, 'utf8');
85
136
  console.log(pc.green('✔ Created .ai/ui_specs.md'));
86
137
  }
138
+ installTasteBundle(templatesDir, aiDir, options.force);
87
139
  // 7. Write .ai/master_plan.json
88
140
  const planTemplate = fs.readFileSync(path.join(templatesDir, 'dot-ai/master_plan.json'), 'utf8');
89
141
  const planContent = planTemplate
@@ -1,10 +1,54 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import pc from 'picocolors';
4
5
  import { detectProject } from '../scanner/detector.js';
5
6
  import { applySetup, getTemplatesDir, planTemplateFile } from '../core/setup-assets.js';
6
7
  import { printSetupResult } from './setup.js';
7
8
  import { runValidate } from './validate.js';
9
+ const noCr = (text) => text.replace(/\r/g, '');
10
+ function walkFiles(dir) {
11
+ if (!fs.existsSync(dir))
12
+ return [];
13
+ return fs.readdirSync(dir, { withFileTypes: true })
14
+ .flatMap((e) => (e.isDirectory() ? walkFiles(path.join(dir, e.name)) : [path.join(dir, e.name)]));
15
+ }
16
+ // Plain copies (no stamp) so doctor can compare them; edited files are kept unless forced.
17
+ function syncTasteBundle(templatesDir, aiDir, force) {
18
+ const src = path.join(templatesDir, 'design', 'taste');
19
+ let kept = 0;
20
+ const manifestPath = path.join(aiDir, 'design', 'taste', '.manifest.json');
21
+ let manifest = {};
22
+ try {
23
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
24
+ }
25
+ catch { /* installed before manifests */ }
26
+ const hash = (t) => createHash('sha256').update(noCr(t)).digest('hex');
27
+ for (const file of walkFiles(src)) {
28
+ const rel = path.relative(src, file).split(path.sep).join('/');
29
+ const dest = path.join(aiDir, 'design', 'taste', ...rel.split('/'));
30
+ const text = fs.readFileSync(file, 'utf8');
31
+ const existing = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null;
32
+ const shown = `.ai/design/taste/${rel}`;
33
+ if (existing !== null && noCr(existing) === noCr(text)) {
34
+ printChange({ path: shown, action: 'unchanged' });
35
+ manifest[rel] ??= hash(text);
36
+ }
37
+ else if (existing !== null && !force && manifest[rel] !== hash(existing)) {
38
+ kept++;
39
+ printChange({ path: shown, action: 'skipped', detail: 'differs from the template' });
40
+ }
41
+ else {
42
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
43
+ fs.writeFileSync(dest, text, 'utf8');
44
+ manifest[rel] = hash(text);
45
+ printChange({ path: shown, action: existing === null ? 'created' : 'updated' });
46
+ }
47
+ }
48
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
49
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
50
+ return kept;
51
+ }
8
52
  const ICON = { created: '+', updated: '~', unchanged: '=', skipped: '!' };
9
53
  function printChange(change) {
10
54
  const paint = change.action === 'created' ? pc.green : change.action === 'updated' ? pc.cyan : change.action === 'skipped' ? pc.yellow : pc.dim;
@@ -62,6 +106,8 @@ export async function runUpdate(targetDirArg, options = {}) {
62
106
  kept++;
63
107
  printChange({ path: item.rel, action: plan.action, detail: plan.detail });
64
108
  }
109
+ console.log(pc.bold('\nDesign bundle'));
110
+ kept += syncTasteBundle(templatesDir, aiDir, options.force);
65
111
  // 2. Everything `nativ setup` manages: AGENTS.md, .claude/agents/, settings, MCP server
66
112
  console.log(pc.bold('\nClaude Code configuration'));
67
113
  const setup = applySetup(targetDir, { force: options.force });
@@ -0,0 +1,23 @@
1
+ export declare const DESIGN_PROFILES: readonly ["taste-v1", "taste-v2"];
2
+ export type DesignProfile = (typeof DESIGN_PROFILES)[number];
3
+ export interface DesignFinding {
4
+ line: number;
5
+ column: number;
6
+ rule: string;
7
+ severity: 'error' | 'warning';
8
+ message: string;
9
+ }
10
+ interface Rule {
11
+ id: string;
12
+ profiles: readonly DesignProfile[];
13
+ severity: 'error' | 'warning';
14
+ message: string;
15
+ pattern?: RegExp;
16
+ exts?: readonly string[];
17
+ }
18
+ export declare const DESIGN_RULES: readonly Rule[];
19
+ export declare const SCAN_EXTENSIONS: string[];
20
+ export declare function readProfile(uiSpecsText: string): DesignProfile | null;
21
+ export declare function isCommentLine(line: string): boolean;
22
+ export declare function checkText(text: string, ext: string, profile: DesignProfile): DesignFinding[];
23
+ export {};
@@ -0,0 +1,115 @@
1
+ export const DESIGN_PROFILES = ['taste-v1', 'taste-v2'];
2
+ const TASTE = ['taste-v1', 'taste-v2'];
3
+ export const DESIGN_RULES = [
4
+ {
5
+ id: 'no-emoji', profiles: DESIGN_PROFILES, severity: 'error',
6
+ message: 'Emoji found; use an icon from the catalog',
7
+ pattern: /\p{Extended_Pictographic}/gu,
8
+ },
9
+ {
10
+ id: 'no-em-dash', profiles: ['taste-v2'], severity: 'error',
11
+ message: 'Em or en dash found; use a comma, colon or hyphen',
12
+ pattern: /[–—]/g,
13
+ },
14
+ {
15
+ id: 'no-h-screen', profiles: TASTE, severity: 'error',
16
+ message: 'h-screen found; use min-h-[100dvh]',
17
+ pattern: /(?<![\w-])h-screen(?![\w-])/g,
18
+ },
19
+ {
20
+ id: 'no-scroll-listener', profiles: TASTE, severity: 'error',
21
+ message: 'Scroll listener found; use IntersectionObserver or CSS',
22
+ pattern: /addEventListener\(\s*['"`]scroll['"`]/g,
23
+ },
24
+ {
25
+ id: 'no-pure-black', profiles: TASTE, severity: 'warning',
26
+ message: 'Pure black found; use an off-black token',
27
+ pattern: /#000(?:000)?(?![0-9a-fA-F])|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*[,)]/g,
28
+ },
29
+ {
30
+ id: 'no-google-fonts-link', profiles: ['taste-v2'], severity: 'warning',
31
+ message: 'Google Fonts link found; self-host the font',
32
+ pattern: /fonts\.googleapis\.com/g,
33
+ },
34
+ {
35
+ id: 'no-custom-cursor', profiles: TASTE, severity: 'warning',
36
+ message: 'Custom cursor found',
37
+ pattern: /cursor:\s*url\(|cursor-\[url/g,
38
+ },
39
+ {
40
+ id: 'no-hand-rolled-icon', profiles: TASTE, severity: 'warning',
41
+ message: 'Inline svg with a path; use an icon from the catalog',
42
+ exts: ['.tsx', '.jsx'],
43
+ },
44
+ ];
45
+ export const SCAN_EXTENSIONS = [
46
+ '.tsx', '.jsx', '.ts', '.js', '.html', '.vue', '.svelte', '.astro', '.mdx', '.css',
47
+ ];
48
+ const PROFILE_LINE = /^- \*\*Profile:\*\*\s*(taste-v1|taste-v2)\s*$/m;
49
+ export function readProfile(uiSpecsText) {
50
+ const m = PROFILE_LINE.exec(uiSpecsText);
51
+ return m ? m[1] : null;
52
+ }
53
+ export function isCommentLine(line) {
54
+ const t = line.trim();
55
+ return ['//', '/*', '*', '{/*', '<!--'].some((p) => t.startsWith(p));
56
+ }
57
+ function active(profile, ext) {
58
+ return DESIGN_RULES.filter((r) => r.profiles.includes(profile) && (!r.exts || r.exts.includes(ext)));
59
+ }
60
+ function lineStarts(text) {
61
+ const starts = [0];
62
+ for (let i = 0; i < text.length; i++)
63
+ if (text[i] === '\n')
64
+ starts.push(i + 1);
65
+ return starts;
66
+ }
67
+ function locate(starts, index) {
68
+ let lo = 0;
69
+ let hi = starts.length - 1;
70
+ while (lo < hi) {
71
+ const mid = (lo + hi + 1) >> 1;
72
+ if (starts[mid] <= index)
73
+ lo = mid;
74
+ else
75
+ hi = mid - 1;
76
+ }
77
+ return { line: lo + 1, column: index - starts[lo] + 1 };
78
+ }
79
+ function svgFindings(text, rule, starts, lines) {
80
+ const out = [];
81
+ const re = /<svg\b/g;
82
+ let m;
83
+ while ((m = re.exec(text))) {
84
+ const end = text.indexOf('</svg>', m.index);
85
+ const body = text.slice(m.index, end === -1 ? text.length : end);
86
+ const pos = locate(starts, m.index);
87
+ if (/<path\b/.test(body) && !isCommentLine(lines[pos.line - 1])) {
88
+ out.push({ ...pos, rule: rule.id, severity: rule.severity, message: rule.message });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ export function checkText(text, ext, profile) {
94
+ const rules = active(profile, ext);
95
+ const lines = text.split('\n');
96
+ const starts = lineStarts(text);
97
+ const found = [];
98
+ for (const rule of rules) {
99
+ if (!rule.pattern) {
100
+ found.push(...svgFindings(text, rule, starts, lines));
101
+ continue;
102
+ }
103
+ lines.forEach((line, i) => {
104
+ if (isCommentLine(line))
105
+ return;
106
+ for (const m of line.matchAll(rule.pattern)) {
107
+ found.push({
108
+ line: i + 1, column: (m.index ?? 0) + 1,
109
+ rule: rule.id, severity: rule.severity, message: rule.message,
110
+ });
111
+ }
112
+ });
113
+ }
114
+ return found.sort((a, b) => a.line - b.line || a.column - b.column);
115
+ }
@@ -169,18 +169,37 @@ function checkLearnings(root) {
169
169
  }
170
170
  return findings;
171
171
  }
172
- function checkUiSpecs(root) {
173
- const specs = readText(path.join(root, '.ai', 'ui_specs.md'));
174
- if (specs === null || /Marketing pages/i.test(specs))
172
+ function checkDesignProfile(specs) {
173
+ if (specs === null || /^##\s+Design Profile\b/m.test(specs))
175
174
  return [];
176
175
  return [{
177
- id: 'specs:marketing',
176
+ id: 'specs:design-profile',
178
177
  status: 'info',
179
178
  category: 'specs',
180
- message: '.ai/ui_specs.md predates the Marketing pages checks. Ask the architect to add that part '
181
- + 'of the Anti-Generic Checklist from the current nativ template.',
179
+ message: '.ai/ui_specs.md has no Design Profile section. Ask the architect to add it from the '
180
+ + 'current nativ template (templates/dot-ai/ui_specs.md).',
182
181
  }];
183
182
  }
183
+ function checkTasteBundle(root, templatesDir) {
184
+ const mk = (message) => [{ id: 'design:taste-bundle', status: 'info', category: 'specs', message }];
185
+ const dir = path.join(root, '.ai', 'design', 'taste');
186
+ if (!fs.existsSync(dir)) {
187
+ return mk('The design bundle .ai/design/taste/ is missing. Run `nativ update` to install it.');
188
+ }
189
+ const differs = ['LICENSE', 'v1/SKILL.md', 'v2/SKILL.md'].filter((rel) => {
190
+ const want = readText(path.join(templatesDir, 'design', 'taste', rel));
191
+ const have = readText(path.join(dir, rel));
192
+ return want !== null && (have === null || have.replace(/\r/g, '') !== want.replace(/\r/g, ''));
193
+ });
194
+ if (!differs.length)
195
+ return [];
196
+ return mk(`Design bundle files differ from the nativ package: ${differs.join(', ')}. `
197
+ + 'Run `nativ update` (or `nativ update --force` to replace edited ones).');
198
+ }
199
+ function checkUiSpecs(root, templatesDir) {
200
+ const specs = readText(path.join(root, '.ai', 'ui_specs.md'));
201
+ return [...checkDesignProfile(specs), ...checkTasteBundle(root, templatesDir)];
202
+ }
184
203
  export function checkHarnessIntegrity(root, options = {}) {
185
204
  const findings = [
186
205
  ...checkGuardrailOverrides(root),
@@ -188,7 +207,7 @@ export function checkHarnessIntegrity(root, options = {}) {
188
207
  ...checkMissingRoleGuides(root),
189
208
  ...checkEditedRoleGuides(root, options.templatesDir ?? getTemplatesDir()),
190
209
  ...checkLearnings(root),
191
- ...checkUiSpecs(root),
210
+ ...checkUiSpecs(root, options.templatesDir ?? getTemplatesDir()),
192
211
  ];
193
212
  if (!findings.some((f) => f.category === 'guardrail' || f.category === 'secret')) {
194
213
  const message = 'No guardrail overrides and no secrets in files agents read';
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { runTriage } from './commands/triage.js';
18
18
  import { runNotifyTest } from './commands/notify.js';
19
19
  import { runLearnPropose, runLearnDecide, runLearnList } from './commands/learn.js';
20
20
  import { runProfileCommand } from './commands/profile.js';
21
+ import { runDesignCheck } from './commands/design-check.js';
21
22
  import { packageVersion } from './core/version.js';
22
23
  export function createProgram() {
23
24
  const program = new Command();
@@ -29,6 +30,7 @@ export function createProgram() {
29
30
  .command('init [targetDir]')
30
31
  .description('Scaffold the workflow (.ai/, AGENTS.md, directives) and the Claude Code configuration in the target directory')
31
32
  .option('-f, --force', 'Overwrite existing specification and directive files')
33
+ .option('--design <profile>', 'Design profile: nativ, taste-v1 or taste-v2')
32
34
  .action(async (targetDir, options) => {
33
35
  await runInit(targetDir, options);
34
36
  });
@@ -239,6 +241,15 @@ export function createProgram() {
239
241
  .action(async (taskId, targetDir, options) => {
240
242
  await runTaskUnlock(taskId, targetDir, { reason: options.reason, revoke: options.revoke });
241
243
  });
244
+ const design = program.command('design').description('Design profile tools');
245
+ design
246
+ .command('check [paths...]')
247
+ .description('Scan source files for design-rule violations (default path: src)')
248
+ .option('--profile <id>', 'Design profile: nativ, taste-v1 or taste-v2')
249
+ .option('--json', 'Output findings as JSON')
250
+ .action((paths, options) => {
251
+ runDesignCheck(paths, options);
252
+ });
242
253
  const learn = program
243
254
  .command('learn')
244
255
  .description('Project lessons the contracts do not capture: agents propose, a human approves');
@@ -1,6 +1,6 @@
1
1
  // Benchmarks view styles.
2
2
  export const benchmarksCss = String.raw `/* Benchmarks */
3
- .hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 20px 32px; padding: 22px; margin-bottom: 16px; transition: opacity .2s; }
3
+ .hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 20px 32px; padding: 22px 0; margin-bottom: 0; background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; box-shadow: none; transition: opacity .2s; }
4
4
  .hero.is-running { opacity: .6; }
5
5
  .eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
6
6
  .hero-value { display: flex; align-items: center; gap: 10px; margin: 6px 0; font-size: 40px; font-weight: 650; letter-spacing: -0.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
@@ -9,14 +9,17 @@ export const benchmarksCss = String.raw `/* Benchmarks */
9
9
  .hero-stats { display: grid; grid-template-columns: repeat(4, minmax(96px, auto)); gap: 4px 28px; margin: 0; }
10
10
  .hero-stats dt { font-size: 12.5px; color: var(--muted); }
11
11
  .hero-stats dd { margin: 0; font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
12
- .scenario-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
13
- .scenario { display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 16px; }
14
- .scenario.is-idle { box-shadow: none; border-style: dashed; }
12
+ .scenario-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; }
13
+ .scenario { display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 12px 0; background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; box-shadow: none; transition: background .15s; }
14
+ .scenario:hover { background: var(--surface-2); }
15
+ .scenario.is-idle { color: var(--fg-3); }
15
16
  .scenario-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
16
17
  .scenario-head h3 { margin: 0; font-size: 14.5px; font-weight: 600; }
17
18
  .scenario-sub { margin: -6px 0 0; font-size: 13px; color: var(--muted); }
18
19
  .scenario p { margin: 0; font-size: 13.5px; line-height: 1.5; }
19
- .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
20
+ .metrics { display: inline-flex; font-variant-numeric: tabular-nums; }
21
+ .metrics > * { padding: 0 16px; border-left: 1px solid var(--line); }
22
+ .metrics > :first-child { padding-left: 0; border-left: 0; }
20
23
  .metric-v { display: block; font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
21
24
  .metric-l { display: block; font-size: 12px; color: var(--muted); }
22
25
  .details { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; padding-top: 10px; font-size: 12.5px; border-top: 1px solid var(--border); }
@@ -116,7 +116,7 @@ export const databaseBrowserScript = String.raw ` // ─── Database Studio:
116
116
  var start = res.totalCount === 0 ? 0 : (res.page - 1) * res.limit + 1;
117
117
  var end = Math.min(res.page * res.limit, res.totalCount);
118
118
  var pagination = '<div class="pagination-bar">' +
119
- '<div>Showing ' + start + '–' + end + ' of ' + res.totalCount + ' ' + (isColl ? 'documents' : 'records') + '</div>' +
119
+ '<div>Showing ' + start + '-' + end + ' of ' + res.totalCount + ' ' + (isColl ? 'documents' : 'records') + '</div>' +
120
120
  '<div class="pagination-ctrls">' +
121
121
  '<button class="btn small" type="button" id="btn-page-prev"' + (res.page <= 1 ? ' disabled' : '') + '>Previous</button>' +
122
122
  '<span>Page ' + res.page + ' of ' + Math.max(1, res.totalPages) + '</span>' +
@@ -148,7 +148,7 @@ export const databaseConnectionScript = String.raw ` // ─── Database Stud
148
148
  out.className = 'result err';
149
149
  out.textContent = 'Error: ' + (r.error || 'Connection failed');
150
150
  var hint = hintFor(r.error);
151
- if (hint) out.textContent += ' — ' + hint;
151
+ if (hint) out.textContent += ': ' + hint;
152
152
  return false;
153
153
  }, function (e) {
154
154
  out.className = 'result err'; out.textContent = 'Error: ' + e.message; return false;