@twoux/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
package/bin/init.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // twoux init: sets up the current project so AI coding agents build with TwoUX.
2
+ // Never overwrites: appends to AGENTS.md and CLAUDE.md, merges the hook into .claude/settings.json,
3
+ // and skips files that already exist (use --force to replace skills and the proposal template).
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const pkg = new URL("..", import.meta.url).pathname;
8
+ const cwd = process.cwd();
9
+ const force = process.argv.includes("--force");
10
+ // --principles: the app is not built with TwoUX and isn't migrating; agents follow TwoUX's principles with the app's own components.
11
+ const principles = process.argv.includes("--principles");
12
+ const done = [];
13
+ const skip = [];
14
+
15
+ const AGENTS_LINE = principles
16
+ ? "This app is not built with TwoUX. Follow the principles in `node_modules/@twoux/ui/agent/AGENTS.md` (every rule not marked TwoUX only) using this app's own components, tokens and brand; never import TwoUX components piecemeal, and keep the look of anything you rebuild. Before finishing, run `npx twoux check --principles`, type-check, and open each changed screen in a browser to try the states you changed; say what you could not verify. To move onto TwoUX, follow `node_modules/@twoux/ui/agent/docs-md/migrating.md`."
17
+ : "Follow `node_modules/@twoux/ui/agent/AGENTS.md` for all UI work: components, tokens, patterns, and how to propose additions. Run `npx twoux check` before finishing.";
18
+ const agents = join(cwd, "AGENTS.md");
19
+ if (!existsSync(agents)) { writeFileSync(agents, `# Agent instructions\n\n## UI: TwoUX\n\n${AGENTS_LINE}\n`); done.push("AGENTS.md (created)"); }
20
+ else if (!readFileSync(agents, "utf8").includes("@twoux/ui/agent/AGENTS.md")) { writeFileSync(agents, `${readFileSync(agents, "utf8").trimEnd()}\n\n## UI: TwoUX\n\n${AGENTS_LINE}\n`); done.push("AGENTS.md (section added)"); }
21
+ else skip.push("AGENTS.md already points to TwoUX");
22
+
23
+ const claude = join(cwd, "CLAUDE.md");
24
+ const IMPORT = "@node_modules/@twoux/ui/agent/AGENTS.md";
25
+ // In principles mode Claude Code must read the scope line too, not only the imported rules.
26
+ const CLAUDE_BLOCK = principles ? `${AGENTS_LINE}\n\n${IMPORT}` : IMPORT;
27
+ if (!existsSync(claude)) { writeFileSync(claude, `${CLAUDE_BLOCK}\n`); done.push("CLAUDE.md (created)"); }
28
+ else if (!readFileSync(claude, "utf8").includes(IMPORT)) { writeFileSync(claude, `${readFileSync(claude, "utf8").trimEnd()}\n\n${CLAUDE_BLOCK}\n`); done.push("CLAUDE.md (import added)"); }
29
+ else skip.push("CLAUDE.md already imports TwoUX");
30
+
31
+ // Design skills such as Impeccable take the product's voice from PRODUCT.md ("Brand Commitments"), not from AGENTS.md,
32
+ // so the TwoUX voice is written there too. Created only when Impeccable is in use; otherwise left alone.
33
+ const VOICE = "- Voice (TwoUX): interface copy follows `node_modules/@twoux/ui/agent/docs-md/voice-and-tone.md` and `writing.md`: precise, calm, candid and respectful of expertise; UK English, sentence case, verb-first labels. No humour, playful or celebratory copy, exclamation marks or \"we\"; delight comes from speed and precision, not words. This overrides any general writing or design skill.";
34
+ const product = join(cwd, "PRODUCT.md");
35
+ const home = process.env.HOME ?? "";
36
+ const usesImpeccable = [join(cwd, ".impeccable"), join(cwd, ".claude/skills/impeccable"), join(cwd, ".agents/skills/impeccable"), join(home, ".claude/skills/impeccable")].some((p) => existsSync(p));
37
+ if (existsSync(product)) {
38
+ const text = readFileSync(product, "utf8");
39
+ if (text.includes("docs-md/voice-and-tone.md")) skip.push("PRODUCT.md already carries the TwoUX voice");
40
+ else {
41
+ const m = text.match(/^## Brand Commitments[^\n]*\n/m);
42
+ let next = `${text.trimEnd()}\n\n## Brand Commitments\n\n${VOICE}\n`;
43
+ if (m) {
44
+ const head = text.slice(0, m.index + m[0].length);
45
+ const rest = text.slice(m.index + m[0].length);
46
+ next = rest.startsWith("\n- ") ? `${head}\n${VOICE}${rest}` : `${head}\n${VOICE}\n${rest}`;
47
+ }
48
+ writeFileSync(product, next);
49
+ done.push("PRODUCT.md (TwoUX voice in Brand Commitments)");
50
+ }
51
+ } else if (usesImpeccable) {
52
+ writeFileSync(product, `# Product\n\n## Brand Commitments\n\n${VOICE}\n`);
53
+ done.push("PRODUCT.md (created with the TwoUX voice; /impeccable init fills in the rest)");
54
+ } else skip.push("PRODUCT.md not created (no design skill found that reads it)");
55
+
56
+ const settingsPath = join(cwd, ".claude/settings.json");
57
+ mkdirSync(join(cwd, ".claude"), { recursive: true });
58
+ const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
59
+ const hookCmd = 'node "$CLAUDE_PROJECT_DIR/node_modules/@twoux/ui/bin/twoux.mjs" guard';
60
+ settings.hooks ??= {};
61
+ settings.hooks.PreToolUse ??= [];
62
+ if (!JSON.stringify(settings.hooks.PreToolUse).includes("twoux.mjs\\\" guard") && !JSON.stringify(settings.hooks.PreToolUse).includes("twoux.mjs guard")) {
63
+ settings.hooks.PreToolUse.push({ matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash", hooks: [{ type: "command", command: hookCmd }] });
64
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
65
+ done.push(".claude/settings.json (guard hook)");
66
+ } else skip.push("guard hook already set");
67
+
68
+ // MCP: the same knowledge as tools (find_component, get_doc, list_docs, get_rules, check).
69
+ // Claude Code reads .mcp.json; Cursor and VS Code get theirs only when the project already uses them.
70
+ const MCP = { command: "npx", args: ["-y", "@twoux/ui", "mcp"] };
71
+ for (const [file, key, entry, when] of [
72
+ [".mcp.json", "mcpServers", MCP, true],
73
+ [".cursor/mcp.json", "mcpServers", MCP, existsSync(join(cwd, ".cursor"))],
74
+ [".vscode/mcp.json", "servers", { type: "stdio", ...MCP }, existsSync(join(cwd, ".vscode"))],
75
+ ]) {
76
+ if (!when) continue;
77
+ const path = join(cwd, file);
78
+ let json = {};
79
+ if (existsSync(path)) {
80
+ try { json = JSON.parse(readFileSync(path, "utf8")); } catch { skip.push(`${file} is not valid JSON, so the TwoUX server was not added`); continue; }
81
+ }
82
+ json[key] ??= {};
83
+ if (json[key].twoux) { skip.push(`${file} already lists twoux`); continue; }
84
+ json[key].twoux = entry;
85
+ writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`);
86
+ done.push(`${file} (TwoUX MCP server)`);
87
+ }
88
+
89
+ // Build, design and propose assume @twoux/ui; an app that only follows the principles gets audit and write.
90
+ for (const s of principles ? ["twoux-audit", "twoux-write"] : ["twoux-audit", "twoux-build", "twoux-design", "twoux-propose", "twoux-write"]) {
91
+ const to = join(cwd, ".claude/skills", s);
92
+ if (!existsSync(join(pkg, "agent/skills", s))) continue; // not in this edition
93
+ if (existsSync(to) && !force) { skip.push(`.claude/skills/${s} exists`); continue; }
94
+ cpSync(join(pkg, "agent/skills", s), to, { recursive: true });
95
+ done.push(`.claude/skills/${s}`);
96
+ }
97
+
98
+ if (principles) skip.push("twoux.theme.json not created (--principles: the app keeps its own theme)");
99
+ else if (!existsSync(join(cwd, "twoux.theme.json"))) {
100
+ const t = JSON.parse(readFileSync(join(pkg, "theme.json"), "utf8"));
101
+ writeFileSync(join(cwd, "twoux.theme.json"), `${JSON.stringify(t, null, 2)}\n`);
102
+ done.push("twoux.theme.json (default: indigo, 4/6/8px)");
103
+ } else skip.push("twoux.theme.json exists");
104
+
105
+ if (!principles) mkdirSync(join(cwd, "proposals"), { recursive: true });
106
+ if (!principles && existsSync(join(pkg, "agent/proposals-TEMPLATE.md")) && (!existsSync(join(cwd, "proposals/TEMPLATE.md")) || force)) { cpSync(join(pkg, "agent/proposals-TEMPLATE.md"), join(cwd, "proposals/TEMPLATE.md")); done.push("proposals/TEMPLATE.md"); }
107
+
108
+ console.log("TwoUX is set up for AI agents.\n");
109
+ for (const d of done) console.log(` + ${d}`);
110
+ for (const s of skip) console.log(` · ${s}`);
111
+ console.log(principles
112
+ ? "\nNext: ask your agent to /twoux-audit a screen. It reviews it against TwoUX's principles and proposes fixes in your own components."
113
+ : "\nNext: import '@twoux/ui/styles.css' (then './twoux-theme.css' after running npx twoux theme), and ask your agent to /twoux-audit a screen.");
@@ -0,0 +1,87 @@
1
+ // Shared lookups over TwoUX's agent knowledge, used by `twoux docs`, `twoux find` and `twoux mcp`.
2
+ // Reads only files shipped in the package: agent/AGENTS.md, agent/docs-md/*.md and registry/jobs.json.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ export const pkg = new URL("..", import.meta.url).pathname;
7
+ const docsDir = join(pkg, "agent/docs-md");
8
+
9
+ export const rules = () => readFileSync(join(pkg, "agent/AGENTS.md"), "utf8");
10
+
11
+ let docsCache;
12
+ /** Every page: id, name, kind (foundation, component, pattern…), one-line summary. */
13
+ export function listDocs() {
14
+ if (docsCache) return docsCache;
15
+ docsCache = readdirSync(docsDir)
16
+ .filter((f) => f.endsWith(".md"))
17
+ .map((f) => {
18
+ const lines = readFileSync(join(docsDir, f), "utf8").split("\n");
19
+ const name = (lines.find((l) => l.startsWith("# ")) ?? f).replace(/^# /, "");
20
+ const summary = lines.find((l, i) => i > 0 && l.trim() && !l.startsWith("#")) ?? "";
21
+ const meta = lines.find((l) => l.startsWith("Kind: ")) ?? "";
22
+ const kind = meta.match(/^Kind: ([^·]+)/)?.[1].trim() ?? "foundation";
23
+ const status = meta.match(/Status: ([^·]+)/)?.[1].trim() ?? "";
24
+ return { id: f.replace(/\.md$/, ""), name, kind, status, summary: summary === meta ? "" : summary };
25
+ })
26
+ .sort((a, b) => a.id.localeCompare(b.id));
27
+ return docsCache;
28
+ }
29
+
30
+ export function getDoc(id) {
31
+ const clean = String(id).toLowerCase().trim().replace(/\.md$/, "").replace(/[^a-z0-9-]/g, "-");
32
+ const file = join(docsDir, `${clean}.md`);
33
+ if (existsSync(file)) return { id: clean, text: readFileSync(file, "utf8") };
34
+ // Accept a name such as "Side panel" or "SidePanel".
35
+ const flat = String(id).toLowerCase().replace(/[^a-z0-9]/g, "");
36
+ const hit = listDocs().find((d) => d.name.toLowerCase().replace(/[^a-z0-9]/g, "") === flat || d.id.replace(/-/g, "") === flat);
37
+ return hit ? { id: hit.id, text: readFileSync(join(docsDir, `${hit.id}.md`), "utf8") } : null;
38
+ }
39
+
40
+ const jobsFile = join(pkg, "registry/jobs.json");
41
+ const STOP = new Set("a an and the of to for in on or with by at as is it its that this from into show let people someone".split(" "));
42
+ const words = (s) => String(s).toLowerCase().replace(/[`"“”]/g, " ").split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !STOP.has(w)).map((w) => w.replace(/(?:ly|ing|ed|es|s)$/, ""));
43
+
44
+ /** Rank the job table, then page summaries, against a plain-language job such as "confirm a delete". */
45
+ export function findJob(query, limit = 5) {
46
+ const q = new Set(words(query));
47
+ if (!q.size) return [];
48
+ const docs = Object.fromEntries(listDocs().map((d) => [d.id, d]));
49
+ // Jobs that cover more of the query's words rank first; weights break ties.
50
+ const hits = (...texts) => new Set(texts.flatMap(words).filter((w) => q.has(w))).size * 10;
51
+ const score = (text, weight) => words(text).reduce((n, w) => n + (q.has(w) ? weight : 0), 0);
52
+ const named = (id) => docs[id]?.name ?? id;
53
+ const jobs = JSON.parse(readFileSync(jobsFile, "utf8")).jobs.map((j) => ({
54
+ job: j.job,
55
+ use: j.use,
56
+ name: docs[j.use]?.name ?? j.use,
57
+ how: j.how ?? "",
58
+ notThis: (j.notThis ?? []).map(named),
59
+ score: hits(j.job, named(j.use), j.how ?? "") + score(j.job, 3) + score(docs[j.use]?.name ?? j.use, 2) + score(j.how ?? "", 1),
60
+ }));
61
+ const seen = new Set();
62
+ const ranked = jobs.filter((j) => j.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
63
+ for (const j of ranked) seen.add(j.use);
64
+ // Pages that no job names directly, matched on their name and summary.
65
+ const pages = listDocs()
66
+ .filter((d) => !seen.has(d.id))
67
+ .map((d) => ({ job: d.summary, use: d.id, name: d.name, how: "", notThis: [], score: hits(d.name, d.summary) + score(d.name, 3) + score(d.summary, 1) }))
68
+ .filter((d) => d.score > 11)
69
+ .sort((a, b) => b.score - a.score)
70
+ .slice(0, Math.max(0, limit - ranked.length));
71
+ return [...ranked, ...pages];
72
+ }
73
+
74
+ export function formatMatches(matches) {
75
+ if (!matches.length) return "No job matched. Run `twoux docs` to list every page, or read the job table in AGENTS.md.";
76
+ return matches
77
+ .map((m) => [`${m.job}\n Use: ${m.name} (twoux docs ${m.use})`, m.how && ` How: ${m.how.replace(/`/g, "")}`, m.notThis.length && ` Not this: ${m.notThis.join(", ")}`].filter(Boolean).join("\n"))
78
+ .join("\n\n");
79
+ }
80
+
81
+ export function formatList(kind) {
82
+ const docs = listDocs().filter((d) => !kind || d.kind === kind);
83
+ const kinds = [...new Set(docs.map((d) => d.kind))];
84
+ return kinds
85
+ .map((k) => `## ${k}\n${docs.filter((d) => d.kind === k).map((d) => `- ${d.id}: ${d.name}${d.status && !d.status.startsWith("built") ? ` (${d.status})` : ""}`).join("\n")}`)
86
+ .join("\n\n");
87
+ }
package/bin/mcp.mjs ADDED
@@ -0,0 +1,116 @@
1
+ // twoux mcp: TwoUX's Model Context Protocol server over stdio, with no dependencies.
2
+ // Agents ask it the same questions people ask the docs: which component fits a job, what a page says,
3
+ // what the rules are, and whether code passes `twoux check`. Messages are newline-delimited JSON-RPC 2.0.
4
+ import { spawnSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline";
8
+ import { findJob, formatList, formatMatches, getDoc, pkg, rules } from "./knowledge.mjs";
9
+
10
+ const VERSION = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).version;
11
+ const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
12
+
13
+ const INSTRUCTIONS = `TwoUX is the design system for this app's UI. Before writing UI: call find_component with the job in plain words, then get_doc for the page it names (read "When not to use"). Call get_rules once per task for the full rule set. Before finishing, call check on the files you changed and fix every error.`;
14
+
15
+ const TOOLS = [
16
+ {
17
+ name: "find_component",
18
+ title: "Find the component for a job",
19
+ description: "Given a UI job in plain words (\"confirm a permanent delete\", \"filter a list by a few fields\"), return the one TwoUX component or pattern to use, how to use it and what not to use instead.",
20
+ inputSchema: { type: "object", properties: { job: { type: "string", description: "What the UI must do, in plain words." } }, required: ["job"] },
21
+ annotations: { readOnlyHint: true },
22
+ run: ({ job }) => formatMatches(findJob(job)),
23
+ },
24
+ {
25
+ name: "get_doc",
26
+ title: "Read a TwoUX page",
27
+ description: "Return one foundation, component or pattern page as Markdown: when to use, when not to use, behaviour, accessibility, keyboard, API, example code and sources. Accepts an id (side-panel) or a name (Side panel).",
28
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "Page id or name, such as button, side-panel or filtering-search." } }, required: ["id"] },
29
+ annotations: { readOnlyHint: true },
30
+ run: ({ id }) => {
31
+ const doc = getDoc(id);
32
+ if (doc) return doc.text;
33
+ throw new Error(`No page called "${id}". Call list_docs for every id, or find_component with the job.`);
34
+ },
35
+ },
36
+ {
37
+ name: "list_docs",
38
+ title: "List TwoUX pages",
39
+ description: "List every page id and name, grouped by kind. Filter with kind: component, pattern or foundation.",
40
+ inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["component", "pattern", "foundation"] } } },
41
+ annotations: { readOnlyHint: true },
42
+ run: ({ kind }) => formatList(kind),
43
+ },
44
+ {
45
+ name: "get_rules",
46
+ title: "Read the TwoUX rules",
47
+ description: "Return AGENTS.md: the steps before writing UI, every rule with its reason, and the full job-to-component table.",
48
+ inputSchema: { type: "object", properties: {} },
49
+ annotations: { readOnlyHint: true },
50
+ run: () => rules(),
51
+ },
52
+ {
53
+ name: "check",
54
+ title: "Run twoux check",
55
+ description: "Lint app code against TwoUX's rules (raw colours, arbitrary values, inline styles, local copies, bypassed imports, copy tone). Returns the report; errors must be fixed before finishing.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ paths: { type: "array", items: { type: "string" }, description: "Files or folders relative to the project root. Default: src." },
60
+ principles: { type: "boolean", description: "The app is not built with TwoUX: run only the checks that hold outside it." },
61
+ },
62
+ },
63
+ annotations: { readOnlyHint: true },
64
+ run: ({ paths = [], principles = false }) => {
65
+ const safe = paths.filter((p) => typeof p === "string" && !p.startsWith("-"));
66
+ const r = spawnSync(process.execPath, [join(pkg, "bin/twoux.mjs"), "check", ...(principles ? ["--principles"] : []), ...safe], { cwd: process.cwd(), encoding: "utf8", timeout: 120_000 });
67
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() || "No output.";
68
+ return r.status === 0 ? out : { text: out, isError: true };
69
+ },
70
+ },
71
+ ];
72
+
73
+ const send = (msg) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`);
74
+
75
+ function handle(req) {
76
+ const { id, method, params = {} } = req;
77
+ switch (method) {
78
+ case "initialize":
79
+ return {
80
+ protocolVersion: PROTOCOLS.includes(params.protocolVersion) ? params.protocolVersion : PROTOCOLS[0],
81
+ capabilities: { tools: {} },
82
+ serverInfo: { name: "twoux", title: "TwoUX", version: VERSION },
83
+ instructions: INSTRUCTIONS,
84
+ };
85
+ case "ping":
86
+ return {};
87
+ case "tools/list":
88
+ return { tools: TOOLS.map(({ run, ...t }) => t) };
89
+ case "tools/call": {
90
+ const tool = TOOLS.find((t) => t.name === params.name);
91
+ if (!tool) throw Object.assign(new Error(`Unknown tool: ${params.name}`), { code: -32602 });
92
+ try {
93
+ const out = tool.run(params.arguments ?? {});
94
+ const { text, isError } = typeof out === "string" ? { text: out, isError: false } : out;
95
+ return { content: [{ type: "text", text }], isError };
96
+ } catch (e) {
97
+ return { content: [{ type: "text", text: e.message }], isError: true };
98
+ }
99
+ }
100
+ default:
101
+ if (id === undefined) return undefined; // notifications such as notifications/initialized need no reply
102
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
103
+ }
104
+ }
105
+
106
+ createInterface({ input: process.stdin }).on("line", (line) => {
107
+ if (!line.trim()) return;
108
+ let req;
109
+ try { req = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: "Parse error" } }); }
110
+ try {
111
+ const result = handle(req);
112
+ if (req.id !== undefined && result !== undefined) send({ id: req.id, result });
113
+ } catch (e) {
114
+ if (req.id !== undefined) send({ id: req.id, error: { code: e.code ?? -32603, message: e.message } });
115
+ }
116
+ });
package/bin/theme.mjs ADDED
@@ -0,0 +1,168 @@
1
+ // twoux theme: builds the brand layer (brand colour, corner radii, button shape, font).
2
+ // In an app: reads ./twoux.theme.json, writes ./twoux-theme.css (import it after @twoux/ui/styles.css).
3
+ // In the TwoUX repo: reads packages/ui/theme.json, writes packages/ui/src/styles/theme.css.
4
+ // Fails, and writes nothing, if the colour would break WCAG contrast.
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const pkg = new URL("..", import.meta.url).pathname;
9
+ const projectTheme = join(process.cwd(), "twoux.theme.json");
10
+ const inApp = existsSync(projectTheme) && !existsSync(join(process.cwd(), "packages/ui/bin/theme.mjs"));
11
+ const THEME = inApp ? projectTheme : join(pkg, "theme.json");
12
+ const OUT = inApp ? join(process.cwd(), "twoux-theme.css") : join(pkg, "src/styles/theme.css");
13
+ const PALETTE = join(pkg, "src/styles/palette.css");
14
+
15
+ /* ---------- colour maths (same as scripts/palette.mjs) ---------- */
16
+ const toLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
17
+ const toGam = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
18
+ function hexToOklch(hex) {
19
+ const [r, g, b] = [1, 3, 5].map((i) => toLin(Number.parseInt(hex.slice(i, i + 2), 16) / 255));
20
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
21
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
22
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
23
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
24
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
25
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
26
+ return { L, C: Math.hypot(A, B), H: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
27
+ }
28
+ function oklchToRgb({ L, C, H }) {
29
+ const a = C * Math.cos((H * Math.PI) / 180), b = C * Math.sin((H * Math.PI) / 180);
30
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
31
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
32
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
33
+ return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
34
+ }
35
+ const inGamut = (rgb) => rgb.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
36
+ function oklchToHex(c) {
37
+ let lo = 0, hi = c.C;
38
+ if (!inGamut(oklchToRgb(c))) { for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; inGamut(oklchToRgb({ ...c, C: mid })) ? (lo = mid) : (hi = mid); } c = { ...c, C: lo }; }
39
+ return `#${oklchToRgb(c).map((v) => Math.round(Math.min(1, Math.max(0, toGam(v))) * 255).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
40
+ }
41
+ const lum = (hex) => { const [r, g, b] = [1, 3, 5].map((i) => toLin(Number.parseInt(hex.slice(i, i + 2), 16) / 255)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
42
+ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
43
+
44
+ const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
45
+ const LIGHT = { 50: 0.975, 100: 0.945, 200: 0.89, 300: 0.81, 400: 0.71, 500: 0.62, 600: 0.545, 700: 0.475, 800: 0.405, 900: 0.335, 950: 0.255 };
46
+ const CHROMA = { 50: 0.14, 100: 0.25, 200: 0.45, 300: 0.7, 400: 0.9, 500: 1, 600: 1, 700: 0.92, 800: 0.8, 900: 0.66, 950: 0.5 };
47
+
48
+ /* ---------- read theme ---------- */
49
+ const theme = JSON.parse(readFileSync(THEME, "utf8"));
50
+ const fail = (msg) => { console.error(`${inApp ? "twoux.theme.json" : "theme.json"}: ${msg}\nNothing was written.`); process.exit(1); };
51
+
52
+ // The primary colour is either a TwoUX palette family ("indigo", "blue", "teal"...) or a brand hex.
53
+ let ramp;
54
+ let source;
55
+ if (/^#[0-9a-f]{6}$/i.test(theme.primary)) {
56
+ const hex = theme.primary.toUpperCase();
57
+ const c = hexToOklch(hex);
58
+ const at = STEPS.reduce((best, s) => (Math.abs(LIGHT[s] - c.L) < Math.abs(LIGHT[best] - c.L) ? s : best), 600);
59
+ const peak = c.C / CHROMA[at];
60
+ ramp = Object.fromEntries(STEPS.map((s) => [s, s === at ? hex : oklchToHex({ L: LIGHT[s], C: peak * CHROMA[s], H: c.H })]));
61
+ source = `brand ${hex} (anchored at step ${at})`;
62
+ } else {
63
+ const palette = readFileSync(PALETTE, "utf8");
64
+ ramp = Object.fromEntries(STEPS.map((s) => {
65
+ const m = palette.match(new RegExp(`--color-${theme.primary}-${s}:\\s*(#[0-9A-F]{6})`, "i"));
66
+ if (!m) fail(`"${theme.primary}" is not a palette family or a #RRGGBB colour.`);
67
+ return [s, m[1].toUpperCase()];
68
+ }));
69
+ source = `palette family "${theme.primary}"`;
70
+ }
71
+
72
+ /* ---------- pick roles from the ramp, by contrast ---------- */
73
+ const WHITE = "#FFFFFF";
74
+ const darkerFrom = (start) => STEPS.filter((s) => s >= start);
75
+ const primaryStep = darkerFrom(500).find((s) => contrast(ramp[s], WHITE) >= 4.5);
76
+ if (!primaryStep) fail(`no step of ${source} reaches 4.5:1 with white text. Pick a darker or more saturated colour.`);
77
+ const hoverStep = STEPS[Math.min(STEPS.indexOf(primaryStep) + 1, STEPS.length - 1)];
78
+ const inkStep = darkerFrom(hoverStep).find((s) => contrast(ramp[s], WHITE) >= 7) ?? 950;
79
+ const soft = ramp[50];
80
+ const checks = [
81
+ ["White text on primary", contrast(WHITE, ramp[primaryStep]), 4.5],
82
+ ["Accent text on white", contrast(ramp[inkStep], WHITE), 4.5],
83
+ ["Accent text on the selection tint", contrast(ramp[inkStep], soft), 4.5],
84
+ ["Focus ring on white", contrast(ramp[primaryStep], WHITE), 3],
85
+ ];
86
+ /* Dark theme: the same brand, re-chosen against the dark surfaces in twoux.css (not a flip). */
87
+ const twouxCss = readFileSync(join(pkg, "src/styles/twoux.css"), "utf8");
88
+ const darkOf = (role) => twouxCss.match(new RegExp(`--color-${role}: light-dark\\([^,]+,\\s*(#[0-9A-F]{6})\\)`, "i"))[1];
89
+ const [DS, DHOVER] = [darkOf("surface"), darkOf("hover")];
90
+ const mix = (a, b, t) => { // oklab mix, as color-mix(in oklab, a t, b)
91
+ const [p, q] = [hexToOklch(a), hexToOklch(b)].map(({ L, C, H }) => [L, C * Math.cos((H * Math.PI) / 180), C * Math.sin((H * Math.PI) / 180)]);
92
+ const [L, x, y] = p.map((v, i) => v * t + q[i] * (1 - t));
93
+ return oklchToHex({ L, C: Math.hypot(x, y), H: (Math.atan2(y, x) * 180) / Math.PI });
94
+ };
95
+ const darkSoft = mix(ramp[500], DS, 0.18);
96
+ const lighterFrom = (start) => STEPS.filter((s) => s <= start).reverse();
97
+ const darkInkStep = lighterFrom(400).find((s) => contrast(ramp[s], DS) >= 7 && contrast(ramp[s], darkSoft) >= 4.5) ?? 50;
98
+ const focusStep = contrast(ramp[primaryStep], DHOVER) >= 3 ? primaryStep : lighterFrom(primaryStep).find((s) => contrast(ramp[s], DHOVER) >= 3) ?? 300;
99
+ checks.push(
100
+ ["Dark: accent text on the surface", contrast(ramp[darkInkStep], DS), 4.5],
101
+ ["Dark: accent text on the selection tint", contrast(ramp[darkInkStep], darkSoft), 4.5],
102
+ ["Dark: focus ring on a hover fill", contrast(ramp[focusStep], DHOVER), 3],
103
+ );
104
+ const failed = checks.filter(([, r, min]) => r < min);
105
+ if (failed.length) fail(failed.map(([n, r, min]) => `${n} is ${r.toFixed(2)}:1, needs ${min}:1`).join("; "));
106
+
107
+ /* ---------- radii and font ---------- */
108
+ const { button: buttonRadius, ...tiers } = theme.radius ?? {};
109
+ const radius = { xs: 4, sm: 6, md: 8, ...tiers };
110
+ if (buttonRadius !== undefined && buttonRadius !== "pill" && (typeof buttonRadius !== "number" || buttonRadius < 0 || buttonRadius > 16)) fail('radius.button must be a number from 0 to 16, or "pill".');
111
+ const buttonCss = buttonRadius === "pill" ? "9999px" : buttonRadius === undefined ? "var(--radius-sm)" : `${buttonRadius}px`;
112
+ for (const [k, v] of Object.entries(radius)) if (typeof v !== "number" || v < 0 || v > 16) fail(`radius.${k} must be a number from 0 to 16.`);
113
+ if (!(radius.xs <= radius.sm && radius.sm <= radius.md)) fail("radius must grow: xs ≤ sm ≤ md (inside, controls, floating).");
114
+ const font = theme.font ?? "Geist";
115
+
116
+ /* ---------- motion: tempo scales durations, character sets how much a pop overshoots ---------- */
117
+ const { tempo = 1, character = "standard" } = theme.motion ?? {};
118
+ if (typeof tempo !== "number" || tempo < 0.75 || tempo > 1.25) fail("motion.tempo must be a number from 0.75 (quicker) to 1.25 (calmer). TwoUX stays short because people trigger the same motion all day.");
119
+ const CHARACTERS = {
120
+ crisp: { ease: "cubic-bezier(0.22, 1, 0.36, 1)", pop: 1.1 },
121
+ standard: { ease: "cubic-bezier(0.34, 1.56, 0.64, 1)", pop: 1.25 },
122
+ };
123
+ if (!CHARACTERS[character]) fail(`motion.character must be "crisp" or "standard". TwoUX has no expressive character: overshoot on entrances and layout slows down work.`);
124
+
125
+ /* ---------- write ---------- */
126
+ const css = `/* Generated by twoux theme from ${inApp ? "twoux.theme.json" : "packages/ui/theme.json"} (${source}). Do not edit: change the theme file and run twoux theme. */
127
+ @theme static {
128
+ ${STEPS.map((s) => ` --color-brand-${s}: ${ramp[s]};`).join("\n")}
129
+
130
+ /* Primary roles, chosen by contrast */
131
+ --color-primary: var(--color-brand-${primaryStep}); /* ${contrast(WHITE, ramp[primaryStep]).toFixed(2)}:1 with white text */
132
+ --color-primary-hover: var(--color-brand-${hoverStep});
133
+ --color-primary-soft: light-dark(var(--color-brand-50), color-mix(in oklab, var(--color-brand-500) 18%, ${DS})); /* selected rows, AI cell tint */
134
+ --color-primary-ink: light-dark(var(--color-brand-${inkStep}), var(--color-brand-${darkInkStep})); /* accent text, ${contrast(ramp[inkStep], WHITE).toFixed(2)}:1 on white, ${contrast(ramp[darkInkStep], DS).toFixed(2)}:1 on dark */
135
+ --color-focus: light-dark(var(--color-primary), var(--color-brand-${focusStep})); /* focus ring: ${contrast(ramp[focusStep], DHOVER).toFixed(2)}:1 on a dark hover fill */
136
+ --color-primary-foreground: var(--color-white);
137
+
138
+ /* Corners: inside a control, controls and cards, what floats */
139
+ --radius-xs: ${radius.xs}px;
140
+ --radius-sm: ${radius.sm}px;
141
+ --radius-md: ${radius.md}px;
142
+ --radius-button: ${buttonCss}; /* buttons only; defaults to sm */
143
+
144
+ --font-sans: "${font}", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
145
+
146
+ /* Motion: tempo scales every --duration-* token; character sets the pop */
147
+ --motion-tempo: ${tempo};
148
+ --motion-pop-scale: ${CHARACTERS[character].pop};
149
+ --ease-pop: ${CHARACTERS[character].ease}; /* ${character} */
150
+ }
151
+ `;
152
+ // Warn when the brand sits close to a status hue: a green brand next to green "Open" badges reads as status.
153
+ const paletteCss = readFileSync(PALETTE, "utf8");
154
+ const hueOf = (name) => hexToOklch(paletteCss.match(new RegExp(`--color-${name}:\\s*(#[0-9A-F]{6})`, "i"))[1]).H;
155
+ const STATUS = { ok: hueOf("green-700"), warn: hueOf("amber-600"), bad: hueOf("red-600") };
156
+ const brandHue = hexToOklch(ramp[primaryStep]).H;
157
+ for (const [name, h] of Object.entries(STATUS)) {
158
+ const d = Math.min(Math.abs(brandHue - h), 360 - Math.abs(brandHue - h));
159
+ if (d < 30) console.warn(` Note: the brand hue is ${Math.round(d)}° from the "${name}" status colour. Primary buttons and ${name} badges may read as the same thing; status always carries a word, but check key screens.`);
160
+ }
161
+ writeFileSync(OUT, css);
162
+ if (inApp) console.log(`Wrote twoux-theme.css. Import it after @twoux/ui/styles.css.`);
163
+ console.log(`Theme: ${source}. Primary step ${primaryStep} (${ramp[primaryStep]}), ink ${inkStep}.`);
164
+ if (/^#/.test(theme.primary) && ramp[primaryStep] !== theme.primary.toUpperCase())
165
+ console.warn(` Note: ${theme.primary.toUpperCase()} is too light for white button text (${contrast(theme.primary, WHITE).toFixed(2)}:1), so buttons use the darker ${ramp[primaryStep]}. Your exact colour stays available as --color-brand-* for marks and illustrations.`);
166
+ if (contrast(ramp[primaryStep], DS) < 3) console.warn(` Note: in dark, primary buttons are ${contrast(ramp[primaryStep], DS).toFixed(2)}:1 against the surface. Their white label carries them (WCAG 1.4.11 exempts labelled buttons), but they will look quieter than in light.`);
167
+ for (const [n, r, min] of checks) console.log(` ${n}: ${r.toFixed(2)}:1 (needs ${min}:1)`);
168
+ console.log(` Radius ${radius.xs}/${radius.sm}/${radius.md}px, buttons ${buttonRadius ?? "sm"} · font ${font} · motion tempo ${tempo}, ${character}`);
package/bin/twoux.mjs ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // twoux: TwoUX's command line. Run from your project root.
3
+ // twoux init set up this project for AI agents (AGENTS.md, CLAUDE.md, guard, skills, MCP, theme, proposals)
4
+ // twoux find "<job>" which component or pattern fits a job
5
+ // twoux docs [id] list every page, or print one
6
+ // twoux check [paths…] [--check-links] lint app code and proposals against TwoUX's rules
7
+ // twoux theme build twoux-theme.css from twoux.theme.json
8
+ // twoux mcp MCP server over stdio, for agents that speak MCP
9
+ // twoux guard Claude Code PreToolUse hook (reads the tool call on stdin)
10
+ const [cmd, ...rest] = process.argv.slice(2);
11
+ const run = { check: "./check.mjs", theme: "./theme.mjs", guard: "./guard.mjs", init: "./init.mjs", docs: "./docs.mjs", find: "./docs.mjs", mcp: "./mcp.mjs" }[cmd];
12
+ if (!run) {
13
+ console.log(`Usage: twoux <command> [options]
14
+
15
+ init [--force] [--principles] set up this project for AI agents
16
+ find "<job>" which component or pattern fits a job
17
+ docs [id] [--kind <kind>] list every page, or print one (docs rules: every rule)
18
+ check [paths…] [--check-links] lint app code against TwoUX's rules
19
+ theme build twoux-theme.css from twoux.theme.json
20
+ mcp MCP server over stdio
21
+ guard Claude Code hook, run by init`);
22
+ process.exit(cmd ? 1 : 0);
23
+ }
24
+ process.argv = [process.argv[0], run, ...(run === "./docs.mjs" ? [cmd] : []), ...rest];
25
+ await import(run);
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@twoux/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Engineering-first design system for complex apps. Makes UI and UX decisions explicit for engineers and AI agents.",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./styles.css": "./src/styles/twoux.css",
10
+ "./theme.json": "./theme.json",
11
+ "./agent/*": "./agent/*",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "peerDependencies": {
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0"
17
+ },
18
+ "dependencies": {
19
+ "@radix-ui/react-checkbox": "1.3.2",
20
+ "@radix-ui/react-dialog": "1.1.14",
21
+ "@radix-ui/react-dropdown-menu": "2.1.15",
22
+ "@radix-ui/react-label": "2.1.7",
23
+ "@radix-ui/react-radio-group": "1.3.7",
24
+ "@radix-ui/react-slot": "^1.2.3",
25
+ "@radix-ui/react-switch": "1.2.5",
26
+ "@radix-ui/react-tabs": "1.1.12",
27
+ "@radix-ui/react-toggle-group": "1.1.10",
28
+ "@radix-ui/react-tooltip": "1.2.7",
29
+ "@xyflow/react": "12.12.0",
30
+ "class-variance-authority": "^0.7.1",
31
+ "clsx": "^2.1.1",
32
+ "cmdk": "1.1.1",
33
+ "d3-geo": "3.1.1",
34
+ "date-fns": "4.1.0",
35
+ "lucide-react": "0.525.0",
36
+ "radix-ui": "1.4.2",
37
+ "react-day-picker": "9.11.1",
38
+ "sonner": "2.0.7",
39
+ "tailwind-merge": "^3.3.1",
40
+ "topojson-client": "3.1.0",
41
+ "tw-animate-css": "1.3.5"
42
+ },
43
+ "devDependencies": {
44
+ "@types/d3-geo": "3.1.0",
45
+ "@types/geojson": "7946.0.16",
46
+ "@types/react": "^19.1.8",
47
+ "@types/react-dom": "^19.1.6",
48
+ "@types/topojson-client": "3.1.5",
49
+ "@types/topojson-specification": "1.0.5",
50
+ "typescript": "^5.8.3"
51
+ },
52
+ "bin": {
53
+ "twoux": "./bin/twoux.mjs"
54
+ },
55
+ "files": [
56
+ "src",
57
+ "bin",
58
+ "agent",
59
+ "registry",
60
+ "theme.json",
61
+ "SHADCN-DIFF.md",
62
+ "LICENSE.md",
63
+ "NOTICE.md",
64
+ "LICENSES"
65
+ ],
66
+ "author": "Giulia Pizzignacco <hi@axdexpert.ai>",
67
+ "homepage": "https://twoux.com",
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/axdexpert/twoux.git",
71
+ "directory": "packages/ui"
72
+ },
73
+ "bugs": {
74
+ "url": "https://github.com/axdexpert/twoux/issues"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public",
78
+ "provenance": true
79
+ },
80
+ "scripts": {
81
+ "typecheck": "tsc --noEmit"
82
+ }
6
83
  }