@astryxdesign/cli 0.6.3 → 0.6.4-canary.06c8fa3

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 (670) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +121 -85
  3. package/api/blog/blog.doc.mjs +1 -0
  4. package/api/build/_adapter.d.mts +50 -0
  5. package/api/build/_adapter.mjs +60 -0
  6. package/api/build/build.doc.mjs +16 -9
  7. package/api/build/build.test.mjs +197 -8
  8. package/api/build/build.type.d.mts +91 -2
  9. package/api/build/build.type.mjs +52 -8
  10. package/api/build/help/help.d.mts +12 -5
  11. package/api/build/help/help.mjs +69 -6
  12. package/api/build/kit/kit.d.mts +4 -1
  13. package/api/build/kit/kit.mjs +165 -49
  14. package/api/build/kit/rank.d.mts +44 -0
  15. package/api/build/kit/rank.mjs +432 -0
  16. package/api/build/kit/rank.test.mjs +196 -0
  17. package/api/component/_adapter.d.mts +31 -12
  18. package/api/component/_adapter.mjs +79 -15
  19. package/api/component/component.d.mts +6 -3
  20. package/api/component/component.doc.mjs +36 -13
  21. package/api/component/component.mjs +339 -22
  22. package/api/component/component.test.mjs +38 -0
  23. package/api/component/component.type.d.mts +47 -11
  24. package/api/component/component.type.mjs +76 -24
  25. package/api/component/detail/blocks/blocks.d.mts +2 -1
  26. package/api/component/detail/blocks/blocks.mjs +4 -3
  27. package/api/component/list/list.d.mts +0 -5
  28. package/api/component/list/list.mjs +40 -11
  29. package/api/discover/_adapter.d.mts +114 -6
  30. package/api/discover/_adapter.mjs +372 -17
  31. package/api/discover/_adapter.test.mjs +215 -0
  32. package/api/discover/_catalog-view.d.mts +115 -0
  33. package/api/discover/_catalog-view.mjs +203 -0
  34. package/api/discover/_catalog-view.test.mjs +128 -0
  35. package/api/discover/detail/detail.d.mts +18 -6
  36. package/api/discover/detail/detail.mjs +67 -13
  37. package/api/discover/detail/detail.test.mjs +85 -0
  38. package/api/discover/detail/item/item.d.mts +26 -0
  39. package/api/discover/detail/item/item.mjs +78 -0
  40. package/api/discover/detail/item/item.test.mjs +73 -0
  41. package/api/discover/discover.d.mts +3 -9
  42. package/api/discover/discover.doc.mjs +62 -18
  43. package/api/discover/discover.mjs +220 -36
  44. package/api/discover/discover.test.mjs +11 -2
  45. package/api/discover/discover.type.d.mts +150 -11
  46. package/api/discover/discover.type.mjs +107 -17
  47. package/api/discover/list/list.d.mts +20 -6
  48. package/api/discover/list/list.mjs +45 -12
  49. package/api/discover/list/list.test.mjs +46 -0
  50. package/api/discover/search/search.d.mts +18 -16
  51. package/api/discover/search/search.mjs +102 -56
  52. package/api/discover/search/search.test.mjs +144 -10
  53. package/api/docs/_adapter.d.mts +272 -41
  54. package/api/docs/_adapter.mjs +985 -108
  55. package/api/docs/compiled-topics.test.mjs +78 -0
  56. package/api/docs/detail/detail.mjs +22 -63
  57. package/api/docs/detail/section/section.d.mts +1 -1
  58. package/api/docs/detail/section/section.mjs +54 -19
  59. package/api/docs/detail/section/section.test.mjs +50 -0
  60. package/api/docs/docs.d.mts +10 -3
  61. package/api/docs/docs.doc.mjs +55 -16
  62. package/api/docs/docs.mjs +53 -10
  63. package/api/docs/docs.test.mjs +166 -4
  64. package/api/docs/docs.type.d.mts +221 -5
  65. package/api/docs/docs.type.mjs +153 -11
  66. package/api/docs/index/index.d.mts +18 -0
  67. package/api/docs/index/index.mjs +40 -0
  68. package/api/docs/index/index.test.mjs +62 -0
  69. package/api/docs/integration-tree.test.mjs +555 -0
  70. package/api/docs/integrationDocs.test.mjs +114 -8
  71. package/api/docs/list/list.mjs +28 -12
  72. package/api/docs/node/node.d.mts +43 -0
  73. package/api/docs/node/node.mjs +192 -0
  74. package/api/docs/reference-blocks.test.mjs +406 -0
  75. package/api/doctor/doctor.d.mts +104 -1
  76. package/api/doctor/doctor.doc.mjs +1 -0
  77. package/api/doctor/doctor.mjs +635 -7
  78. package/api/doctor/doctor.test.mjs +732 -11
  79. package/api/gap-report/gap-report.doc.mjs +8 -4
  80. package/api/hook/_adapter.mjs +19 -5
  81. package/api/hook/hook.doc.mjs +1 -0
  82. package/api/hook/hook.type.d.mts +3 -3
  83. package/api/hook/hook.type.mjs +11 -11
  84. package/api/hook/list/list.d.mts +2 -2
  85. package/api/hook/list/list.mjs +69 -17
  86. package/api/index.d.mts +2 -3
  87. package/api/index.mjs +5 -4
  88. package/api/init/init.doc.mjs +6 -1
  89. package/api/init/init.test.mjs +41 -1
  90. package/api/init/remove/remove.mjs +1 -1
  91. package/api/init/run/run.mjs +20 -10
  92. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  93. package/api/integration/add-contribution.d.mts +2 -1
  94. package/api/integration/add-contribution.mjs +130 -15
  95. package/api/integration/add-contribution.test.mjs +258 -7
  96. package/api/integration/add-helpers.d.mts +5 -2
  97. package/api/integration/add-helpers.mjs +36 -9
  98. package/api/integration/add-theme.mjs +34 -64
  99. package/api/integration/add-theme.test.mjs +105 -21
  100. package/api/integration/authoring-checks.mjs +138 -28
  101. package/api/integration/authoring-checks.test.mjs +179 -7
  102. package/api/integration/authoring-checks.type.mjs +6 -1
  103. package/api/integration/integration-authoring.type.d.mts +3 -1
  104. package/api/integration/integration-authoring.type.mjs +2 -0
  105. package/api/integration/integration-block-exports.test.mjs +10 -6
  106. package/api/integration/integrationAdd.doc.mjs +14 -4
  107. package/api/integration/integrationAddAgentDoc.doc.mjs +1 -0
  108. package/api/integration/integrationAddCodemod.doc.mjs +1 -0
  109. package/api/integration/integrationAddComponent.doc.mjs +2 -1
  110. package/api/integration/integrationAddDoc.doc.mjs +8 -1
  111. package/api/integration/integrationAddTemplate.doc.mjs +1 -0
  112. package/api/integration/integrationAddTheme.doc.mjs +6 -5
  113. package/api/integration/integrationComponentConflicts.doc.mjs +1 -0
  114. package/api/integration/integrationDocConflicts.doc.mjs +2 -1
  115. package/api/integration/integrationPackCheck.doc.mjs +2 -1
  116. package/api/integration/integrationTemplateConflicts.doc.d.mts +3 -0
  117. package/api/integration/integrationTemplateConflicts.doc.mjs +4 -0
  118. package/api/integration/pack-check.lifecycle-output.test.mjs +105 -0
  119. package/api/integration/pack-check.mjs +111 -10
  120. package/api/integration/pack-check.test.mjs +387 -47
  121. package/api/integration/pack-check.type.d.mts +26 -2
  122. package/api/integration/pack-check.type.mjs +14 -1
  123. package/api/integration/summarizeIssues.doc.mjs +1 -0
  124. package/api/integration/template-conflict-compatibility.test.mjs +73 -0
  125. package/api/integration/validate-integration-fixes.test.mjs +1389 -0
  126. package/api/integration/validate-integration.mjs +52 -102
  127. package/api/integration/validate-integration.test.mjs +179 -26
  128. package/api/integration/validate-unread-theme-folders.test.mjs +110 -0
  129. package/api/integration/validateIntegration.doc.mjs +3 -2
  130. package/api/json/assertResponse.doc.mjs +1 -0
  131. package/api/json/envelope-types.test.mjs +76 -0
  132. package/api/json/index.ts +2 -1
  133. package/api/json/isError.doc.mjs +1 -0
  134. package/api/json/parseResponse.doc.mjs +3 -2
  135. package/api/search/search-return-type.test.mjs +54 -0
  136. package/api/search/search.d.mts +62 -11
  137. package/api/search/search.doc.mjs +8 -2
  138. package/api/search/search.mjs +471 -83
  139. package/api/search/search.test.mjs +142 -1
  140. package/api/search/search.type.d.mts +15 -3
  141. package/api/search/search.type.mjs +5 -2
  142. package/api/swizzle/copy/copy.mjs +28 -11
  143. package/api/swizzle/swizzle.doc.mjs +2 -1
  144. package/api/swizzle/swizzle.type.d.mts +2 -2
  145. package/api/swizzle/swizzle.type.mjs +2 -2
  146. package/api/template/copy/copy.mjs +17 -23
  147. package/api/template/copy/copy.test.mjs +17 -0
  148. package/api/template/list/list.mjs +1 -0
  149. package/api/template/table-floating-bulk-actions.test.mjs +66 -0
  150. package/api/template/template-integration.test.mjs +1008 -3
  151. package/api/template/template-suffix.test.mjs +41 -21
  152. package/api/template/template.d.mts +1 -1
  153. package/api/template/template.doc.mjs +30 -8
  154. package/api/template/template.mjs +46 -9
  155. package/api/template/template.type.d.mts +12 -14
  156. package/api/template/template.type.mjs +15 -14
  157. package/api/theme/_adapter.d.mts +2 -3
  158. package/api/theme/_adapter.mjs +4 -5
  159. package/api/theme/add/add.binary.test.mjs +84 -0
  160. package/api/theme/add/add.mjs +31 -22
  161. package/api/theme/add/add.rollback.test.mjs +158 -0
  162. package/api/theme/add/add.staging.test.mjs +83 -0
  163. package/api/theme/add/add.test.mjs +14 -1
  164. package/api/theme/build/build.family.test.mjs +7 -12
  165. package/api/theme/build/build.mjs +140 -59
  166. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  167. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  168. package/api/theme/build/build.rollback.test.mjs +148 -0
  169. package/api/theme/build/build.test.mjs +127 -0
  170. package/api/theme/build/font-warning.mjs +3 -3
  171. package/api/theme/build/font-warning.test.mjs +5 -2
  172. package/api/theme/generateTonalPalette.doc.mjs +1 -0
  173. package/api/theme/integration-themes.test.mjs +39 -28
  174. package/api/theme/list/list.test.mjs +19 -20
  175. package/api/theme/listThemes.doc.mjs +6 -5
  176. package/api/theme/palette/generate/generate.mjs +8 -3
  177. package/api/theme/palette/generate/generate.test.mjs +96 -0
  178. package/api/theme/palette/generate/generator.d.mts +10 -13
  179. package/api/theme/palette/generate/generator.mjs +15 -4
  180. package/api/theme/palette/generate/generator.test.mjs +10 -0
  181. package/api/theme/template/template.mjs +11 -2
  182. package/api/theme/template/template.test.mjs +20 -0
  183. package/api/theme/theme.type.d.mts +170 -11
  184. package/api/theme/theme.type.mjs +94 -27
  185. package/api/theme/themeAdd.doc.mjs +4 -3
  186. package/api/theme/themeBuild.doc.mjs +8 -4
  187. package/api/theme/themeList.doc.mjs +6 -3
  188. package/api/theme/themeListAvailable.doc.mjs +5 -3
  189. package/api/theme/themePaletteGenerate.doc.mjs +1 -0
  190. package/api/theme/themeTargets.doc.mjs +1 -0
  191. package/api/theme/themeTemplate.doc.mjs +6 -2
  192. package/api/upgrade/_adapter.d.mts +32 -5
  193. package/api/upgrade/_adapter.mjs +139 -22
  194. package/api/upgrade/list/list.mjs +2 -1
  195. package/api/upgrade/list/list.test.mjs +73 -0
  196. package/api/upgrade/project-context.test.mjs +272 -0
  197. package/api/upgrade/provider-agreement.test.mjs +152 -0
  198. package/api/upgrade/run/files-changed.test.mjs +111 -0
  199. package/api/upgrade/run/run.mjs +359 -60
  200. package/api/upgrade/status/status.mjs +2 -2
  201. package/api/upgrade/upgrade.doc.mjs +12 -5
  202. package/api/upgrade/upgrade.type.d.mts +43 -5
  203. package/api/upgrade/upgrade.type.mjs +29 -13
  204. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  205. package/assets/codemods/__tests__/runner.test.mjs +332 -8
  206. package/assets/codemods/file-count.test.mjs +163 -0
  207. package/assets/codemods/integration-discovery.mjs +48 -4
  208. package/assets/codemods/integration-discovery.test.mjs +73 -0
  209. package/assets/codemods/integration-runner.mjs +59 -7
  210. package/assets/codemods/integration-runner.protection.test.mjs +153 -0
  211. package/assets/codemods/registry.mjs +1 -0
  212. package/assets/codemods/run-codemod.mjs +177 -34
  213. package/assets/codemods/runner.mjs +353 -104
  214. package/assets/codemods/term-log.mjs +32 -8
  215. package/assets/codemods/term-log.test.mjs +19 -1
  216. package/assets/codemods/transform-prop.mjs +109 -0
  217. package/assets/codemods/transform-prop.test.mjs +95 -0
  218. package/assets/codemods/transforms/v0.0.14/__tests__/rename-status-variants.test.mjs +86 -165
  219. package/assets/codemods/transforms/v0.0.14/rename-status-variants.mjs +72 -210
  220. package/assets/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +83 -115
  221. package/assets/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +57 -186
  222. package/assets/codemods/transforms/v0.3.0/__tests__/unwrap-authoring-factories.test.mjs +27 -5
  223. package/assets/codemods/transforms/v0.3.0/unwrap-authoring-factories.mjs +20 -5
  224. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  225. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  226. package/assets/codemods/transforms/v0.6.4/__tests__/migrate-native-picker-to-presentation.test.mjs +63 -0
  227. package/assets/codemods/transforms/v0.6.4/__tests__/migrate-theme-catalog-to-descriptors.test.mjs +220 -0
  228. package/assets/codemods/transforms/v0.6.4/index.mjs +31 -0
  229. package/assets/codemods/transforms/v0.6.4/migrate-native-picker-to-presentation.mjs +148 -0
  230. package/assets/codemods/transforms/v0.6.4/migrate-theme-catalog-to-descriptors.mjs +141 -0
  231. package/assets/docs/README.md +9 -0
  232. package/assets/docs/authoring.doc.mjs +14 -0
  233. package/assets/docs/getting-started.doc.mjs +2 -2
  234. package/assets/docs/internationalization.doc.mjs +7 -5
  235. package/assets/docs/layout.doc.dense.mjs +2 -2
  236. package/assets/docs/layout.doc.mjs +1 -1
  237. package/assets/docs/principles.doc.mjs +6 -6
  238. package/assets/docs/styling-libraries.doc.mjs +4 -4
  239. package/assets/docs/styling.doc.mjs +4 -4
  240. package/assets/docs/theme.doc.mjs +5 -5
  241. package/assets/docs/tokens.doc.mjs +1 -1
  242. package/assets/docs/tree/api.doc.mjs +30 -0
  243. package/assets/docs/tree/cli.doc.mjs +23 -0
  244. package/assets/docs/tree/commands.doc.mjs +25 -0
  245. package/assets/docs/tree/component-lookups.doc.mjs +149 -0
  246. package/assets/docs/{cli-integrations.doc.mjs → tree/integrations.doc.mjs} +144 -26
  247. package/assets/docs/tree/integrations.test.mjs +62 -0
  248. package/assets/docs/tree/writing-docs.doc.mjs +286 -0
  249. package/assets/docs/working-with-ai.doc.mjs +4 -4
  250. package/assets/templates/blocks/components/CheckboxList/CheckboxListSelectAllPattern.doc.mjs +1 -1
  251. package/assets/templates/blocks/components/CheckboxList/CheckboxListSelectAllPattern.tsx +1 -3
  252. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +1 -1
  253. package/assets/templates/blocks/components/InternationalizationProvider/InternationalizationProvider01ShippedLocale.tsx +1 -1
  254. package/assets/templates/blocks/components/Item/ItemDocumentTabs.doc.mjs +14 -0
  255. package/assets/templates/blocks/components/Item/ItemDocumentTabs.tsx +100 -0
  256. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.doc.mjs +14 -0
  257. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.tsx +61 -0
  258. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.doc.mjs +14 -0
  259. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.tsx +126 -0
  260. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.doc.mjs +15 -0
  261. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.tsx +86 -0
  262. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.doc.mjs +14 -0
  263. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.tsx +99 -0
  264. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.doc.mjs +14 -0
  265. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.tsx +122 -0
  266. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.doc.mjs +14 -0
  267. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.tsx +95 -0
  268. package/assets/templates/blocks/components/Table/TableBulkActionsTable.doc.mjs +14 -0
  269. package/assets/templates/blocks/components/Table/TableBulkActionsTable.tsx +71 -0
  270. package/assets/templates/blocks/components/Table/TableFloatingBulkActionsTable.doc.mjs +19 -0
  271. package/assets/templates/blocks/components/Table/TableFloatingBulkActionsTable.tsx +139 -0
  272. package/assets/templates/blocks/components/TimeInput/TimeInputConstrained.tsx +1 -0
  273. package/assets/templates/blocks/components/Timer/TimerFormats.doc.mjs +14 -0
  274. package/assets/templates/blocks/components/Timer/TimerFormats.tsx +34 -0
  275. package/assets/templates/blocks/components/Timer/TimerInline.doc.mjs +14 -0
  276. package/assets/templates/blocks/components/Timer/TimerInline.tsx +14 -0
  277. package/assets/templates/blocks/components/Timer/TimerShowcase.doc.mjs +13 -0
  278. package/assets/templates/blocks/components/Timer/TimerShowcase.tsx +47 -0
  279. package/assets/templates/blocks/components/Timer/TimerTypography.doc.mjs +14 -0
  280. package/assets/templates/blocks/components/Timer/TimerTypography.tsx +31 -0
  281. package/assets/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +19 -3
  282. package/assets/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +383 -65
  283. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  284. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  285. package/assets/templates/themes/butter/butterTheme.doc.mjs +11 -0
  286. package/assets/templates/themes/chocolate/chocolateTheme.doc.mjs +11 -0
  287. package/assets/templates/themes/gothic/gothicTheme.doc.mjs +11 -0
  288. package/assets/templates/themes/matcha/matchaTheme.doc.mjs +11 -0
  289. package/assets/templates/themes/neutral/neutralTheme.doc.mjs +11 -0
  290. package/assets/templates/themes/stone/stoneTheme.doc.mjs +11 -0
  291. package/assets/templates/themes/y2k/y2kTheme.doc.mjs +11 -0
  292. package/authoring/_shared/contract.ts +22 -0
  293. package/authoring/codemod/codemod.doc.mjs +7 -2
  294. package/authoring/codemod/parse.d.mts +8 -8
  295. package/authoring/codemod/parse.mjs +8 -6
  296. package/authoring/codemod/type.ts +12 -0
  297. package/authoring/config/config.doc.mjs +11 -3
  298. package/authoring/config/debug-composition.test.mjs +92 -0
  299. package/authoring/config/parse.d.mts +15 -13
  300. package/authoring/config/parse.mjs +27 -8
  301. package/authoring/config/parse.test.mjs +8 -0
  302. package/authoring/config/type.ts +31 -8
  303. package/authoring/debug/debug.doc.d.mts +11 -0
  304. package/authoring/debug/debug.doc.mjs +182 -0
  305. package/authoring/debug/parse.d.mts +8 -8
  306. package/authoring/debug/parse.mjs +3 -3
  307. package/authoring/discover/discover.doc.d.mts +13 -0
  308. package/authoring/discover/discover.doc.mjs +138 -0
  309. package/authoring/discover/parse.d.mts +24 -0
  310. package/authoring/discover/parse.mjs +128 -0
  311. package/authoring/discover/parse.test.mjs +124 -0
  312. package/authoring/discover/type.ts +87 -0
  313. package/authoring/doctypes/_schema.d.mts +790 -23
  314. package/authoring/doctypes/_schema.mjs +543 -39
  315. package/authoring/doctypes/base/graph-fields.doc.d.mts +9 -0
  316. package/authoring/doctypes/base/graph-fields.doc.mjs +64 -0
  317. package/authoring/doctypes/base/type.ts +41 -0
  318. package/authoring/doctypes/command/command.doc.mjs +5 -4
  319. package/authoring/doctypes/command/parse.d.mts +2 -2
  320. package/authoring/doctypes/command/parse.mjs +1 -1
  321. package/authoring/doctypes/command/type.ts +6 -5
  322. package/authoring/doctypes/component/component.doc.mjs +6 -3
  323. package/authoring/doctypes/component/parse.d.mts +2 -2
  324. package/authoring/doctypes/component/parse.mjs +1 -1
  325. package/authoring/doctypes/component/type.ts +6 -5
  326. package/authoring/doctypes/doctypes-new.test.mjs +48 -6
  327. package/authoring/doctypes/enum/enum.doc.mjs +1 -1
  328. package/authoring/doctypes/enum/parse.d.mts +2 -2
  329. package/authoring/doctypes/enum/parse.mjs +1 -1
  330. package/authoring/doctypes/enum/type.ts +4 -2
  331. package/authoring/doctypes/function/function.doc.mjs +7 -2
  332. package/authoring/doctypes/function/parse.d.mts +2 -2
  333. package/authoring/doctypes/function/parse.mjs +1 -1
  334. package/authoring/doctypes/function/type.ts +9 -4
  335. package/authoring/doctypes/hook/hook.doc.mjs +4 -0
  336. package/authoring/doctypes/hook/parse.d.mts +2 -2
  337. package/authoring/doctypes/hook/parse.mjs +1 -1
  338. package/authoring/doctypes/hook/type.ts +5 -4
  339. package/authoring/doctypes/legacy.d.mts +8 -6
  340. package/authoring/doctypes/legacy.mjs +5 -4
  341. package/authoring/doctypes/load-contract.test.mjs +233 -0
  342. package/authoring/doctypes/namespace/namespace.doc.d.mts +9 -0
  343. package/authoring/doctypes/namespace/namespace.doc.mjs +128 -0
  344. package/authoring/doctypes/namespace/parse.d.mts +12 -0
  345. package/authoring/doctypes/namespace/parse.mjs +25 -0
  346. package/authoring/doctypes/namespace/parse.test.mjs +163 -0
  347. package/authoring/doctypes/namespace/type.ts +74 -0
  348. package/authoring/doctypes/parse.d.mts +22 -18
  349. package/authoring/doctypes/parse.mjs +22 -11
  350. package/authoring/doctypes/parse.test.mjs +77 -3
  351. package/authoring/doctypes/reference/parse.d.mts +2 -2
  352. package/authoring/doctypes/reference/parse.mjs +8 -5
  353. package/authoring/doctypes/reference/reference.doc.mjs +48 -6
  354. package/authoring/doctypes/reference/type.ts +70 -7
  355. package/authoring/doctypes/schema/parse.d.mts +2 -2
  356. package/authoring/doctypes/schema/parse.mjs +1 -1
  357. package/authoring/doctypes/schema/schema.doc.mjs +1 -1
  358. package/authoring/doctypes/schema/type.ts +4 -4
  359. package/authoring/doctypes/template/parse.d.mts +94 -1
  360. package/authoring/doctypes/template/parse.mjs +40 -2
  361. package/authoring/doctypes/template/parse.test.mjs +26 -2
  362. package/authoring/doctypes/template/template.doc.mjs +13 -3
  363. package/authoring/doctypes/template/type.ts +13 -2
  364. package/authoring/doctypes/theme/parse.d.mts +35 -0
  365. package/authoring/doctypes/theme/parse.mjs +76 -0
  366. package/authoring/doctypes/theme/theme.doc.d.mts +9 -0
  367. package/authoring/doctypes/theme/theme.doc.mjs +79 -0
  368. package/authoring/doctypes/theme/type.ts +42 -0
  369. package/authoring/doctypes/types.ts +12 -10
  370. package/authoring/gap-report/gap-report.doc.d.mts +12 -0
  371. package/authoring/gap-report/gap-report.doc.mjs +183 -0
  372. package/authoring/gap-report/parse.d.mts +10 -10
  373. package/authoring/gap-report/parse.mjs +6 -6
  374. package/authoring/gap-report/type.ts +1 -1
  375. package/authoring/identity/identity.doc.d.mts +9 -0
  376. package/authoring/identity/identity.doc.mjs +61 -0
  377. package/authoring/identity/type.ts +132 -0
  378. package/authoring/index.d.mts +3 -0
  379. package/authoring/index.d.ts +62 -17
  380. package/authoring/index.mjs +4 -1
  381. package/authoring/integration/integration.doc.mjs +15 -8
  382. package/authoring/integration/parse.d.mts +2 -2
  383. package/authoring/integration/parse.mjs +1 -1
  384. package/authoring/integration/parse.test.mjs +10 -1
  385. package/authoring/integration/schema.d.mts +6 -4
  386. package/authoring/integration/schema.mjs +9 -3
  387. package/authoring/integration/type.ts +19 -8
  388. package/authoring/shadcn/receipt.d.mts +6 -6
  389. package/clients/cli/__tests__/cliManifest.test.ts +27 -29
  390. package/clients/cli/command-load-failure.test.mjs +83 -0
  391. package/clients/cli/command-result-coverage.test.mjs +7 -7
  392. package/clients/cli/commands/blog.doc.mjs +1 -1
  393. package/clients/cli/commands/blog.mjs +23 -8
  394. package/clients/cli/commands/blog.test.mjs +42 -1
  395. package/clients/cli/commands/build-theme.adaptations.test.mjs +100 -1
  396. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  397. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  398. package/clients/cli/commands/build-theme.mjs +16 -50
  399. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  400. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  401. package/clients/cli/commands/build.doc.mjs +16 -8
  402. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  403. package/clients/cli/commands/build.mjs +137 -114
  404. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  405. package/clients/cli/commands/build.text-fields.test.mjs +81 -0
  406. package/clients/cli/commands/component/index.mjs +153 -61
  407. package/clients/cli/commands/component-batch.test.mjs +341 -0
  408. package/clients/cli/commands/component-ownership.test.mjs +92 -3
  409. package/clients/cli/commands/component-package.test.mjs +46 -0
  410. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  411. package/clients/cli/commands/component.doc.mjs +24 -7
  412. package/clients/cli/commands/component.test.mjs +19 -0
  413. package/clients/cli/commands/debug-result-summary.test.mjs +2 -2
  414. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  415. package/clients/cli/commands/discover.broken-integration.test.mjs +52 -5
  416. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  417. package/clients/cli/commands/discover.doc.mjs +55 -9
  418. package/clients/cli/commands/discover.mjs +393 -118
  419. package/clients/cli/commands/discover.sources.test.mjs +267 -0
  420. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  421. package/clients/cli/commands/docs.doc.mjs +28 -6
  422. package/clients/cli/commands/docs.mjs +240 -26
  423. package/clients/cli/commands/docs.test.mjs +222 -1
  424. package/clients/cli/commands/doctor-integration-components.doc.mjs +1 -1
  425. package/clients/cli/commands/doctor-integration-docs.doc.mjs +3 -3
  426. package/clients/cli/commands/doctor-integration-templates.doc.mjs +15 -8
  427. package/clients/cli/commands/doctor-integration-validate.doc.mjs +1 -1
  428. package/clients/cli/commands/doctor-integration.doc.mjs +1 -1
  429. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  430. package/clients/cli/commands/doctor-integration.test.mjs +90 -8
  431. package/clients/cli/commands/doctor.doc.mjs +1 -1
  432. package/clients/cli/commands/doctor.mjs +59 -32
  433. package/clients/cli/commands/doctor.test.mjs +42 -0
  434. package/clients/cli/commands/gap-report.doc.mjs +17 -6
  435. package/clients/cli/commands/gap-report.test.mjs +72 -0
  436. package/clients/cli/commands/hook/index.mjs +7 -17
  437. package/clients/cli/commands/hook.doc.mjs +1 -1
  438. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  439. package/clients/cli/commands/init.doc.mjs +20 -9
  440. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  441. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  442. package/clients/cli/commands/integration-add.doc.mjs +32 -6
  443. package/clients/cli/commands/integration-authoring.test.mjs +13 -9
  444. package/clients/cli/commands/integration-pack.doc.mjs +1 -1
  445. package/clients/cli/commands/integration-real-world.test.mjs +3 -9
  446. package/clients/cli/commands/integration.doc.mjs +1 -1
  447. package/clients/cli/commands/integration.mjs +1 -0
  448. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  449. package/clients/cli/commands/json-contract.test.mjs +33 -0
  450. package/clients/cli/commands/manifest.doc.mjs +1 -1
  451. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  452. package/clients/cli/commands/search.doc.mjs +7 -4
  453. package/clients/cli/commands/search.mjs +28 -9
  454. package/clients/cli/commands/search.test.mjs +75 -0
  455. package/clients/cli/commands/setup-nudge.test.mjs +6 -0
  456. package/clients/cli/commands/swizzle.doc.mjs +3 -2
  457. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  458. package/clients/cli/commands/template.doc.mjs +52 -13
  459. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  460. package/clients/cli/commands/template.mjs +4 -91
  461. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  462. package/clients/cli/commands/text-json-parity.test.mjs +702 -0
  463. package/clients/cli/commands/theme-add.doc.mjs +4 -3
  464. package/clients/cli/commands/theme-build.doc.mjs +8 -7
  465. package/clients/cli/commands/theme-list.doc.mjs +2 -2
  466. package/clients/cli/commands/theme-palette-generate.doc.mjs +9 -5
  467. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  468. package/clients/cli/commands/theme-palette.doc.mjs +1 -1
  469. package/clients/cli/commands/theme-targets.behavior.test.mjs +4 -3
  470. package/clients/cli/commands/theme-targets.doc.mjs +1 -1
  471. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  472. package/clients/cli/commands/theme-template.doc.mjs +2 -2
  473. package/clients/cli/commands/theme.doc.mjs +1 -1
  474. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  475. package/clients/cli/commands/upgrade.doc.mjs +22 -10
  476. package/clients/cli/commands/upgrade.file-protection.test.mjs +228 -0
  477. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  478. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  479. package/clients/cli/commands/upgrade.mjs +29 -7
  480. package/clients/cli/formatters/index.mjs +164 -1
  481. package/clients/cli/formatters/index.test.mjs +97 -0
  482. package/clients/cli/index.mjs +21 -34
  483. package/clients/cli/latest-version-env.test.mjs +50 -0
  484. package/clients/cli/lib/cli-error.test.mjs +7 -0
  485. package/clients/cli/lib/component-format.mjs +9 -9
  486. package/clients/cli/lib/component-format.test.mjs +1 -1
  487. package/clients/cli/lib/define-command.mjs +32 -6
  488. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  489. package/clients/cli/lib/exit-codes.test.mjs +90 -0
  490. package/clients/cli/lib/hook-format.mjs +19 -10
  491. package/clients/cli/lib/json-shim.mjs +62 -16
  492. package/clients/cli/lib/json-shim.test.mjs +69 -0
  493. package/clients/cli/lib/manifest.d.ts +2 -0
  494. package/clients/cli/lib/manifest.mjs +39 -10
  495. package/clients/cli/lib/manifest.test.mjs +22 -2
  496. package/clients/cli/lib/parse-error-format.test.mjs +81 -0
  497. package/foundation/agent-docs/agent-docs.d.mts +7 -2
  498. package/foundation/agent-docs/agent-docs.mjs +82 -12
  499. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  500. package/foundation/agent-docs/agent-docs.test.mjs +19 -1
  501. package/foundation/config/integration-debug.test.mjs +28 -3
  502. package/foundation/config/project-themes.test.mjs +11 -19
  503. package/foundation/config/project.d.mts +20 -11
  504. package/foundation/config/project.mjs +263 -91
  505. package/foundation/config/project.test.mjs +270 -21
  506. package/foundation/discovery/authoring-self-docs.d.mts +87 -0
  507. package/foundation/discovery/authoring-self-docs.mjs +237 -0
  508. package/foundation/discovery/authoring-self-docs.test.mjs +170 -0
  509. package/foundation/discovery/authoring-surface.d.mts +74 -0
  510. package/foundation/discovery/authoring-surface.mjs +525 -0
  511. package/foundation/discovery/authoring-surface.test.mjs +392 -0
  512. package/foundation/discovery/cli-self-docs.d.mts +119 -0
  513. package/foundation/discovery/cli-self-docs.mjs +490 -0
  514. package/foundation/discovery/cli-self-docs.test.mjs +375 -0
  515. package/foundation/discovery/component-discovery.d.mts +39 -1
  516. package/foundation/discovery/component-discovery.mjs +50 -1
  517. package/foundation/discovery/component-loader.d.mts +35 -38
  518. package/foundation/discovery/component-loader.mjs +53 -222
  519. package/foundation/discovery/docs-discovery.d.mts +119 -11
  520. package/foundation/discovery/docs-discovery.mjs +423 -108
  521. package/foundation/discovery/docs-discovery.test.mjs +365 -20
  522. package/foundation/discovery/docs-output-budget.d.mts +28 -0
  523. package/foundation/discovery/docs-output-budget.mjs +50 -0
  524. package/foundation/discovery/docs-section-key.d.mts +116 -0
  525. package/foundation/discovery/docs-section-key.mjs +322 -0
  526. package/foundation/discovery/docs-section-key.test.mjs +246 -0
  527. package/foundation/discovery/template-adapter.d.mts +113 -11
  528. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  529. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  530. package/foundation/discovery/template-adapter.mjs +775 -84
  531. package/foundation/discovery/template-adapter.test.mjs +57 -0
  532. package/foundation/discovery/template-conflict-release.d.mts +13 -0
  533. package/foundation/discovery/template-conflict-release.mjs +40 -0
  534. package/foundation/discovery/template-conflict-release.test.mjs +40 -0
  535. package/foundation/discovery/theme-discovery.d.mts +67 -7
  536. package/foundation/discovery/theme-discovery.mjs +916 -186
  537. package/foundation/discovery/theme-discovery.test.mjs +613 -219
  538. package/foundation/discovery/theming-targets.test.mjs +4 -0
  539. package/foundation/doc-compiler/bundle.d.mts +47 -0
  540. package/foundation/doc-compiler/bundle.mjs +278 -0
  541. package/foundation/doc-compiler/bundle.test.mjs +266 -0
  542. package/foundation/doc-compiler/compile.d.mts +343 -0
  543. package/foundation/doc-compiler/compile.mjs +558 -0
  544. package/foundation/doc-compiler/diagnostics.d.mts +126 -0
  545. package/foundation/doc-compiler/diagnostics.mjs +305 -0
  546. package/foundation/doc-compiler/doc-compiler.test.mjs +714 -0
  547. package/foundation/doc-compiler/doc-loads.test.mjs +1630 -0
  548. package/foundation/doc-compiler/import.d.mts +24 -0
  549. package/foundation/doc-compiler/import.mjs +59 -0
  550. package/foundation/doc-compiler/inputs.d.mts +102 -0
  551. package/foundation/doc-compiler/inputs.mjs +291 -0
  552. package/foundation/doc-compiler/inputs.test.mjs +299 -0
  553. package/foundation/doc-compiler/ir.d.mts +22 -0
  554. package/foundation/doc-compiler/ir.mjs +471 -0
  555. package/foundation/doc-compiler/lenses.d.mts +36 -0
  556. package/foundation/doc-compiler/lenses.mjs +173 -0
  557. package/foundation/doc-compiler/links.d.mts +162 -0
  558. package/foundation/doc-compiler/links.mjs +294 -0
  559. package/foundation/doc-compiler/links.test.mjs +192 -0
  560. package/foundation/doc-compiler/lower-doc.test.mjs +495 -0
  561. package/foundation/doc-compiler/overlays.d.mts +37 -0
  562. package/foundation/doc-compiler/overlays.mjs +206 -0
  563. package/foundation/doc-compiler/parse-readable.d.mts +9 -0
  564. package/foundation/doc-compiler/parse-readable.mjs +29 -0
  565. package/foundation/doc-compiler/read.d.mts +127 -0
  566. package/foundation/doc-compiler/read.mjs +325 -0
  567. package/foundation/doc-compiler/read.test.mjs +313 -0
  568. package/foundation/doc-compiler/source.d.mts +33 -0
  569. package/foundation/doc-compiler/source.mjs +128 -0
  570. package/foundation/doc-compiler/tree.d.mts +288 -0
  571. package/foundation/doc-compiler/tree.mjs +876 -0
  572. package/foundation/doc-compiler/tree.test.mjs +606 -0
  573. package/foundation/fs/file-protection.d.mts +33 -0
  574. package/foundation/fs/file-protection.mjs +825 -0
  575. package/foundation/fs/file-protection.test.mjs +250 -0
  576. package/foundation/fs/module-loader.d.mts +1 -0
  577. package/foundation/fs/module-loader.mjs +50 -1
  578. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  579. package/foundation/fs/path-safety.d.mts +3 -2
  580. package/foundation/fs/path-safety.mjs +49 -19
  581. package/foundation/fs/path-safety.test.mjs +50 -0
  582. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  583. package/foundation/identity/provider-identity.d.mts +90 -0
  584. package/foundation/identity/provider-identity.mjs +320 -0
  585. package/foundation/identity/provider-identity.test.mjs +254 -0
  586. package/foundation/identity/providers.d.mts +7 -0
  587. package/foundation/identity/providers.mjs +16 -0
  588. package/foundation/integrations/autolink.d.mts +58 -1
  589. package/foundation/integrations/autolink.mjs +143 -45
  590. package/foundation/integrations/autolink.test.mjs +1 -1
  591. package/foundation/integrations/cli-requirement.d.mts +45 -0
  592. package/foundation/integrations/cli-requirement.mjs +154 -0
  593. package/foundation/integrations/cli-requirement.test.mjs +84 -0
  594. package/foundation/integrations/contribution-fixes.d.mts +145 -0
  595. package/foundation/integrations/contribution-fixes.mjs +1284 -0
  596. package/foundation/integrations/contribution-inventory.d.mts +3 -2
  597. package/foundation/integrations/contribution-inventory.mjs +27 -24
  598. package/foundation/integrations/contribution-inventory.test.mjs +86 -27
  599. package/foundation/integrations/integration-warnings.d.mts +9 -2
  600. package/foundation/integrations/integration-warnings.mjs +52 -21
  601. package/foundation/integrations/integration-warnings.test.mjs +74 -1
  602. package/foundation/integrations/integrations.d.mts +63 -3
  603. package/foundation/integrations/integrations.mjs +122 -9
  604. package/foundation/integrations/integrations.test.mjs +415 -1
  605. package/foundation/integrations/provider-conflicts.test.mjs +125 -0
  606. package/foundation/integrations/provider-ledger.test.mjs +275 -0
  607. package/foundation/integrations/provider-resolution.d.mts +152 -0
  608. package/foundation/integrations/provider-resolution.mjs +576 -0
  609. package/foundation/integrations/provider-resolution.test.mjs +369 -0
  610. package/foundation/integrations/theme-descriptor.d.mts +8 -0
  611. package/foundation/integrations/theme-descriptor.mjs +44 -0
  612. package/foundation/integrations/validate-contributions.d.mts +2 -0
  613. package/foundation/integrations/validate-contributions.mjs +131 -29
  614. package/foundation/response/base.d.ts +8 -4
  615. package/foundation/response/batch.type.d.mts +33 -0
  616. package/foundation/response/batch.type.mjs +34 -0
  617. package/foundation/response/error-codes.d.mts +3 -1
  618. package/foundation/response/error-codes.d.ts +2 -0
  619. package/foundation/response/error-codes.doc.mjs +13 -4
  620. package/foundation/response/error-codes.mjs +8 -2
  621. package/foundation/response/error-codes.test.mjs +137 -10
  622. package/foundation/response/json-contract.test.mjs +57 -17
  623. package/foundation/response/json.d.mts +4 -2
  624. package/foundation/response/json.mjs +8 -10
  625. package/foundation/response/response-types.doc.d.mts +5 -1
  626. package/foundation/response/response-types.doc.mjs +46 -38
  627. package/foundation/response/response-types.doc.test.mjs +181 -0
  628. package/foundation/response/response.doc.mjs +1 -1
  629. package/foundation/text/string-utils.d.mts +8 -0
  630. package/foundation/text/string-utils.mjs +40 -10
  631. package/foundation/xle/browser.d.mts +3 -3
  632. package/foundation/xle/browser.mjs +3 -3
  633. package/foundation/xle/expand.d.mts +2 -0
  634. package/foundation/xle/expand.mjs +6 -5
  635. package/foundation/xle/expand.test.mjs +54 -0
  636. package/foundation/xle/parse.mjs +1 -1
  637. package/foundation/xle/print.mjs +2 -2
  638. package/foundation/xle/splice.mjs +1 -1
  639. package/foundation/xle/xle.test.mjs +13 -0
  640. package/package.json +10 -11
  641. package/api/layout/_adapter.d.mts +0 -34
  642. package/api/layout/_adapter.mjs +0 -133
  643. package/api/layout/check/check.d.mts +0 -16
  644. package/api/layout/check/check.mjs +0 -40
  645. package/api/layout/expand/expand.d.mts +0 -22
  646. package/api/layout/expand/expand.mjs +0 -153
  647. package/api/layout/grammar/grammar.d.mts +0 -13
  648. package/api/layout/grammar/grammar.mjs +0 -86
  649. package/api/layout/layout.d.mts +0 -6
  650. package/api/layout/layout.mjs +0 -17
  651. package/api/layout/layout.test.mjs +0 -297
  652. package/api/layout/layout.type.d.mts +0 -89
  653. package/api/layout/layout.type.mjs +0 -103
  654. package/api/layout/layoutCheck.doc.d.mts +0 -11
  655. package/api/layout/layoutCheck.doc.mjs +0 -84
  656. package/api/layout/layoutExpand.doc.d.mts +0 -11
  657. package/api/layout/layoutExpand.doc.mjs +0 -106
  658. package/api/layout/layoutGrammar.doc.d.mts +0 -11
  659. package/api/layout/layoutGrammar.doc.mjs +0 -56
  660. package/assets/templates/themes/manifest.json +0 -95
  661. package/clients/cli/commands/layout-check.doc.mjs +0 -54
  662. package/clients/cli/commands/layout-expand.doc.mjs +0 -66
  663. package/clients/cli/commands/layout-grammar.doc.mjs +0 -30
  664. package/clients/cli/commands/layout.doc.mjs +0 -34
  665. package/clients/cli/commands/layout.error-codes.test.mjs +0 -66
  666. package/clients/cli/commands/layout.exit-parity.test.mjs +0 -41
  667. package/clients/cli/commands/layout.mjs +0 -263
  668. package/clients/cli/lib/update-check.mjs +0 -83
  669. package/clients/cli/lib/update-check.test.mjs +0 -137
  670. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -0,0 +1,1704 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * A repository you can drill into: folder, subfolder, file, in one table whose
7
+ * folder rows carry the newest commit from everything beneath them.
8
+ *
9
+ * A tree table earns its complexity when the parent rows are *derived from*
10
+ * the children. A plain folder listing does not qualify — a folder with a
11
+ * blank column has nothing to say at its own level, so a tree view is enough,
12
+ * and a table just adds a header row that is true of no row beneath it. What
13
+ * makes a repository qualify is that a folder has a real answer: its last
14
+ * commit is the most recent commit inside it. Then "src changed 2 hours ago"
15
+ * and "src/components is what changed" are the same question at two zoom
16
+ * levels, which is exactly what this shape is for.
17
+ *
18
+ * ## Extending this template
19
+ *
20
+ * **Two hooks, and neither one is the tree.** `useTableTreeState` owns the
21
+ * expanded set and flattens the nested data into the visible rows;
22
+ * `useTableTreeData` is the render plugin that draws the per-level indent and
23
+ * the chevron in the tree column. You pass the first one's `treeConfig`
24
+ * straight into the second. Hand-rolling this — a Set of open ids plus a
25
+ * recursive flatten on every render — is the usual way tree tables get built,
26
+ * and it costs you the ARIA (`aria-level`, `aria-expanded`), the expand-all
27
+ * control, and the guarantee that every level shares one column grid.
28
+ *
29
+ * **Sorting composes with the hierarchy instead of flattening it.**
30
+ * `useTableSortableState` returns an `applySort` that is handed to the tree's
31
+ * `sortSiblings`, so a sort reorders each sibling group in place and children
32
+ * never leave their parent. Sorting by date does not interleave a file with
33
+ * the folder that contains it.
34
+ *
35
+ * **Rollups are computed from the leaves, never stored.** `withRollups` walks
36
+ * the fixture once and gives every folder the newest commit beneath it, so a
37
+ * folder can never disagree with its contents. Message and date are copied
38
+ * from the *same* child rather than reduced separately — taking the newest
39
+ * date and the longest message would describe two different commits on one
40
+ * row. Hardcoding either is the bug this shape exists to prevent.
41
+ *
42
+ * **Search prunes the tree and takes the chevrons with it.** A query keeps any
43
+ * branch with a match anywhere beneath it and force-expands what survives, so
44
+ * results are never hidden behind a collapsed parent. While a query is active
45
+ * the expanded set is derived rather than stored, and manual toggling is
46
+ * suspended; clearing the box restores exactly the expansion state the user had
47
+ * before searching.
48
+ *
49
+ * **A pruned tree has to be re-rolled.** `withRollups` runs again on the
50
+ * pruned result, because removing children changes what every ancestor
51
+ * contains. Skipping that second pass is the subtle bug in most filtered tree
52
+ * tables: the rows are correct, the parents are stale, and a folder quietly
53
+ * advertises a commit that no surviving file beneath it carries. If you add a
54
+ * filter of any kind, re-roll after it.
55
+ *
56
+ * **Ids are paths, and that is load-bearing.** Every node's id is its full
57
+ * path, so the parent lookup for ArrowLeft and the row keys all fall out of
58
+ * string operations instead of a second index. If you swap in data keyed by
59
+ * uuid, you need an explicit `parentId` and the keyboard plugin has to walk
60
+ * that instead.
61
+ *
62
+ * **Arrow keys are a local plugin, not a core one.** `useTableTreeData` gives
63
+ * you the ARIA but no key handling, so `useTreeKeyboardNav` below implements
64
+ * the APG treegrid keys through `transformTable` (one `onKeyDown`) and
65
+ * `transformBodyRow` (roving `tabIndex`). It is written as a real
66
+ * `TablePlugin` rather than a wrapper div so it composes with the other
67
+ * plugins in the same `plugins={{}}` bag. Lift it into core if you need it in
68
+ * more than one place.
69
+ *
70
+ * **The rows carry a cursor and a selection, and they are different things.**
71
+ * The roving `tabIndex` is the cursor — wherever the arrows last left off. The
72
+ * selection is the folder the header trail and the README are describing, and
73
+ * it moves from the name cell or from a breadcrumb, never from an arrow key.
74
+ * The keyboard plugin owns the paint for both because it holds the only
75
+ * `transformBodyRow` in the bag: `aria-selected` and a washed row for the
76
+ * selection, `tabIndex` for the cursor. Because selection is read there, it is
77
+ * also a dependency of that memo — a breadcrumb click changes nothing else the
78
+ * memo watches, so leaving it out strands the tint and the ARIA state on the
79
+ * previously selected row. If you need real multi-row selection, reach for
80
+ * `useTableSelection` rather than widening this one.
81
+ *
82
+ * **The tree column wraps and does not truncate.** That is a documented
83
+ * limitation of `useTableTreeData`: `textOverflow="truncate"` does not reach
84
+ * inside the tree column, because the indent and expander need the cell to be a
85
+ * flex row. Name and the commit subject both take `proportional()` so the two
86
+ * variable-length columns share the slack; the date is `pixel()` because its
87
+ * text has a known ceiling.
88
+ */
89
+
90
+ import {
91
+ useCallback,
92
+ useEffect,
93
+ useLayoutEffect,
94
+ useMemo,
95
+ useRef,
96
+ useState,
97
+ } from 'react';
98
+ import {
99
+ HStack,
100
+ Layout,
101
+ LayoutContent,
102
+ LayoutPanel,
103
+ StackItem,
104
+ VStack,
105
+ } from '@astryxdesign/core/Layout';
106
+ import {Heading, Text} from '@astryxdesign/core/Text';
107
+ import {Button} from '@astryxdesign/core/Button';
108
+ import {Avatar} from '@astryxdesign/core/Avatar';
109
+ import {Badge} from '@astryxdesign/core/Badge';
110
+ import {Card} from '@astryxdesign/core/Card';
111
+ import {Divider} from '@astryxdesign/core/Divider';
112
+ import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
113
+ import {Section} from '@astryxdesign/core/Section';
114
+ import {SizeProvider} from '@astryxdesign/core/SizeContext';
115
+ import {EmptyState} from '@astryxdesign/core/EmptyState';
116
+ import {Icon} from '@astryxdesign/core/Icon';
117
+ import {Markdown} from '@astryxdesign/core/Markdown';
118
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
119
+ import {IconButton} from '@astryxdesign/core/IconButton';
120
+ import {Link} from '@astryxdesign/core/Link';
121
+ import {Breadcrumbs, BreadcrumbItem} from '@astryxdesign/core/Breadcrumbs';
122
+ import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
123
+ import {colorVars} from '@astryxdesign/core/theme/tokens.stylex';
124
+ import {Token} from '@astryxdesign/core/Token';
125
+ import {TextInput} from '@astryxdesign/core/TextInput';
126
+ import {Tab, TabList} from '@astryxdesign/core/TabList';
127
+ import {Toolbar} from '@astryxdesign/core/Toolbar';
128
+ import {
129
+ Table,
130
+ pixel,
131
+ proportional,
132
+ useTableColumnResize,
133
+ useTableSortable,
134
+ useTableSortableState,
135
+ useTableTreeData,
136
+ useTableTreeState,
137
+ } from '@astryxdesign/core/Table';
138
+ import type {TableColumn, TablePlugin} from '@astryxdesign/core/Table';
139
+ import * as stylex from '@stylexjs/stylex';
140
+ /*
141
+ * Lucide throughout, not Heroicons. A repository page needs a branch and a
142
+ * fork glyph and Heroicons has no git iconography at all, so mixing sets was
143
+ * the only alternative — two libraries drawn on different grids, at different
144
+ * stroke weights, sitting side by side in the same toolbar.
145
+ */
146
+ import {
147
+ BookOpen,
148
+ ChartColumn,
149
+ Code,
150
+ Eye,
151
+ File,
152
+ Folder,
153
+ GitBranch,
154
+ GitFork,
155
+ Scale,
156
+ Search,
157
+ ShieldCheck,
158
+ Star,
159
+ Tag,
160
+ } from 'lucide-react';
161
+
162
+ // ============= DATA =============
163
+
164
+ type EntryKind =
165
+ 'folder' | 'typescript' | 'json' | 'yaml' | 'markdown' | 'text';
166
+
167
+ interface RepoEntry extends Record<string, unknown> {
168
+ /** Full path. Doubles as the ArrowLeft parent link and the row key. */
169
+ id: string;
170
+ name: string;
171
+ kind: EntryKind;
172
+ /** Empty on folders — `withRollups` copies the newest commit beneath them. */
173
+ commitMessage: string;
174
+ /** Epoch ms. Zero on folders — `withRollups` takes the newest descendant. */
175
+ committedAt: number;
176
+ children?: RepoEntry[];
177
+ }
178
+
179
+ /** The repository whose tree fills the table. */
180
+ const ROOT_NAME = 'canvas-editor';
181
+
182
+ /*
183
+ * Every file gets the same glyph. Only one distinction earns its keep in this
184
+ * column — folder or not, because that is what predicts whether a row expands.
185
+ * Splitting files further by extension paints four near-identical shapes that
186
+ * repeat the name sitting next to them.
187
+ */
188
+ const KIND_ICON: Record<
189
+ EntryKind,
190
+ React.ComponentType<React.SVGProps<SVGSVGElement>>
191
+ > = {
192
+ folder: Folder,
193
+ typescript: File,
194
+ json: File,
195
+ yaml: File,
196
+ markdown: File,
197
+ text: File,
198
+ };
199
+
200
+ /**
201
+ * A fixed clock. Every age is authored as an offset from it, so "2 hours ago"
202
+ * stays "2 hours ago" in each environment and in the docsite's snapshots —
203
+ * which a real `Date.now()` could not promise.
204
+ */
205
+ const NOW = Date.UTC(2026, 8, 9, 16, 0);
206
+
207
+ const minutesAgo = (count: number): number => NOW - count * 60_000;
208
+ const hoursAgo = (count: number): number => NOW - count * 3_600_000;
209
+ const daysAgo = (count: number): number => NOW - count * 86_400_000;
210
+
211
+ function file(
212
+ path: string,
213
+ kind: EntryKind,
214
+ commitMessage: string,
215
+ committedAt: number,
216
+ ): RepoEntry {
217
+ return {
218
+ id: path,
219
+ name: path.slice(path.lastIndexOf('/') + 1),
220
+ kind,
221
+ commitMessage,
222
+ committedAt,
223
+ };
224
+ }
225
+
226
+ function folder(path: string, children: RepoEntry[]): RepoEntry {
227
+ return {
228
+ id: path,
229
+ name: path.slice(path.lastIndexOf('/') + 1),
230
+ kind: 'folder',
231
+ commitMessage: '',
232
+ committedAt: 0,
233
+ children,
234
+ };
235
+ }
236
+
237
+ const REPO_TREE: RepoEntry[] = [
238
+ folder('.github', [
239
+ folder('.github/workflows', [
240
+ file(
241
+ '.github/workflows/ci.yml',
242
+ 'yaml',
243
+ 'ci: cache the pnpm store between runs (#412)',
244
+ hoursAgo(3),
245
+ ),
246
+ file(
247
+ '.github/workflows/release.yml',
248
+ 'yaml',
249
+ 'ci: publish npm provenance on tagged releases (#398)',
250
+ daysAgo(12),
251
+ ),
252
+ ]),
253
+ file(
254
+ '.github/PULL_REQUEST_TEMPLATE.md',
255
+ 'markdown',
256
+ 'docs: ask for a repro link in bug reports (#377)',
257
+ daysAgo(61),
258
+ ),
259
+ ]),
260
+ folder('docs', [
261
+ folder('docs/architecture', [
262
+ file(
263
+ 'docs/architecture/rendering.md',
264
+ 'markdown',
265
+ 'docs: explain the dirty-rect redraw path (#405)',
266
+ daysAgo(5),
267
+ ),
268
+ file(
269
+ 'docs/architecture/state.md',
270
+ 'markdown',
271
+ 'docs: describe undo stack ownership (#390)',
272
+ daysAgo(21),
273
+ ),
274
+ ]),
275
+ // The one nested README. GitHub renders whichever README sits in the
276
+ // folder you are looking at, so this folder gets a preview and the others
277
+ // get none — which is the behaviour worth showing.
278
+ file(
279
+ 'docs/README.md',
280
+ 'markdown',
281
+ 'docs: add an index for the docs folder (#411)',
282
+ daysAgo(3),
283
+ ),
284
+ file(
285
+ 'docs/contributing.md',
286
+ 'markdown',
287
+ 'docs: document the release checklist (#402)',
288
+ daysAgo(8),
289
+ ),
290
+ file(
291
+ 'docs/getting-started.md',
292
+ 'markdown',
293
+ 'docs: add a first-plugin walkthrough (#369)',
294
+ daysAgo(64),
295
+ ),
296
+ ]),
297
+ folder('src', [
298
+ folder('src/components', [
299
+ // Three levels deep, so the header trail can outgrow the bar and the
300
+ // collapse menu has something to collapse.
301
+ folder('src/components/canvas', [
302
+ file(
303
+ 'src/components/canvas/Canvas.tsx',
304
+ 'typescript',
305
+ 'fix: keep pointer capture through a drag (#417)',
306
+ hoursAgo(2),
307
+ ),
308
+ file(
309
+ 'src/components/canvas/layers.ts',
310
+ 'typescript',
311
+ 'feat: let a group be locked without hiding it (#413)',
312
+ hoursAgo(9),
313
+ ),
314
+ file(
315
+ 'src/components/canvas/pointer.ts',
316
+ 'typescript',
317
+ 'fix: release capture when the window blurs (#417)',
318
+ hoursAgo(2),
319
+ ),
320
+ ]),
321
+ file(
322
+ 'src/components/Inspector.tsx',
323
+ 'typescript',
324
+ 'feat: group the transform fields into one panel (#409)',
325
+ daysAgo(4),
326
+ ),
327
+ file(
328
+ 'src/components/Toolbar.tsx',
329
+ 'typescript',
330
+ 'refactor: derive tool state from the store (#396)',
331
+ daysAgo(14),
332
+ ),
333
+ ]),
334
+ folder('src/lib', [
335
+ file(
336
+ 'src/lib/geometry.ts',
337
+ 'typescript',
338
+ 'perf: stop reallocating the bounds buffer (#414)',
339
+ hoursAgo(20),
340
+ ),
341
+ file(
342
+ 'src/lib/serialize.ts',
343
+ 'typescript',
344
+ 'fix: round-trip empty groups without loss (#401)',
345
+ daysAgo(9),
346
+ ),
347
+ file(
348
+ 'src/lib/shapes.ts',
349
+ 'typescript',
350
+ 'feat: add polygon and star primitives (#411)',
351
+ daysAgo(3),
352
+ ),
353
+ ]),
354
+ file(
355
+ 'src/main.ts',
356
+ 'typescript',
357
+ 'chore: move bootstrap behind a feature flag (#415)',
358
+ daysAgo(1),
359
+ ),
360
+ file(
361
+ 'src/ui.tsx',
362
+ 'typescript',
363
+ 'fix: restore focus after the modal closes (#407)',
364
+ daysAgo(6),
365
+ ),
366
+ ]),
367
+ folder('tests', [
368
+ file(
369
+ 'tests/geometry.test.ts',
370
+ 'typescript',
371
+ 'test: cover degenerate bounding boxes (#414)',
372
+ hoursAgo(20),
373
+ ),
374
+ file(
375
+ 'tests/serialize.test.ts',
376
+ 'typescript',
377
+ 'test: add a fixture for nested groups (#401)',
378
+ daysAgo(9),
379
+ ),
380
+ ]),
381
+ file(
382
+ '.gitignore',
383
+ 'text',
384
+ 'chore: ignore local coverage output (#380)',
385
+ daysAgo(49),
386
+ ),
387
+ file(
388
+ 'LICENSE',
389
+ 'text',
390
+ 'chore: bump the copyright year (#352)',
391
+ daysAgo(243),
392
+ ),
393
+ file(
394
+ 'README.md',
395
+ 'markdown',
396
+ 'docs: rewrite the install section (#418)',
397
+ minutesAgo(30),
398
+ ),
399
+ file('package.json', 'json', 'chore: bump vite to 6.2 (#416)', hoursAgo(18)),
400
+ file(
401
+ 'tsconfig.json',
402
+ 'json',
403
+ 'build: turn on verbatimModuleSyntax (#393)',
404
+ daysAgo(35),
405
+ ),
406
+ ];
407
+
408
+ /**
409
+ * Give every folder the newest commit beneath it — message and date together,
410
+ * taken from the same child so the two columns never describe different
411
+ * commits. Runs bottom-up, so a folder always agrees with what is inside it.
412
+ */
413
+ function withRollups(nodes: RepoEntry[]): RepoEntry[] {
414
+ return nodes.map(node => {
415
+ if (!node.children || node.children.length === 0) {
416
+ return node;
417
+ }
418
+ const children = withRollups(node.children);
419
+ const newest = children.reduce((latest, child) =>
420
+ child.committedAt > latest.committedAt ? child : latest,
421
+ );
422
+ return {
423
+ ...node,
424
+ children,
425
+ commitMessage: newest.commitMessage,
426
+ committedAt: newest.committedAt,
427
+ };
428
+ });
429
+ }
430
+
431
+ const ROLLED_UP = withRollups(REPO_TREE);
432
+
433
+ const DEFAULT_EXPANDED = ['src'];
434
+
435
+ // ============= README =============
436
+
437
+ /*
438
+ * Rendered by core's `Markdown`, which parses in-house and draws through the
439
+ * same Heading/Text/List/CodeBlock/Table primitives the rest of the page uses
440
+ * — so the preview inherits the theme instead of shipping its own prose CSS.
441
+ */
442
+ const README = `## canvas-editor
443
+
444
+ A small, embeddable vector canvas. Draw shapes, group them, and serialise the
445
+ result to JSON that round-trips without loss.
446
+
447
+ ### Install
448
+
449
+ \`\`\`bash
450
+ npm install canvas-editor
451
+ \`\`\`
452
+
453
+ ### Usage
454
+
455
+ \`\`\`ts
456
+ const {Canvas} = await import('canvas-editor');
457
+
458
+ const canvas = new Canvas(document.querySelector('#root'));
459
+ canvas.add({kind: 'rect', x: 0, y: 0, width: 120, height: 80});
460
+ \`\`\`
461
+
462
+ ### Status
463
+
464
+ | Area | State | Notes |
465
+ | ----------- | -------- | -------------------------------------- |
466
+ | Rendering | Stable | Dirty-rect redraw, no full repaints |
467
+ | Serialising | Stable | Round-trips groups and nested shapes |
468
+ | Plugins | Beta | API may change before 1.0 |
469
+
470
+ ### Contributing
471
+
472
+ Read [the contributing guide](#) first. In short:
473
+
474
+ - One change per pull request, with a test that fails without it.
475
+ - Run \`npm test\` before pushing.
476
+ - Conventional commits — the changelog is generated from them.
477
+
478
+ > Issues tagged \`good first issue\` are a reasonable place to start.
479
+ `;
480
+
481
+ const DOCS_README = `## Documentation
482
+
483
+ How the canvas is put together, and how to work on it.
484
+
485
+ ### Start here
486
+
487
+ - **getting-started.md** — install the package and draw your first shape.
488
+ - **contributing.md** — branch naming, the release checklist, how to get review.
489
+
490
+ ### Architecture
491
+
492
+ The \`architecture/\` folder covers the parts that are hard to infer from the
493
+ source: how the dirty-rect redraw path decides what to repaint, and who owns
494
+ the undo stack.
495
+ `;
496
+
497
+ /*
498
+ * Which folders have a README to show. Keyed by folder id, with the empty
499
+ * string standing for the repo root.
500
+ *
501
+ * Whether a preview appears is decided by the tree, not by this map — a
502
+ * folder shows one only if it actually contains a README file. This just
503
+ * supplies the body for the folders that do, so the two cannot disagree
504
+ * about which folders have one.
505
+ */
506
+ const READMES: Record<string, string> = {
507
+ '': README,
508
+ docs: DOCS_README,
509
+ };
510
+
511
+ /**
512
+ * The README to show for the folder currently selected, or `null` when that
513
+ * folder has none — the file list stands on its own and the preview card is
514
+ * dropped rather than left empty.
515
+ *
516
+ * Ids are paths, so the folder is reached by walking its segments.
517
+ */
518
+ function readmeFor(folderId: string | null): string | null {
519
+ let entries = REPO_TREE;
520
+
521
+ if (folderId != null) {
522
+ for (const segment of folderId.split('/')) {
523
+ const next: RepoEntry | undefined = entries.find(
524
+ entry => entry.name === segment && entry.kind === 'folder',
525
+ );
526
+ if (next?.children == null) {
527
+ return null;
528
+ }
529
+ entries = next.children;
530
+ }
531
+ }
532
+
533
+ const hasReadme = entries.some(
534
+ entry => entry.kind !== 'folder' && /^readme\.md$/i.test(entry.name),
535
+ );
536
+ return hasReadme ? (READMES[folderId ?? ''] ?? null) : null;
537
+ }
538
+
539
+ // ============= SEARCH =============
540
+
541
+ /**
542
+ * Shared by `pruneTree` and `countMatches` so the rows on screen and the
543
+ * tally beside the search field can never disagree about what a match is.
544
+ */
545
+ function isMatch(node: RepoEntry, needle: string): boolean {
546
+ return (
547
+ node.name.toLowerCase().includes(needle) ||
548
+ node.commitMessage.toLowerCase().includes(needle)
549
+ );
550
+ }
551
+
552
+ /**
553
+ * Counts only true matches. `pruneTree` also keeps ancestors, so the visible
554
+ * row count runs ahead of the number of things actually found.
555
+ */
556
+ function countMatches(nodes: RepoEntry[], needle: string): number {
557
+ return nodes.reduce(
558
+ (total, node) =>
559
+ total +
560
+ (isMatch(node, needle) ? 1 : 0) +
561
+ (node.children ? countMatches(node.children, needle) : 0),
562
+ 0,
563
+ );
564
+ }
565
+
566
+ /** Keep any branch with a match at or beneath it; drop everything else. */
567
+ function pruneTree(nodes: RepoEntry[], needle: string): RepoEntry[] {
568
+ const kept: RepoEntry[] = [];
569
+ for (const node of nodes) {
570
+ const isSelfMatch = isMatch(node, needle);
571
+ const children = node.children ? pruneTree(node.children, needle) : [];
572
+ if (isSelfMatch || children.length > 0) {
573
+ kept.push(
574
+ node.children
575
+ ? {
576
+ ...node,
577
+ children:
578
+ isSelfMatch && children.length === 0 ? node.children : children,
579
+ }
580
+ : node,
581
+ );
582
+ }
583
+ }
584
+ return kept;
585
+ }
586
+
587
+ function collectIds(nodes: RepoEntry[], into: Set<string>): Set<string> {
588
+ for (const node of nodes) {
589
+ if (node.children && node.children.length > 0) {
590
+ into.add(node.id);
591
+ collectIds(node.children, into);
592
+ }
593
+ }
594
+ return into;
595
+ }
596
+
597
+ // ============= FORMATTING =============
598
+
599
+ /*
600
+ * `numeric: 'auto'` is what turns a single unit into "yesterday", "last week"
601
+ * and "last month" instead of "1 day ago" — the phrasing a repository browser
602
+ * uses. The locale is pinned so the docsite's snapshots do not move with the
603
+ * runner's own.
604
+ */
605
+ const relativeFormat = new Intl.RelativeTimeFormat('en-US', {numeric: 'auto'});
606
+
607
+ // Largest first: the loop reports in the biggest unit that fits.
608
+ const AGE_UNITS: ReadonlyArray<readonly [Intl.RelativeTimeFormatUnit, number]> =
609
+ [
610
+ ['year', 365 * 86_400_000],
611
+ ['month', 30 * 86_400_000],
612
+ ['week', 7 * 86_400_000],
613
+ ['day', 86_400_000],
614
+ ['hour', 3_600_000],
615
+ ['minute', 60_000],
616
+ ];
617
+
618
+ function commitAge(epochMs: number): string {
619
+ const elapsed = NOW - epochMs;
620
+ for (const [unit, size] of AGE_UNITS) {
621
+ if (elapsed >= size) {
622
+ return relativeFormat.format(-Math.floor(elapsed / size), unit);
623
+ }
624
+ }
625
+ return 'just now';
626
+ }
627
+
628
+ // ============= KEYBOARD =============
629
+
630
+ const styles = stylex.create({
631
+ /*
632
+ * Toolbar pads its own contents by 12px, but the table body sits on a 16px
633
+ * content line. The difference is added out here so the two agree.
634
+ */
635
+ headerContainer: {
636
+ paddingInline: '4px',
637
+ },
638
+ /*
639
+ * The wash for the selected folder.
640
+ *
641
+ * `--color-background-blue`, not `--color-accent-muted`: the theme in use
642
+ * neutralises the accent family to grey (#f1f1f1 light, #262626 dark), so an
643
+ * accent wash would be invisible against the zebra stripe. The categorical
644
+ * colours are left chromatic on purpose, which is what makes this read blue.
645
+ *
646
+ * Painted as an inset shadow rather than a background so it survives the
647
+ * zebra stripe, which sets `background-color` on this same row and would
648
+ * otherwise replace it.
649
+ */
650
+ selectedRow: {
651
+ boxShadow: `inset 0 0 0 100vmax ${colorVars['--color-background-blue']}`,
652
+ },
653
+ // Inside the TabList so its divider border keeps spanning the whole card.
654
+ readmeTabs: {
655
+ paddingInline: '4px',
656
+ },
657
+ });
658
+
659
+ /** Header width below which the view switcher and search give up their space. */
660
+ const NARROW_HEADER = 720;
661
+
662
+ /**
663
+ * How many crumbs stay inline before the middle folds into a menu. Three
664
+ * keeps the repo, the parent and the current folder — one more than that and
665
+ * the trail starts competing with the rest of the toolbar for the row.
666
+ */
667
+ const MAX_INLINE_CRUMBS = 3;
668
+
669
+ /** Stands in for the folded-away crumbs while mapping over the trail. */
670
+ const ELLIPSIS = Symbol('ellipsis');
671
+
672
+ /** One entry in the header trail. `id` is null for the repository root. */
673
+ interface Crumb {
674
+ label: string;
675
+ id: string | null;
676
+ }
677
+
678
+ /**
679
+ * Reports the observed width of whatever the returned ref is attached to.
680
+ *
681
+ * A container query cannot drive this header. Passing `centerContent` at all
682
+ * is what puts Toolbar into its three-column grid, so hiding the control
683
+ * inside leaves the middle column still holding its share of the row. Whether
684
+ * to pass the slot is a render decision, which puts the breakpoint in JS — and
685
+ * once it is there, the search reads the same number so both halves of the
686
+ * header agree on where narrow starts.
687
+ */
688
+ function useMeasuredWidth(
689
+ ref: React.RefObject<HTMLElement | null>,
690
+ ): number | null {
691
+ // Null until measured, so the first paint is not committed to a guess.
692
+ const [width, setWidth] = useState<number | null>(null);
693
+
694
+ useLayoutEffect(() => {
695
+ const element = ref.current;
696
+ if (element == null) {
697
+ return;
698
+ }
699
+ const observer = new ResizeObserver(entries => {
700
+ setWidth(entries[0].contentRect.width);
701
+ });
702
+ observer.observe(element);
703
+ return () => observer.disconnect();
704
+ }, [ref]);
705
+
706
+ return width;
707
+ }
708
+
709
+ interface TreeKeyboardNavConfig {
710
+ /** The flattened, currently visible rows, in render order. */
711
+ rows: ReadonlyArray<RepoEntry>;
712
+ focusedId: string | null;
713
+ onFocusedIdChange: (id: string) => void;
714
+ expandedIds: ReadonlySet<string>;
715
+ onExpand: (id: string) => void;
716
+ onCollapse: (id: string) => void;
717
+ /** The folder whose contents the header trail is describing, if any. */
718
+ selectedId: string | null;
719
+ }
720
+
721
+ /**
722
+ * APG treegrid keys over the visible rows: Up/Down move, Right expands then
723
+ * descends, Left collapses then ascends, Home/End jump to the ends.
724
+ *
725
+ * Roving `tabIndex` means the table is one tab stop; once inside, the arrows
726
+ * own the movement. Focus itself is moved by the page effect that watches
727
+ * `focusedId`, not from here, so a click and a keypress converge on one path.
728
+ */
729
+ function useTreeKeyboardNav({
730
+ rows,
731
+ focusedId,
732
+ onFocusedIdChange,
733
+ expandedIds,
734
+ onExpand,
735
+ onCollapse,
736
+ selectedId,
737
+ }: TreeKeyboardNavConfig): TablePlugin<RepoEntry> {
738
+ const handleKeyDown = useCallback(
739
+ (event: React.KeyboardEvent<HTMLTableElement>) => {
740
+ if (event.defaultPrevented || rows.length === 0) {
741
+ return;
742
+ }
743
+ const index = rows.findIndex(row => row.id === focusedId);
744
+ const current = index >= 0 ? rows[index] : undefined;
745
+ const hasChildren = Boolean(current?.children?.length);
746
+ const isExpanded = current != null && expandedIds.has(current.id);
747
+
748
+ let nextId: string | undefined;
749
+
750
+ switch (event.key) {
751
+ case 'ArrowDown':
752
+ nextId = rows[index + 1]?.id ?? rows[0]?.id;
753
+ break;
754
+ case 'ArrowUp':
755
+ nextId = index > 0 ? rows[index - 1].id : rows[0]?.id;
756
+ break;
757
+ case 'ArrowRight':
758
+ if (current == null) {
759
+ nextId = rows[0]?.id;
760
+ } else if (hasChildren && !isExpanded) {
761
+ onExpand(current.id);
762
+ } else if (hasChildren) {
763
+ // Already open, so Right descends to the first child.
764
+ nextId = rows[index + 1]?.id;
765
+ }
766
+ break;
767
+ case 'ArrowLeft':
768
+ if (current == null) {
769
+ nextId = rows[0]?.id;
770
+ } else if (hasChildren && isExpanded) {
771
+ onCollapse(current.id);
772
+ } else {
773
+ // Ids are paths, so the parent is a prefix of this row's id.
774
+ const cut = current.id.lastIndexOf('/');
775
+ const parentId = cut > 0 ? current.id.slice(0, cut) : undefined;
776
+ nextId = rows.find(row => row.id === parentId)?.id;
777
+ }
778
+ break;
779
+ case 'Home':
780
+ nextId = rows[0]?.id;
781
+ break;
782
+ case 'End':
783
+ nextId = rows[rows.length - 1]?.id;
784
+ break;
785
+ default:
786
+ return;
787
+ }
788
+
789
+ // Every branch above either moved focus or toggled a row, so the arrow
790
+ // key must not also scroll the page.
791
+ event.preventDefault();
792
+ if (nextId != null && nextId !== focusedId) {
793
+ onFocusedIdChange(nextId);
794
+ }
795
+ },
796
+ [rows, focusedId, expandedIds, onExpand, onCollapse, onFocusedIdChange],
797
+ );
798
+
799
+ return useMemo(
800
+ () => ({
801
+ transformTable: props => ({
802
+ ...props,
803
+ htmlProps: {...props.htmlProps, onKeyDown: handleKeyDown},
804
+ }),
805
+ transformBodyRow: (props, item) => ({
806
+ ...props,
807
+ /*
808
+ * The selected folder is washed rather than outlined, so the row
809
+ * still reads as part of the list. The tint rides on this plugin
810
+ * because it owns the only `transformBodyRow` in the bag — a second
811
+ * plugin transforming the same row would have to be merged by hand.
812
+ */
813
+ xstyle: [...props.xstyle, item.id === selectedId && styles.selectedRow],
814
+ htmlProps: {
815
+ ...props.htmlProps,
816
+ /*
817
+ * Roving tabindex: one stop for the whole table. Selection and the
818
+ * cursor are separate — the cursor is wherever the arrows left off,
819
+ * the selection is the folder the header trail is describing.
820
+ */
821
+ tabIndex: item.id === focusedId ? 0 : -1,
822
+ 'data-row-id': item.id,
823
+ 'aria-selected': item.id === selectedId ? true : undefined,
824
+ onClick: () => onFocusedIdChange(item.id),
825
+ },
826
+ }),
827
+ }),
828
+ [handleKeyDown, focusedId, selectedId, onFocusedIdChange],
829
+ );
830
+ }
831
+
832
+ // ============= COLUMNS =============
833
+
834
+ /*
835
+ * A factory, not a constant: the name cell has to reach the handler that
836
+ * moves the trail in the header, and that handler only exists inside the
837
+ * page component.
838
+ */
839
+ function buildColumns(
840
+ onSelectFolder: (id: string) => void,
841
+ ): TableColumn<RepoEntry>[] {
842
+ return [
843
+ {
844
+ key: 'name',
845
+ header: 'Name',
846
+ // Deep rows spend their width on indentation before the name even starts,
847
+ // so this column needs a floor well above the 120px default.
848
+ width: proportional(2, {minWidth: 240}),
849
+ sortable: true,
850
+ renderCell: entry => (
851
+ <HStack gap={2} vAlign="center">
852
+ <Icon
853
+ icon={KIND_ICON[entry.kind]}
854
+ size="sm"
855
+ color={entry.kind === 'folder' ? 'accent' : 'secondary'}
856
+ // Lucide draws strokes only, so filling the folder is the sole way
857
+ // to weight it against the files. `fill` reaches the svg because
858
+ // Icon forwards unknown props through to the component it renders.
859
+ fill={entry.kind === 'folder' ? 'currentColor' : 'none'}
860
+ />
861
+ {entry.kind === 'folder' ? (
862
+ /*
863
+ * A folder is a place rather than a document, so activating it
864
+ * selects it and the header trail follows. The href is what makes
865
+ * it a link for the keyboard and the context menu; the default is
866
+ * suppressed because nothing is actually fetched.
867
+ */
868
+ <Link
869
+ href="#"
870
+ onClick={event => {
871
+ event.preventDefault();
872
+ onSelectFolder(entry.id);
873
+ }}>
874
+ {entry.name}
875
+ </Link>
876
+ ) : (
877
+ <Link href="#">{entry.name}</Link>
878
+ )}
879
+ </HStack>
880
+ ),
881
+ },
882
+ {
883
+ // Subject lines are long and vary a lot, so this column takes the slack
884
+ // rather than truncating at a width picked in advance.
885
+ key: 'message',
886
+ header: 'Last commit message',
887
+ width: proportional(3, {minWidth: 200}),
888
+ sortable: true,
889
+ renderCell: entry => <Text color="secondary">{entry.commitMessage}</Text>,
890
+ },
891
+ {
892
+ key: 'committed',
893
+ header: 'Last commit date',
894
+ width: pixel(160),
895
+ align: 'end',
896
+ sortable: true,
897
+ renderCell: entry => (
898
+ <Text color="secondary">{commitAge(entry.committedAt)}</Text>
899
+ ),
900
+ },
901
+ ];
902
+ }
903
+
904
+ // ============= REPO CHROME =============
905
+
906
+ /*
907
+ * The supporting detail around the file list. None of it is interactive here —
908
+ * it exists so the table is read in the context a real repository gives it,
909
+ * and so the layout is exercised at its true density rather than with a bare
910
+ * table floating in an empty page.
911
+ */
912
+
913
+ const REPO_DESCRIPTION =
914
+ 'A small, embeddable vector canvas. Draw shapes, group them, and serialise the result to JSON that round-trips without loss.';
915
+
916
+ const TOPICS = [
917
+ 'canvas',
918
+ 'vector-graphics',
919
+ 'typescript',
920
+ 'editor',
921
+ 'svg',
922
+ '2d-graphics',
923
+ 'wasm',
924
+ ];
925
+
926
+ const ABOUT_LINKS = [
927
+ {label: 'Readme', icon: BookOpen},
928
+ {label: 'MIT license', icon: Scale},
929
+ {label: 'Code of conduct', icon: ShieldCheck},
930
+ {label: 'Security policy', icon: ShieldCheck},
931
+ {label: 'Activity', icon: ChartColumn},
932
+ ];
933
+
934
+ /** The count reads as a noun; the button beside it has to read as a verb. */
935
+ const STAT_ACTION: Record<string, string> = {
936
+ stars: 'Star',
937
+ watching: 'Watch',
938
+ forks: 'Fork',
939
+ };
940
+
941
+ /*
942
+ * The two ref counts beside the branch selector. Tag rather than Tags: the
943
+ * plural glyph reads as a stack at 16px, which is noise next to a branch icon
944
+ * that is already busy.
945
+ */
946
+ const REF_COUNTS = [
947
+ {label: '171 Branches', icon: GitBranch},
948
+ {label: '56 Tags', icon: Tag},
949
+ ];
950
+
951
+ const REPO_STATS = [
952
+ {label: 'stars', value: '12.6k', icon: Star},
953
+ {label: 'watching', value: '97', icon: Eye},
954
+ {label: 'forks', value: '1.3k', icon: GitFork},
955
+ ];
956
+
957
+ const CONTRIBUTORS = [
958
+ 'Ada Okafor',
959
+ 'Bo Lindqvist',
960
+ 'Cass Moreau',
961
+ 'Dev Raman',
962
+ 'Elif Aydın',
963
+ 'Finn Ashworth',
964
+ 'Gia Petrova',
965
+ 'Hana Sato',
966
+ 'Ines Duarte',
967
+ 'Jo Mbeki',
968
+ 'Kit Nakamura',
969
+ 'Lena Voss',
970
+ ];
971
+
972
+ /** The sidebar GitHub puts beside the file list. */
973
+ function AboutPanel() {
974
+ return (
975
+ <VStack gap={6}>
976
+ <VStack gap={3}>
977
+ <Heading level={3}>About</Heading>
978
+ <Text color="secondary">{REPO_DESCRIPTION}</Text>
979
+ <Link href="#" isExternalLink>
980
+ canvas-editor.dev
981
+ </Link>
982
+ {/*
983
+ Topics are chips you can click, which rules out Badge — its docs
984
+ name clickable badges as an anti-pattern. Token is the chip that
985
+ takes an onClick and paints a hover state to match.
986
+ */}
987
+ <HStack gap={1} wrap="wrap">
988
+ {TOPICS.map(topic => (
989
+ <Token key={topic} size="sm" label={topic} onClick={() => {}} />
990
+ ))}
991
+ </HStack>
992
+ {/*
993
+ Not a List. ListItem is a row with its own inline padding and hit
994
+ area, so it hangs its text further in than the description, the
995
+ topics and the stats above it — the one thing this column needs is
996
+ a single left edge. An icon beside a Link starts where they start.
997
+ */}
998
+ <VStack gap={2}>
999
+ {ABOUT_LINKS.map(link => (
1000
+ <HStack key={link.label} gap={2} vAlign="center">
1001
+ <Icon icon={link.icon} size="sm" color="secondary" />
1002
+ <Link href="#">{link.label}</Link>
1003
+ </HStack>
1004
+ ))}
1005
+ </VStack>
1006
+ {/*
1007
+ Same row shape as the links above: one per line, icon at sm beside
1008
+ a default-size Link. These used to be supporting-size with the count
1009
+ bolded and the three of them wrapping two-then-one, which read as a
1010
+ different kind of thing sitting in the middle of the same column.
1011
+ */}
1012
+ <VStack gap={2}>
1013
+ {REPO_STATS.map(stat => (
1014
+ <HStack key={stat.label} gap={2} vAlign="center">
1015
+ <Icon icon={stat.icon} size="sm" color="secondary" />
1016
+ <Link href="#">
1017
+ {stat.value} {stat.label}
1018
+ </Link>
1019
+ </HStack>
1020
+ ))}
1021
+ </VStack>
1022
+ </VStack>
1023
+
1024
+ <Divider />
1025
+
1026
+ <VStack gap={3}>
1027
+ <Heading level={3}>Releases</Heading>
1028
+ <HStack gap={2} vAlign="center">
1029
+ <Icon icon={Tag} size="sm" color="success" />
1030
+ <Text weight="bold">v2.4.0</Text>
1031
+ <Badge label="Latest" variant="success" />
1032
+ </HStack>
1033
+ <Text type="supporting" color="secondary">
1034
+ {commitAge(daysAgo(11))} · 36 releases
1035
+ </Text>
1036
+ </VStack>
1037
+
1038
+ <Divider />
1039
+
1040
+ <VStack gap={3}>
1041
+ <Heading level={3}>Contributors</Heading>
1042
+ {/*
1043
+ AvatarGroup overlaps its children by design, which fights a wrapping
1044
+ grid — so this is a plain wrapped row of avatars, which is the shape
1045
+ GitHub uses and the shape the group's own docs point you to here.
1046
+ */}
1047
+ <HStack gap={1} wrap="wrap">
1048
+ {CONTRIBUTORS.map(name => (
1049
+ <Avatar key={name} name={name} size={32} />
1050
+ ))}
1051
+ </HStack>
1052
+ <Link href="#">+ 61 contributors</Link>
1053
+ </VStack>
1054
+ </VStack>
1055
+ );
1056
+ }
1057
+
1058
+ /**
1059
+ * The bar between the repo header and the file list: which ref you are looking
1060
+ * at on the left, and what you can do with it on the right.
1061
+ */
1062
+ function BranchBar({
1063
+ query,
1064
+ onQueryChange,
1065
+ searchRef,
1066
+ isSearching,
1067
+ matchCount,
1068
+ isNarrow,
1069
+ isSearchOpen,
1070
+ onSearchOpen,
1071
+ }: {
1072
+ query: string;
1073
+ onQueryChange: (value: string) => void;
1074
+ searchRef: React.RefObject<HTMLInputElement | null>;
1075
+ isSearching: boolean;
1076
+ matchCount: number;
1077
+ isNarrow: boolean;
1078
+ isSearchOpen: boolean;
1079
+ onSearchOpen: () => void;
1080
+ }) {
1081
+ return (
1082
+ /*
1083
+ * One height for the whole row. Toolbar gets this by wrapping its slots in
1084
+ * a SizeProvider, and this bar is a plain HStack, so it does the same.
1085
+ * Button and TextInput read the context; IconButton and DropdownMenu do
1086
+ * not, so those two are told directly — DropdownMenu takes it on its
1087
+ * `button` object, where it otherwise defaults to `md`.
1088
+ */
1089
+ <SizeProvider value="sm">
1090
+ <HStack gap={2} vAlign="center" wrap="wrap">
1091
+ <DropdownMenu
1092
+ button={{
1093
+ label: 'main',
1094
+ size: 'sm',
1095
+ icon: <Icon icon={GitBranch} size="sm" />,
1096
+ }}
1097
+ hasChevron
1098
+ items={[
1099
+ {label: 'main', onClick: () => {}},
1100
+ {label: 'next', onClick: () => {}},
1101
+ {type: 'divider'},
1102
+ {label: 'View all branches', onClick: () => {}},
1103
+ ]}
1104
+ />
1105
+ {/*
1106
+ Ghost is the flat variant — no fill, no border. The count and the
1107
+ noun ride in one `label` because Button takes a string, so the
1108
+ number cannot be bolded separately the way it is in the stats row.
1109
+
1110
+ These drop to icons at the same width where the search field
1111
+ collapses. `label` carries the full "171 Branches" in both forms, so
1112
+ what a screen reader hears does not change with the viewport — only
1113
+ whether the text is painted alongside the glyph.
1114
+ */}
1115
+ <HStack gap={1} vAlign="center">
1116
+ {REF_COUNTS.map(ref =>
1117
+ isNarrow ? (
1118
+ <IconButton
1119
+ key={ref.label}
1120
+ variant="ghost"
1121
+ size="sm"
1122
+ icon={<Icon icon={ref.icon} size="sm" />}
1123
+ label={ref.label}
1124
+ onClick={() => {}}
1125
+ />
1126
+ ) : (
1127
+ <Button
1128
+ key={ref.label}
1129
+ variant="ghost"
1130
+ size="sm"
1131
+ label={ref.label}
1132
+ onClick={() => {}}
1133
+ />
1134
+ ),
1135
+ )}
1136
+ </HStack>
1137
+ <StackItem size="fill">
1138
+ <HStack gap={2} hAlign="end" vAlign="center">
1139
+ {isSearching && matchCount > 0 && (
1140
+ <Text type="supporting" color="secondary">
1141
+ {matchCount} {matchCount === 1 ? 'result' : 'results'}
1142
+ </Text>
1143
+ )}
1144
+ {isNarrow && !isSearchOpen ? (
1145
+ <IconButton
1146
+ variant="ghost"
1147
+ size="sm"
1148
+ icon={<Icon icon={Search} size="sm" />}
1149
+ label="Go to file"
1150
+ onClick={onSearchOpen}
1151
+ />
1152
+ ) : (
1153
+ <TextInput
1154
+ label="Go to file"
1155
+ isLabelHidden
1156
+ placeholder="Go to file"
1157
+ startIcon={Search}
1158
+ value={query}
1159
+ onChange={onQueryChange}
1160
+ hasClear
1161
+ ref={searchRef}
1162
+ />
1163
+ )}
1164
+ <DropdownMenu
1165
+ button={{label: 'Add file', size: 'sm'}}
1166
+ hasChevron
1167
+ items={[
1168
+ {label: 'Create new file', onClick: () => {}},
1169
+ {label: 'Upload files', onClick: () => {}},
1170
+ ]}
1171
+ />
1172
+ <Button
1173
+ label="Code"
1174
+ variant="primary"
1175
+ icon={<Icon icon={Code} size="sm" />}
1176
+ />
1177
+ </HStack>
1178
+ </StackItem>
1179
+ </HStack>
1180
+ </SizeProvider>
1181
+ );
1182
+ }
1183
+
1184
+ // ============= README =============
1185
+
1186
+ /**
1187
+ * The repository's own documentation, under the file list.
1188
+ *
1189
+ * The tabs swap content in place rather than navigating, so they are a real
1190
+ * `role="tablist"` and each `Tab` names the panel it controls. `Markdown`
1191
+ * takes `headingLevelStart={2}` because the page's h1 is the repository name
1192
+ * up in the header — leaving it at 1 would put a second top-level heading in
1193
+ * the outline for what is really a section of this page.
1194
+ */
1195
+ function ReadmePanel({
1196
+ markdown,
1197
+ hasRepoTabs,
1198
+ }: {
1199
+ markdown: string;
1200
+ /*
1201
+ * Code of conduct, licence and security policy are repository-level files
1202
+ * that live at the root, so they are not offered while you are looking
1203
+ * inside a folder — only that folder's own README is.
1204
+ */
1205
+ hasRepoTabs: boolean;
1206
+ }) {
1207
+ const [tab, setTab] = useState('readme');
1208
+ const activeTab = hasRepoTabs ? tab : 'readme';
1209
+
1210
+ return (
1211
+ <Card padding={0}>
1212
+ {/*
1213
+ `hasDivider` is a border on the TabList itself, so the inset has to go
1214
+ inside it: padding moves the tabs in 4px while the border still runs
1215
+ the full width of the card. Putting that padding on the Section
1216
+ instead would carry the rule in with it and stop it short at both ends.
1217
+ */}
1218
+ <Section variant="transparent" padding={0} paddingBlockStart={1}>
1219
+ <TabList
1220
+ value={activeTab}
1221
+ onChange={setTab}
1222
+ role="tablist"
1223
+ size="sm"
1224
+ hasDivider
1225
+ xstyle={styles.readmeTabs}>
1226
+ <Tab value="readme" label="Readme" panelId="repo-readme" />
1227
+ {hasRepoTabs && (
1228
+ <Tab
1229
+ value="conduct"
1230
+ label="Code of conduct"
1231
+ panelId="repo-conduct"
1232
+ />
1233
+ )}
1234
+ {hasRepoTabs && (
1235
+ <Tab value="license" label="MIT license" panelId="repo-license" />
1236
+ )}
1237
+ {hasRepoTabs && (
1238
+ <Tab value="security" label="Security" panelId="repo-security" />
1239
+ )}
1240
+ </TabList>
1241
+ </Section>
1242
+ <Section variant="transparent" padding={6} id={`repo-${activeTab}`}>
1243
+ {activeTab === 'readme' ? (
1244
+ // Prose already caps at Markdown's default 680px measure; centring
1245
+ // it stops that column hugging the left edge of a much wider card.
1246
+ <Markdown headingLevelStart={2} contentAlign="center">
1247
+ {markdown}
1248
+ </Markdown>
1249
+ ) : (
1250
+ <Text color="secondary">
1251
+ {TAB_PLACEHOLDER[activeTab as keyof typeof TAB_PLACEHOLDER]}
1252
+ </Text>
1253
+ )}
1254
+ </Section>
1255
+ </Card>
1256
+ );
1257
+ }
1258
+
1259
+ /*
1260
+ * The other three tabs exist to show the shape of the control; a template that
1261
+ * shipped four full documents would be teaching markdown, not layout.
1262
+ */
1263
+ const TAB_PLACEHOLDER = {
1264
+ conduct: 'The Contributor Covenant, version 2.1, applies to this project.',
1265
+ license: 'MIT. Do what you like, keep the notice, expect no warranty.',
1266
+ security: 'Report vulnerabilities privately through a security advisory.',
1267
+ };
1268
+
1269
+ // ============= PAGE =============
1270
+
1271
+ export default function FileTreeTemplate() {
1272
+ const [query, setQuery] = useState('');
1273
+ const [manualExpandedIds, setManualExpandedIds] = useState<
1274
+ ReadonlySet<string>
1275
+ >(() => new Set(DEFAULT_EXPANDED));
1276
+ const [focusedId, setFocusedId] = useState<string | null>(null);
1277
+ const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
1278
+ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
1279
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
1280
+ const searchRef = useRef<HTMLInputElement>(null);
1281
+ /*
1282
+ * Two regions, two numbers. The header spans the whole page, so it is what
1283
+ * decides whether the repo actions keep their labels. The branch bar sits in
1284
+ * the content column beside the sidebar and can be far narrower than the
1285
+ * page — measuring the header for it would leave the search field expanded
1286
+ * in a column that cannot hold it.
1287
+ */
1288
+ const headerRef = useRef<HTMLDivElement>(null);
1289
+ const headerWidth = useMeasuredWidth(headerRef);
1290
+ const isNarrowHeader = headerWidth != null && headerWidth <= NARROW_HEADER;
1291
+
1292
+ const bodyRef = useRef<HTMLDivElement>(null);
1293
+ const bodyWidth = useMeasuredWidth(bodyRef);
1294
+ const isNarrowBody = bodyWidth != null && bodyWidth <= NARROW_HEADER;
1295
+
1296
+ /*
1297
+ * Below this the sidebar would starve the table — 296px off an already
1298
+ * short page leaves the commit column clipped. It moves under the README
1299
+ * instead, which is where GitHub puts it at the same size.
1300
+ */
1301
+ const isSidebarInline = useMediaQuery('(min-width: 1024px)');
1302
+
1303
+ /*
1304
+ * The field is only hidden, never unmounted, so `hasAutoFocus` would not
1305
+ * fire on the way back in — the button reveals an existing element rather
1306
+ * than mounting a new one.
1307
+ */
1308
+ useEffect(() => {
1309
+ if (isSearchOpen) {
1310
+ searchRef.current?.focus();
1311
+ }
1312
+ }, [isSearchOpen]);
1313
+
1314
+ /*
1315
+ * Collapsing with a query still applied would hide the reason the tree is
1316
+ * pruned, so an empty field is the only way back to the button. Bound
1317
+ * natively because blur does not bubble and TextInput takes no `onBlur`.
1318
+ */
1319
+ useEffect(() => {
1320
+ const input = searchRef.current;
1321
+ if (input == null) {
1322
+ return;
1323
+ }
1324
+ const collapseIfEmpty = () => {
1325
+ if (query === '') {
1326
+ setIsSearchOpen(false);
1327
+ }
1328
+ };
1329
+ input.addEventListener('blur', collapseIfEmpty);
1330
+ return () => input.removeEventListener('blur', collapseIfEmpty);
1331
+ // `isSearchOpen` mounts and unmounts the field, so the listener has to be
1332
+ // rebound onto whichever element is currently there.
1333
+ }, [query, isSearchOpen]);
1334
+
1335
+ const needle = query.trim().toLowerCase();
1336
+ const isSearching = needle.length > 0;
1337
+
1338
+ // Pruning changes what a folder contains, so the rollups have to be
1339
+ // recomputed from the survivors. Reusing the unpruned totals would leave a
1340
+ // folder claiming more bytes than the files visible underneath it add up to.
1341
+ const data = useMemo(
1342
+ () => (isSearching ? withRollups(pruneTree(ROLLED_UP, needle)) : ROLLED_UP),
1343
+ [isSearching, needle],
1344
+ );
1345
+
1346
+ // While searching, expansion is derived from the pruned tree so no match can
1347
+ // hide behind a collapsed parent; manual state is parked, not overwritten.
1348
+ const searchExpandedIds = useMemo(
1349
+ () => (isSearching ? collectIds(data, new Set<string>()) : null),
1350
+ [isSearching, data],
1351
+ );
1352
+ const expandedIds = searchExpandedIds ?? manualExpandedIds;
1353
+
1354
+ const sortable = useTableSortableState<RepoEntry>({
1355
+ data,
1356
+ comparators: {
1357
+ // Folders first, then by name — the ordering every repo browser uses.
1358
+ name: (a, b) =>
1359
+ Number(b.kind === 'folder') - Number(a.kind === 'folder') ||
1360
+ a.name.localeCompare(b.name),
1361
+ message: (a, b) => a.commitMessage.localeCompare(b.commitMessage),
1362
+ committed: (a, b) => a.committedAt - b.committedAt,
1363
+ },
1364
+ });
1365
+
1366
+ const {visibleData, treeConfig} = useTableTreeState<RepoEntry>({
1367
+ data,
1368
+ idKey: 'id',
1369
+ expandedIds,
1370
+ onExpandedIdsChange: isSearching ? () => {} : setManualExpandedIds,
1371
+ // Reorders each sibling group in place, so children never leave their
1372
+ // parent — this is what keeps sorting and hierarchy compatible.
1373
+ sortSiblings: sortable.applySort,
1374
+ });
1375
+
1376
+ const setExpanded = useCallback(
1377
+ (id: string, shouldExpand: boolean) => {
1378
+ if (isSearching) {
1379
+ return;
1380
+ }
1381
+ setManualExpandedIds(previous => {
1382
+ const next = new Set(previous);
1383
+ if (shouldExpand) {
1384
+ next.add(id);
1385
+ } else {
1386
+ next.delete(id);
1387
+ }
1388
+ return next;
1389
+ });
1390
+ },
1391
+ [isSearching],
1392
+ );
1393
+
1394
+ const expandRow = useCallback(
1395
+ (id: string) => setExpanded(id, true),
1396
+ [setExpanded],
1397
+ );
1398
+ const collapseRow = useCallback(
1399
+ (id: string) => setExpanded(id, false),
1400
+ [setExpanded],
1401
+ );
1402
+
1403
+ /*
1404
+ * Selecting a folder is what the header trail reads from. Ids are paths, so
1405
+ * the trail is the id split on '/' — there is no second structure to keep in
1406
+ * step with the tree.
1407
+ */
1408
+ const selectFolder = useCallback(
1409
+ (id: string) => {
1410
+ setSelectedFolderId(id);
1411
+ // Selecting a folder you cannot see into is a dead end, so open it.
1412
+ setExpanded(id, true);
1413
+ },
1414
+ [setExpanded],
1415
+ );
1416
+
1417
+ /*
1418
+ * The header trail, root first. Ids are paths, so each crumb's target is
1419
+ * just the prefix up to that segment — there is no second structure to keep
1420
+ * in step with the tree.
1421
+ */
1422
+ const crumbs = useMemo((): Crumb[] => {
1423
+ const segments =
1424
+ selectedFolderId == null ? [] : selectedFolderId.split('/');
1425
+ return [
1426
+ {label: ROOT_NAME, id: null},
1427
+ ...segments.map((segment, index) => ({
1428
+ label: segment,
1429
+ id: segments.slice(0, index + 1).join('/'),
1430
+ })),
1431
+ ];
1432
+ }, [selectedFolderId]);
1433
+
1434
+ /*
1435
+ * Keep the first crumb and the last two; everything between them goes to
1436
+ * the menu. The first is the repo, the last is where you are, and the one
1437
+ * before it is the usual "up one level" target — those three carry almost
1438
+ * all the navigation, so the middle is what can afford to fold away.
1439
+ */
1440
+ const hiddenCrumbs = useMemo(
1441
+ () =>
1442
+ crumbs.length > MAX_INLINE_CRUMBS
1443
+ ? crumbs.slice(1, crumbs.length - 2)
1444
+ : [],
1445
+ [crumbs],
1446
+ );
1447
+
1448
+ const collapsedCrumbs = useMemo(
1449
+ (): Array<Crumb | typeof ELLIPSIS> =>
1450
+ hiddenCrumbs.length === 0
1451
+ ? crumbs
1452
+ : [crumbs[0], ELLIPSIS, ...crumbs.slice(crumbs.length - 2)],
1453
+ [crumbs, hiddenCrumbs],
1454
+ );
1455
+
1456
+ const columns = useMemo(() => buildColumns(selectFolder), [selectFolder]);
1457
+
1458
+ const readme = useMemo(() => readmeFor(selectedFolderId), [selectedFolderId]);
1459
+
1460
+ // Focus follows `focusedId`, whether it moved by arrow key or by click, so
1461
+ // there is one path to keep the roving tabindex and the DOM in agreement.
1462
+ const shouldFocusRef = useRef(false);
1463
+ const moveFocus = useCallback((id: string) => {
1464
+ shouldFocusRef.current = true;
1465
+ setFocusedId(id);
1466
+ }, []);
1467
+
1468
+ useEffect(() => {
1469
+ if (!shouldFocusRef.current || focusedId == null) {
1470
+ return;
1471
+ }
1472
+ shouldFocusRef.current = false;
1473
+ bodyRef.current
1474
+ ?.querySelector<HTMLElement>(`tr[data-row-id="${CSS.escape(focusedId)}"]`)
1475
+ ?.focus();
1476
+ }, [focusedId, visibleData]);
1477
+
1478
+ const tree = useTableTreeData({...treeConfig, hasExpandAllControl: true});
1479
+ const sort = useTableSortable<RepoEntry>(sortable.sortConfig);
1480
+ const resize = useTableColumnResize<RepoEntry>({
1481
+ columnWidths,
1482
+ onColumnResizeEnd: updates =>
1483
+ setColumnWidths(previous => ({...previous, ...updates})),
1484
+ columns: columns as TableColumn<Record<string, unknown>>[],
1485
+ /*
1486
+ * No global `minWidth` on purpose: it takes precedence over every column's
1487
+ * own floor, so setting one here would flatten Name's 240px down to a
1488
+ * single shared number. Each column carries its own instead — the fixed
1489
+ * ones floor at their declared width, Name at the minimum that still fits
1490
+ * an indented filename.
1491
+ */
1492
+ });
1493
+ const keyboard = useTreeKeyboardNav({
1494
+ rows: visibleData,
1495
+ focusedId,
1496
+ onFocusedIdChange: moveFocus,
1497
+ expandedIds,
1498
+ onExpand: expandRow,
1499
+ onCollapse: collapseRow,
1500
+ selectedId: selectedFolderId,
1501
+ });
1502
+
1503
+ const matchCount = useMemo(
1504
+ () => (isSearching ? countMatches(ROLLED_UP, needle) : 0),
1505
+ [isSearching, needle],
1506
+ );
1507
+
1508
+ return (
1509
+ <Layout
1510
+ /*
1511
+ * `fill` — the default — pins the layout to the viewport and scrolls the
1512
+ * content area internally, which would trap the README below the fold
1513
+ * with no way to reach it. `auto` lets the page itself scroll.
1514
+ */
1515
+ height="auto"
1516
+ /*
1517
+ * Past this the commit column strands its date miles from its message.
1518
+ * Layout centres each slot inside the cap and leaves dividers
1519
+ * full-bleed, so the page keeps its edge-to-edge rules on a wide screen.
1520
+ */
1521
+ contentWidth={1440}
1522
+ end={
1523
+ !isSidebarInline ? undefined : (
1524
+ <LayoutPanel
1525
+ width={296}
1526
+ padding={4}
1527
+ role="complementary"
1528
+ label="About this repository"
1529
+ /*
1530
+ * One scroller for the page, as on the reference: the sidebar
1531
+ * travels with the README rather than holding its own scrollbar
1532
+ * next to the page's.
1533
+ */
1534
+ isScrollable={false}>
1535
+ <AboutPanel />
1536
+ </LayoutPanel>
1537
+ )
1538
+ }
1539
+ header={
1540
+ <Toolbar
1541
+ label="Repository"
1542
+ size="sm"
1543
+ dividers={['bottom']}
1544
+ xstyle={styles.headerContainer}
1545
+ startContent={
1546
+ <HStack gap={2} vAlign="center">
1547
+ {/*
1548
+ The repo name is the first crumb rather than a heading sitting
1549
+ beside the trail. As a heading it was 17px against the
1550
+ segments' 14px, so selecting a folder put two type sizes on one
1551
+ line; as a crumb it cannot drift from them. The page still owes
1552
+ the outline an h1, which no longer has anything to attach to,
1553
+ so it is supplied for assistive tech only.
1554
+ */}
1555
+ <VisuallyHidden>
1556
+ <Heading level={1}>{ROOT_NAME}</Heading>
1557
+ </VisuallyHidden>
1558
+ <Breadcrumbs label="Folder path">
1559
+ {/*
1560
+ Breadcrumbs wraps rather than truncates when it runs out of
1561
+ room, which in a single-row toolbar pushes the whole header
1562
+ taller. Past MAX_INLINE_CRUMBS the middle folds into a menu
1563
+ on a "…" item, so the trail keeps its height and the ends —
1564
+ the repo you are in and the folder you are looking at — stay
1565
+ visible, which are the two you navigate from.
1566
+ */}
1567
+ {collapsedCrumbs.map((crumb, index) =>
1568
+ crumb === ELLIPSIS ? (
1569
+ <BreadcrumbItem
1570
+ key="ellipsis"
1571
+ menu={hiddenCrumbs.map(hidden => ({
1572
+ label: hidden.label,
1573
+ onClick: () => setSelectedFolderId(hidden.id),
1574
+ }))}>
1575
+ &hellip;
1576
+ </BreadcrumbItem>
1577
+ ) : (
1578
+ <BreadcrumbItem
1579
+ key={crumb.id ?? ROOT_NAME}
1580
+ isCurrent={index === collapsedCrumbs.length - 1}
1581
+ onClick={
1582
+ index === collapsedCrumbs.length - 1
1583
+ ? undefined
1584
+ : () => setSelectedFolderId(crumb.id)
1585
+ }>
1586
+ {crumb.label}
1587
+ </BreadcrumbItem>
1588
+ ),
1589
+ )}
1590
+ </Breadcrumbs>
1591
+ {/*
1592
+ Badge has no outline variant, so GitHub's grey-ringed pill is
1593
+ a filled neutral one here. It is also read-only by contract,
1594
+ which suits a status word that is not a control.
1595
+ */}
1596
+ <Badge label="Public" variant="neutral" />
1597
+ </HStack>
1598
+ }
1599
+ ref={headerRef}
1600
+ endContent={
1601
+ /*
1602
+ * GitHub's three social counts. The leading slot on Button is
1603
+ * `icon`, not `startContent`, and `endContent` is documented to
1604
+ * take a Badge — which is exactly the attached-count shape.
1605
+ * Narrow drops the labels rather than wrapping the row.
1606
+ */
1607
+ <>
1608
+ {REPO_STATS.map(stat =>
1609
+ isNarrowHeader ? (
1610
+ <IconButton
1611
+ key={stat.label}
1612
+ variant="secondary"
1613
+ size="sm"
1614
+ icon={<Icon icon={stat.icon} size="sm" />}
1615
+ label={`${stat.label}: ${stat.value}`}
1616
+ onClick={() => {}}
1617
+ />
1618
+ ) : (
1619
+ <Button
1620
+ key={stat.label}
1621
+ size="sm"
1622
+ label={STAT_ACTION[stat.label]}
1623
+ icon={<Icon icon={stat.icon} size="sm" />}
1624
+ endContent={<Badge label={stat.value} />}
1625
+ />
1626
+ ),
1627
+ )}
1628
+ </>
1629
+ }
1630
+ />
1631
+ }
1632
+ content={
1633
+ <LayoutContent padding={4}>
1634
+ {data.length === 0 ? (
1635
+ <EmptyState
1636
+ title="No matching files"
1637
+ description="No file or folder in this project matches your search."
1638
+ actions={
1639
+ <Button
1640
+ label="Clear search"
1641
+ variant="secondary"
1642
+ onClick={() => setQuery('')}
1643
+ />
1644
+ }
1645
+ />
1646
+ ) : (
1647
+ <VStack gap={6} ref={bodyRef}>
1648
+ <BranchBar
1649
+ query={query}
1650
+ onQueryChange={setQuery}
1651
+ searchRef={searchRef}
1652
+ isSearching={isSearching}
1653
+ matchCount={matchCount}
1654
+ isNarrow={isNarrowBody}
1655
+ isSearchOpen={isSearchOpen}
1656
+ onSearchOpen={() => setIsSearchOpen(true)}
1657
+ />
1658
+ {/*
1659
+ The file list reads as one bounded object, the way GitHub
1660
+ boxes it. `padding={0}` matters twice: it lets rows reach the
1661
+ border so the zebra stripes are full-bleed inside the card,
1662
+ and it zeroes the container padding variables that Table's own
1663
+ edge-bleed subtracts from its margins.
1664
+ */}
1665
+ <Card padding={0}>
1666
+ <Table<RepoEntry>
1667
+ data={visibleData}
1668
+ columns={columns}
1669
+ idKey="id"
1670
+ density="compact"
1671
+ // Rows are separated by tint, not by rule.
1672
+ dividers="none"
1673
+ isStriped
1674
+ // Without this a narrowed column wraps mid-word and the row
1675
+ // heights go ragged. It does not reach the tree column,
1676
+ // which wraps by design — see the note at the top.
1677
+ textOverflow="truncate"
1678
+ plugins={{tree, sort, resize, keyboard}}
1679
+ />
1680
+ </Card>
1681
+ {readme != null && (
1682
+ <ReadmePanel
1683
+ markdown={readme}
1684
+ hasRepoTabs={selectedFolderId == null}
1685
+ />
1686
+ )}
1687
+ {/*
1688
+ Same panel, different slot: once the layout drops the sidebar
1689
+ the metadata is not discarded, it just stacks under the file
1690
+ list and its README the way it does on a phone.
1691
+ */}
1692
+ {!isSidebarInline && (
1693
+ <>
1694
+ <Divider />
1695
+ <AboutPanel />
1696
+ </>
1697
+ )}
1698
+ </VStack>
1699
+ )}
1700
+ </LayoutContent>
1701
+ }
1702
+ />
1703
+ );
1704
+ }