@skillcdn/console 0.4.3 → 0.4.5

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 (268) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +255 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +20 -5
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +98 -26
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +39 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +32 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +14 -2
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +49 -18
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -3
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +24 -18
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +34 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/my-decisions.d.ts +20 -3
  55. package/dist/components/my-decisions.d.ts.map +1 -1
  56. package/dist/components/my-decisions.js +14 -1
  57. package/dist/components/my-decisions.js.map +1 -1
  58. package/dist/components/pack-connect.d.ts.map +1 -1
  59. package/dist/components/pack-connect.js +5 -4
  60. package/dist/components/pack-connect.js.map +1 -1
  61. package/dist/components/people.d.ts +45 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +125 -4
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/picker.d.ts +8 -0
  66. package/dist/components/picker.d.ts.map +1 -1
  67. package/dist/components/picker.js +2 -2
  68. package/dist/components/picker.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -13
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +75 -21
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts +5 -1
  74. package/dist/components/plugin-settings.d.ts.map +1 -1
  75. package/dist/components/plugin-settings.js +283 -45
  76. package/dist/components/plugin-settings.js.map +1 -1
  77. package/dist/components/projects.d.ts +0 -8
  78. package/dist/components/projects.d.ts.map +1 -1
  79. package/dist/components/projects.js +4 -14
  80. package/dist/components/projects.js.map +1 -1
  81. package/dist/components/searches.d.ts +11 -0
  82. package/dist/components/searches.d.ts.map +1 -0
  83. package/dist/components/searches.js +30 -0
  84. package/dist/components/searches.js.map +1 -0
  85. package/dist/components/task-form.d.ts.map +1 -1
  86. package/dist/components/task-form.js +6 -5
  87. package/dist/components/task-form.js.map +1 -1
  88. package/dist/components/task-view.d.ts.map +1 -1
  89. package/dist/components/task-view.js +2 -1
  90. package/dist/components/task-view.js.map +1 -1
  91. package/dist/components/ui.d.ts +16 -0
  92. package/dist/components/ui.d.ts.map +1 -1
  93. package/dist/components/ui.js +15 -2
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/connect-page.d.ts.map +1 -1
  96. package/dist/connect-page.js +5 -6
  97. package/dist/connect-page.js.map +1 -1
  98. package/dist/console.css +1123 -258
  99. package/dist/console.d.ts +12 -4
  100. package/dist/console.d.ts.map +1 -1
  101. package/dist/console.js +123 -27
  102. package/dist/console.js.map +1 -1
  103. package/dist/data.d.ts +18 -3
  104. package/dist/data.d.ts.map +1 -1
  105. package/dist/data.js +41 -1
  106. package/dist/data.js.map +1 -1
  107. package/dist/docs-page.d.ts.map +1 -1
  108. package/dist/docs-page.js +6 -5
  109. package/dist/docs-page.js.map +1 -1
  110. package/dist/home-tiles.d.ts +92 -0
  111. package/dist/home-tiles.d.ts.map +1 -0
  112. package/dist/home-tiles.js +294 -0
  113. package/dist/home-tiles.js.map +1 -0
  114. package/dist/i18n/en.d.ts +264 -1
  115. package/dist/i18n/en.d.ts.map +1 -1
  116. package/dist/i18n/en.js +260 -1
  117. package/dist/i18n/en.js.map +1 -1
  118. package/dist/i18n/index.d.ts +4 -2
  119. package/dist/i18n/index.d.ts.map +1 -1
  120. package/dist/i18n/index.js +10 -11
  121. package/dist/i18n/index.js.map +1 -1
  122. package/dist/i18n/ko.d.ts.map +1 -1
  123. package/dist/i18n/ko.js +255 -4
  124. package/dist/i18n/ko.js.map +1 -1
  125. package/dist/index.d.ts +7 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +7 -5
  128. package/dist/index.js.map +1 -1
  129. package/dist/limits.d.ts +12 -0
  130. package/dist/limits.d.ts.map +1 -1
  131. package/dist/limits.js +12 -0
  132. package/dist/limits.js.map +1 -1
  133. package/dist/plugin/args.d.ts +6 -1
  134. package/dist/plugin/args.d.ts.map +1 -1
  135. package/dist/plugin/args.js +16 -11
  136. package/dist/plugin/args.js.map +1 -1
  137. package/dist/plugin/bridge.d.ts +63 -3
  138. package/dist/plugin/bridge.d.ts.map +1 -1
  139. package/dist/plugin/bridge.js +54 -0
  140. package/dist/plugin/bridge.js.map +1 -1
  141. package/dist/plugin/capabilities.d.ts +7 -1
  142. package/dist/plugin/capabilities.d.ts.map +1 -1
  143. package/dist/plugin/capabilities.js +24 -1
  144. package/dist/plugin/capabilities.js.map +1 -1
  145. package/dist/plugin/context.d.ts +174 -12
  146. package/dist/plugin/context.d.ts.map +1 -1
  147. package/dist/plugin/define.d.ts +14 -1
  148. package/dist/plugin/define.d.ts.map +1 -1
  149. package/dist/plugin/define.js +18 -2
  150. package/dist/plugin/define.js.map +1 -1
  151. package/dist/plugin/limits.d.ts +9 -2
  152. package/dist/plugin/limits.d.ts.map +1 -1
  153. package/dist/plugin/limits.js +9 -2
  154. package/dist/plugin/limits.js.map +1 -1
  155. package/dist/plugin/manifest.d.ts +98 -6
  156. package/dist/plugin/manifest.d.ts.map +1 -1
  157. package/dist/plugin/manifest.js +171 -10
  158. package/dist/plugin/manifest.js.map +1 -1
  159. package/dist/plugin/rest-schemas.d.ts +120 -0
  160. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  161. package/dist/plugin/rest-schemas.js +78 -5
  162. package/dist/plugin/rest-schemas.js.map +1 -1
  163. package/dist/plugin/sandbox.d.ts +4 -3
  164. package/dist/plugin/sandbox.d.ts.map +1 -1
  165. package/dist/plugin/sandbox.js +33 -6
  166. package/dist/plugin/sandbox.js.map +1 -1
  167. package/dist/plugin/semver.d.ts +26 -0
  168. package/dist/plugin/semver.d.ts.map +1 -0
  169. package/dist/plugin/semver.js +76 -0
  170. package/dist/plugin/semver.js.map +1 -0
  171. package/dist/plugin/testing.d.ts +15 -1
  172. package/dist/plugin/testing.d.ts.map +1 -1
  173. package/dist/plugin/testing.js +42 -5
  174. package/dist/plugin/testing.js.map +1 -1
  175. package/dist/plugin/web.d.ts +16 -1
  176. package/dist/plugin/web.d.ts.map +1 -1
  177. package/dist/plugin/web.js +44 -1
  178. package/dist/plugin/web.js.map +1 -1
  179. package/dist/plugin.d.ts +3 -2
  180. package/dist/plugin.d.ts.map +1 -1
  181. package/dist/plugin.js +3 -2
  182. package/dist/plugin.js.map +1 -1
  183. package/dist/router.d.ts +19 -0
  184. package/dist/router.d.ts.map +1 -1
  185. package/dist/router.js +18 -0
  186. package/dist/router.js.map +1 -1
  187. package/dist/routes.d.ts +11 -2
  188. package/dist/routes.d.ts.map +1 -1
  189. package/dist/routes.js +14 -1
  190. package/dist/routes.js.map +1 -1
  191. package/dist/rules.d.ts +2 -2
  192. package/dist/rules.js +3 -3
  193. package/dist/schemas.d.ts +864 -2
  194. package/dist/schemas.d.ts.map +1 -1
  195. package/dist/schemas.js +156 -9
  196. package/dist/schemas.js.map +1 -1
  197. package/dist/use-action.d.ts +8 -2
  198. package/dist/use-action.d.ts.map +1 -1
  199. package/dist/use-action.js +13 -6
  200. package/dist/use-action.js.map +1 -1
  201. package/dist/vocabulary.d.ts +3 -1
  202. package/dist/vocabulary.d.ts.map +1 -1
  203. package/dist/vocabulary.js +9 -0
  204. package/dist/vocabulary.js.map +1 -1
  205. package/docs/specs/cli.md +10 -6
  206. package/docs/specs/plugins.md +83 -62
  207. package/docs/specs/rest.md +34 -16
  208. package/package.json +1 -1
  209. package/skills/building-a-plugin/SKILL.md +15 -12
  210. package/skills/planning-the-board/references/setting-up.md +2 -1
  211. package/skills/working-the-board/SKILL.md +3 -3
  212. package/src/api.ts +23 -0
  213. package/src/cli/cli.ts +323 -30
  214. package/src/cli/format.ts +122 -26
  215. package/src/cli/help.ts +43 -14
  216. package/src/cli/plugin-check.ts +41 -2
  217. package/src/cli/plugin-cli.ts +10 -1
  218. package/src/cli/plugin-init.ts +8 -0
  219. package/src/client.ts +78 -0
  220. package/src/compat.ts +1 -1
  221. package/src/components/board.tsx +86 -45
  222. package/src/components/decision-form.tsx +73 -36
  223. package/src/components/decision-list.tsx +45 -13
  224. package/src/components/event-feed.tsx +40 -7
  225. package/src/components/goals.tsx +4 -1
  226. package/src/components/home.tsx +615 -0
  227. package/src/components/my-decisions.tsx +50 -5
  228. package/src/components/pack-connect.tsx +5 -4
  229. package/src/components/people.tsx +500 -61
  230. package/src/components/picker.tsx +11 -2
  231. package/src/components/plugin-frame.tsx +85 -63
  232. package/src/components/plugin-settings.tsx +735 -185
  233. package/src/components/projects.tsx +14 -69
  234. package/src/components/searches.ts +47 -0
  235. package/src/components/task-form.tsx +23 -28
  236. package/src/components/task-view.tsx +7 -0
  237. package/src/components/ui.tsx +42 -2
  238. package/src/connect-page.tsx +5 -6
  239. package/src/console.tsx +303 -64
  240. package/src/data.ts +62 -3
  241. package/src/docs-page.tsx +6 -5
  242. package/src/home-tiles.ts +438 -0
  243. package/src/i18n/en.ts +274 -0
  244. package/src/i18n/index.ts +17 -14
  245. package/src/i18n/ko.ts +267 -4
  246. package/src/index.ts +18 -5
  247. package/src/limits.ts +12 -0
  248. package/src/plugin/args.ts +16 -11
  249. package/src/plugin/bridge.ts +122 -1
  250. package/src/plugin/capabilities.ts +28 -1
  251. package/src/plugin/context.ts +186 -9
  252. package/src/plugin/define.ts +38 -1
  253. package/src/plugin/limits.ts +9 -2
  254. package/src/plugin/manifest.ts +228 -13
  255. package/src/plugin/rest-schemas.ts +89 -6
  256. package/src/plugin/sandbox.ts +36 -8
  257. package/src/plugin/semver.ts +89 -0
  258. package/src/plugin/testing.ts +63 -6
  259. package/src/plugin/web.ts +66 -0
  260. package/src/plugin.ts +19 -0
  261. package/src/router.ts +25 -0
  262. package/src/routes.ts +17 -2
  263. package/src/rules.ts +3 -3
  264. package/src/schemas.ts +197 -10
  265. package/src/styles/console.css +1123 -258
  266. package/src/use-action.ts +16 -7
  267. package/src/vocabulary.ts +10 -0
  268. package/template/plugin/console-plugin.json +3 -1
package/src/data.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  type RestProjectInput,
45
45
  type RestProjectPatch,
46
46
  type RestProjectRules,
47
+ type RestReachedDecision,
47
48
  type RestRules,
48
49
  type RestRulesInput,
49
50
  type RestRulesPlugin,
@@ -53,6 +54,9 @@ import {
53
54
  type RestTaskInput,
54
55
  type RestTaskPatch,
55
56
  type RestTasks,
57
+ type RestTeam,
58
+ type RestTeamInput,
59
+ type RestTeamPatch,
56
60
  type RestToken,
57
61
  type RestTokenCreated,
58
62
  type RestTokenInput,
@@ -148,6 +152,10 @@ export interface ConsoleActions {
148
152
  },
149
153
  signal?: AbortSignal,
150
154
  ): Promise<RestMyDecisions>;
155
+ /** One decision wherever it is (ADR-0068), with its project and whether the person may answer it, read when the page asks. */
156
+ reachDecision(id: string, signal?: AbortSignal): Promise<RestReachedDecision>;
157
+ /** Answers a decision wherever it is, as whoever it reaches (ADR-0068). */
158
+ answerReachedDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
151
159
  /** The categories tasks are sorted into: kept by an administrator. */
152
160
  createCategory(input: RestCategoryInput): Promise<RestCategory>;
153
161
  updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
@@ -173,7 +181,14 @@ export interface ConsoleActions {
173
181
  createListing(input: RestListingInput): Promise<RestListing>;
174
182
  /** Takes a listing away that nobody signed in with; for an administrator. */
175
183
  removeListing(id: string): Promise<void>;
176
- /** Keeps the person's own choice of language (ADR-0012). */
184
+ /** The teams (ADR-0073): made, renamed and removed, a person put in or taken out; for an administrator. */
185
+ createTeam(input: RestTeamInput): Promise<RestTeam>;
186
+ /** A team is called by its key (ADR-0074), which never changes. */
187
+ renameTeam(key: string, patch: RestTeamPatch): Promise<RestTeam>;
188
+ removeTeam(key: string): Promise<void>;
189
+ addTeamMember(key: string, personId: string): Promise<RestTeam>;
190
+ removeTeamMember(key: string, personId: string): Promise<RestTeam>;
191
+ /** Keeps the person's language (ADR-0065) or their front page. */
177
192
  updateMe(patch: RestMePatch): Promise<RestMe>;
178
193
  /** Changes the workspace's settings (ADR-0035); for an administrator. `me` says them from then on. */
179
194
  updateWorkspace(patch: RestWorkspacePatch): Promise<RestWorkspace>;
@@ -214,8 +229,10 @@ export interface ConsoleActions {
214
229
  export interface ConsoleData {
215
230
  /** Who is signed in and what the board is called; `undefined` until the server has answered. */
216
231
  readonly me: RestMe | undefined;
217
- /** What went wrong asking who is signed in, for a person. */
232
+ /** What went wrong asking who is signed in, for a person, in English. */
218
233
  readonly error: string | undefined;
234
+ /** The failure itself, for a page to say in the person's language (`errorWords`). */
235
+ readonly failure?: unknown;
219
236
  /** True once the workspace has been loaded for the first time: the projects, the people, the tokens. */
220
237
  readonly loaded: boolean;
221
238
  /** The projects the person may see, by name. */
@@ -224,6 +241,8 @@ export interface ConsoleData {
224
241
  readonly people: readonly RestPerson[];
225
242
  /** The logins let in that nobody has signed in with yet. */
226
243
  readonly listed: readonly RestListing[];
244
+ /** The teams of the workspace (ADR-0073), by name, each with the members the person may see. */
245
+ readonly teams: readonly RestTeam[];
227
246
  /** The organization at the git host that says who is in, where the deployment names one. */
228
247
  readonly organization: RestOrganization | null;
229
248
  /** The categories tasks are sorted into, by name. */
@@ -357,10 +376,12 @@ const wordsOf = (failure: unknown, fallback: string): string =>
357
376
  export function useConsoleData(client: ConsoleClient, projectKey: string | undefined): ConsoleData {
358
377
  const [me, setMe] = useState<RestMe | undefined>(undefined);
359
378
  const [error, setError] = useState<string | undefined>(undefined);
379
+ const [failure, setFailure] = useState<unknown>(undefined);
360
380
  const [loaded, setLoaded] = useState(false);
361
381
  const [projects, setProjects] = useState<readonly RestProject[]>([]);
362
382
  const [people, setPeople] = useState<readonly RestPerson[]>([]);
363
383
  const [listed, setListed] = useState<readonly RestListing[]>([]);
384
+ const [teams, setTeams] = useState<readonly RestTeam[]>([]);
364
385
  const [organization, setOrganization] = useState<RestOrganization | null>(null);
365
386
  const [categories, setCategories] = useState<readonly RestCategory[]>([]);
366
387
  const [plugins, setPlugins] = useState<readonly RestPlugin[]>([]);
@@ -400,12 +421,14 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
400
421
  /** Loads the workspace's lists again. What fails stays as it was. */
401
422
  const refreshWorkspace = useCallback(
402
423
  async (signal?: AbortSignal) => {
403
- const [nextProjects, nextPeople, nextCategories, nextPlugins] = await Promise.all([
424
+ const [nextProjects, nextPeople, nextCategories, nextPlugins, nextTeams] = await Promise.all([
404
425
  client.projects(signal),
405
426
  client.people(signal),
406
427
  client.categories(signal),
407
428
  // A console without plugins answers none; one older than them answers nothing, which is none too.
408
429
  client.plugins(signal).catch(() => ({ items: [], sql: false })),
430
+ // A console older than teams (ADR-0073) answers nothing, which is no team.
431
+ client.teams(signal).catch(() => ({ items: [] })),
409
432
  ]);
410
433
  if (signal?.aborted === true) {
411
434
  return;
@@ -413,6 +436,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
413
436
  setProjects(nextProjects.items);
414
437
  setPeople(nextPeople.items);
415
438
  setListed(nextPeople.listed);
439
+ setTeams(nextTeams.items);
416
440
  setOrganization(nextPeople.organization);
417
441
  setCategories(nextCategories.items);
418
442
  setPlugins(nextPlugins.items);
@@ -461,12 +485,14 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
461
485
  const controller = new AbortController();
462
486
  const { signal } = controller;
463
487
  setError(undefined);
488
+ setFailure(undefined);
464
489
  const run = async () => {
465
490
  let answer: RestMe;
466
491
  try {
467
492
  answer = await client.me(signal);
468
493
  } catch (failure) {
469
494
  if (!signal.aborted) {
495
+ setFailure(failure);
470
496
  setError(wordsOf(failure, "The server could not be reached."));
471
497
  }
472
498
  return;
@@ -492,6 +518,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
492
518
  setLoaded(true);
493
519
  } catch (failure) {
494
520
  if (!signal.aborted) {
521
+ setFailure(failure);
495
522
  setError(wordsOf(failure, "The workspace could not be loaded."));
496
523
  }
497
524
  }
@@ -738,6 +765,12 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
738
765
  },
739
766
  work: (signal) => client.work(signal),
740
767
  myDecisions: (filter, signal) => client.myDecisions(filter, signal),
768
+ reachDecision: (id, signal) => client.decision(id, signal),
769
+ async answerReachedDecision(id, input) {
770
+ const decision = await client.answerDecision(id, input);
771
+ await refreshMe().catch(() => undefined);
772
+ return decision;
773
+ },
741
774
  async createCategory(input) {
742
775
  const category = await client.createCategory(input);
743
776
  setCategories((await client.categories()).items);
@@ -775,6 +808,30 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
775
808
  await client.removeListing(id);
776
809
  await refreshWorkspace();
777
810
  },
811
+ async createTeam(input) {
812
+ const team = await client.createTeam(input);
813
+ await refreshWorkspace();
814
+ return team;
815
+ },
816
+ async renameTeam(key, patch) {
817
+ const team = await client.renameTeam(key, patch);
818
+ await refreshWorkspace();
819
+ return team;
820
+ },
821
+ async removeTeam(key) {
822
+ await client.removeTeam(key);
823
+ await refreshWorkspace();
824
+ },
825
+ async addTeamMember(key, personId) {
826
+ const team = await client.addTeamMember(key, personId);
827
+ await refreshWorkspace();
828
+ return team;
829
+ },
830
+ async removeTeamMember(key, personId) {
831
+ const team = await client.removeTeamMember(key, personId);
832
+ await refreshWorkspace();
833
+ return team;
834
+ },
778
835
  async updateMe(patch) {
779
836
  const answer = await client.updateMe(patch);
780
837
  setMe(answer);
@@ -862,10 +919,12 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
862
919
  return {
863
920
  me,
864
921
  error,
922
+ failure,
865
923
  loaded,
866
924
  projects,
867
925
  people,
868
926
  listed,
927
+ teams,
869
928
  organization,
870
929
  categories,
871
930
  plugins,
package/src/docs-page.tsx CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  taskHref,
31
31
  } from "./router.js";
32
32
  import { projectPath } from "./routes.js";
33
- import { useAction } from "./use-action.js";
33
+ import { errorWords, useAction } from "./use-action.js";
34
34
 
35
35
  // The Docs page of a project (ADR-0009): the folder or the page the path names, read on its
36
36
  // own since the tree may be large, and read again whenever the project's feed grows; and the
@@ -51,7 +51,8 @@ export interface DocsPageProps {
51
51
  type Found = RestDocument | null | undefined;
52
52
 
53
53
  export function DocsPage(props: DocsPageProps) {
54
- const words = useWords().docs;
54
+ const pack = useWords();
55
+ const words = pack.docs;
55
56
  const { project, path, view, data, navigation } = props;
56
57
  const [listing, setListing] = useState<RestDocuments | undefined>(undefined);
57
58
  const [document, setDocument] = useState<Found>(undefined);
@@ -105,7 +106,7 @@ export function DocsPage(props: DocsPageProps) {
105
106
  };
106
107
  load().catch((failure: unknown) => {
107
108
  if (!signal.aborted) {
108
- setProblem(failure instanceof ApiError ? failure.message : words.couldNotLoad);
109
+ setProblem(errorWords(failure, words.couldNotLoad, pack));
109
110
  }
110
111
  });
111
112
  return () => controller.abort();
@@ -145,11 +146,11 @@ export function DocsPage(props: DocsPageProps) {
145
146
  })
146
147
  .catch((failure: unknown) => {
147
148
  if (!controller.signal.aborted) {
148
- setProblem(failure instanceof ApiError ? failure.message : words.searchFailed);
149
+ setProblem(errorWords(failure, words.searchFailed, pack));
149
150
  }
150
151
  });
151
152
  return () => controller.abort();
152
- }, [actions, query, words]);
153
+ }, [actions, pack, query, words]);
153
154
 
154
155
  const toDoc = (target: string) => docHref(key, target);
155
156
  const toFolder = (folder: string) => docHref(key, folder);
@@ -0,0 +1,438 @@
1
+ import { MAX_HOME_ROW, MAX_HOME_TILES } from "./limits.js";
2
+ import type { PluginWidgetSizeName } from "./plugin/bridge.js";
3
+ import { PLUGIN_WIDGET_SIZE_CELLS, widgetSizesOf } from "./plugin/manifest.js";
4
+ import type {
5
+ RestHome,
6
+ RestHomeTile,
7
+ RestPlugin,
8
+ RestPluginWidget,
9
+ RestProject,
10
+ } from "./schemas.js";
11
+
12
+ // The front page as a person arranges it (ADR-0061): one grid of square cells, on which the
13
+ // projects and the plugins' widgets are tiles alike, each at a place of its own. What there is
14
+ // comes from what the person may see; where each stands comes from what they kept, one layout
15
+ // for each width of the grid. What is new takes the first room, what they took off stays off,
16
+ // and what they may no longer see waits out of sight for its place. Pure functions, so that the
17
+ // page and the tests read the same arrangement.
18
+
19
+ /** The grid's columns at each width: wide, a laptop's, a tablet's, a phone's. */
20
+ export const HOME_COLUMNS = [12, 8, 6, 4] as const;
21
+ export type HomeColumns = (typeof HOME_COLUMNS)[number];
22
+
23
+ /** The width in pixels from which the grid has so many columns, so that a cell stays of a size to read. */
24
+ const COLUMNS_FROM: readonly (readonly [number, HomeColumns])[] = [
25
+ [1520, 12],
26
+ [900, 8],
27
+ [560, 6],
28
+ ];
29
+
30
+ /** The columns of the grid at a width in pixels. */
31
+ export function columnsFor(width: number): HomeColumns {
32
+ return COLUMNS_FROM.find(([from]) => width >= from)?.[1] ?? 4;
33
+ }
34
+
35
+ export type HomeTileSize = RestHomeTile["size"];
36
+
37
+ /** The cells a tile takes at each size: an icon one, the widgets' family as the phones have it. */
38
+ export const HOME_TILE_CELLS: Readonly<
39
+ Record<HomeTileSize, { readonly columns: number; readonly rows: number }>
40
+ > = { icon: { columns: 1, rows: 1 }, ...PLUGIN_WIDGET_SIZE_CELLS };
41
+
42
+ /** A project on the page: an icon, or a card with what it is. */
43
+ export interface HomeProjectTile {
44
+ readonly kind: "project";
45
+ readonly id: string;
46
+ readonly project: RestProject;
47
+ readonly sizes: readonly HomeTileSize[];
48
+ readonly defaultSize: HomeTileSize;
49
+ }
50
+
51
+ /** A plugin's widget on the page. */
52
+ export interface HomeWidgetTile {
53
+ readonly kind: "widget";
54
+ readonly id: string;
55
+ readonly plugin: RestPlugin;
56
+ readonly widget: RestPluginWidget;
57
+ readonly sizes: readonly PluginWidgetSizeName[];
58
+ readonly defaultSize: PluginWidgetSizeName;
59
+ }
60
+
61
+ export type HomeTile = HomeProjectTile | HomeWidgetTile;
62
+
63
+ /** A tile where it stands. */
64
+ export interface PlacedTile {
65
+ readonly tile: HomeTile;
66
+ readonly x: number;
67
+ readonly y: number;
68
+ readonly size: HomeTileSize;
69
+ }
70
+
71
+ const PROJECT_SIZES: readonly HomeTileSize[] = ["icon", "small"];
72
+
73
+ /**
74
+ * Every tile the person may see: the widgets of the plugins that are on, which they hold a role
75
+ * for, and the projects, those a manifest places at the top first and those at the bottom last.
76
+ * An administrator reads every plugin installed, and one that is off has no frame to show.
77
+ */
78
+ export function homeTiles(
79
+ plugins: readonly RestPlugin[],
80
+ projects: readonly RestProject[],
81
+ ): HomeTile[] {
82
+ const widgets = plugins
83
+ .filter((plugin) => plugin.state === "enabled")
84
+ .flatMap((plugin) =>
85
+ plugin.widgets.map((widget): HomeWidgetTile => {
86
+ const { sizes, defaultSize } = widgetSizesOf(widget);
87
+ return {
88
+ kind: "widget",
89
+ id: `widget:${plugin.key}:${widget.key}`,
90
+ plugin,
91
+ widget,
92
+ sizes,
93
+ defaultSize,
94
+ };
95
+ }),
96
+ );
97
+ return [
98
+ ...widgets.filter((tile) => tile.widget.place === "home:top"),
99
+ ...projects.map(
100
+ (project): HomeProjectTile => ({
101
+ kind: "project",
102
+ id: `project:${project.id}`,
103
+ project,
104
+ sizes: PROJECT_SIZES,
105
+ defaultSize: "icon",
106
+ }),
107
+ ),
108
+ ...widgets.filter((tile) => tile.widget.place !== "home:top"),
109
+ ];
110
+ }
111
+
112
+ interface Footprint {
113
+ readonly w: number;
114
+ readonly h: number;
115
+ }
116
+
117
+ interface Place {
118
+ readonly x: number;
119
+ readonly y: number;
120
+ }
121
+
122
+ /**
123
+ * The sizes a tile may have on a grid of so many columns: those that fit it. A widget always has
124
+ * one (ADR-0062); only a console older than the rule can answer one that has none, and then it
125
+ * keeps what it has, drawn as wide as the grid.
126
+ */
127
+ export function sizesAt(tile: HomeTile, columns: number): readonly HomeTileSize[] {
128
+ const fitting = (tile.sizes as readonly HomeTileSize[]).filter(
129
+ (size) => HOME_TILE_CELLS[size].columns <= columns,
130
+ );
131
+ return fitting.length > 0 ? fitting : tile.sizes;
132
+ }
133
+
134
+ /** The size a tile starts at on a grid of so many columns: its own where that fits, else the largest that does. */
135
+ export function startingSizeAt(tile: HomeTile, columns: number): HomeTileSize {
136
+ const fitting = sizesAt(tile, columns);
137
+ if (fitting.includes(tile.defaultSize)) {
138
+ return tile.defaultSize;
139
+ }
140
+ const area = (size: HomeTileSize) => HOME_TILE_CELLS[size].columns * HOME_TILE_CELLS[size].rows;
141
+ return [...fitting].sort((a, b) => area(b) - area(a))[0] ?? tile.defaultSize;
142
+ }
143
+
144
+ /** The cells a size takes on a grid of so many columns: never wider than the grid. */
145
+ export function footprintOf(size: HomeTileSize, columns: number): Footprint {
146
+ const cells = HOME_TILE_CELLS[size];
147
+ return { w: Math.min(cells.columns, columns), h: cells.rows };
148
+ }
149
+
150
+ const inReadingOrder = <T extends Place>(tiles: readonly T[]): T[] =>
151
+ [...tiles].sort((a, b) => a.y - b.y || a.x - b.x);
152
+
153
+ /** The cells taken, each as one number. */
154
+ function occupied(placed: readonly PlacedTile[], columns: number): Set<number> {
155
+ const taken = new Set<number>();
156
+ for (const tile of placed) {
157
+ occupy(taken, tile, footprintOf(tile.size, columns), columns);
158
+ }
159
+ return taken;
160
+ }
161
+
162
+ function occupy(taken: Set<number>, place: Place, shape: Footprint, columns: number): void {
163
+ for (let row = 0; row < shape.h; row += 1) {
164
+ for (let column = 0; column < shape.w; column += 1) {
165
+ taken.add((place.y + row) * columns + place.x + column);
166
+ }
167
+ }
168
+ }
169
+
170
+ function fits(
171
+ taken: ReadonlySet<number>,
172
+ place: Place,
173
+ shape: Footprint,
174
+ columns: number,
175
+ ): boolean {
176
+ if (place.x < 0 || place.y < 0 || place.x + shape.w > columns) {
177
+ return false;
178
+ }
179
+ for (let row = 0; row < shape.h; row += 1) {
180
+ for (let column = 0; column < shape.w; column += 1) {
181
+ if (taken.has((place.y + row) * columns + place.x + column)) {
182
+ return false;
183
+ }
184
+ }
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /** The first room for the shape, reading the grid from the top. Below everything there is always some. */
190
+ function firstRoom(taken: ReadonlySet<number>, shape: Footprint, columns: number): Place {
191
+ for (let y = 0; ; y += 1) {
192
+ for (let x = 0; x + shape.w <= columns; x += 1) {
193
+ if (fits(taken, { x, y }, shape, columns)) {
194
+ return { x, y };
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ /** The room for the shape nearest a place. */
201
+ function nearestRoom(
202
+ taken: ReadonlySet<number>,
203
+ shape: Footprint,
204
+ near: Place,
205
+ columns: number,
206
+ ): Place {
207
+ const bottom = Math.max(0, ...[...taken].map((cell) => Math.floor(cell / columns) + 1));
208
+ let best = firstRoom(taken, shape, columns);
209
+ let distance = (best.x - near.x) ** 2 + (best.y - near.y) ** 2;
210
+ for (let y = 0; y <= Math.max(bottom, near.y); y += 1) {
211
+ for (let x = 0; x + shape.w <= columns; x += 1) {
212
+ const away = (x - near.x) ** 2 + (y - near.y) ** 2;
213
+ if (away < distance && fits(taken, { x, y }, shape, columns)) {
214
+ best = { x, y };
215
+ distance = away;
216
+ }
217
+ }
218
+ }
219
+ return best;
220
+ }
221
+
222
+ /** What a layout starts from at a width: what was kept for it, or the same order from another. */
223
+ function keptAt(
224
+ home: RestHome | null | undefined,
225
+ columns: HomeColumns,
226
+ ): { readonly entries: readonly RestHomeTile[]; readonly placed: boolean } {
227
+ const own = home?.layouts[columns];
228
+ if (own !== undefined) {
229
+ return { entries: own, placed: true };
230
+ }
231
+ for (const other of HOME_COLUMNS) {
232
+ const entries = home?.layouts[other];
233
+ if (entries !== undefined) {
234
+ return { entries: inReadingOrder(entries), placed: false };
235
+ }
236
+ }
237
+ return { entries: [], placed: false };
238
+ }
239
+
240
+ /**
241
+ * The page as it stands: what the person kept for this width where they stand, or, kept for
242
+ * another width, the same order packed from the top; a tile they may no longer see stays out, a
243
+ * size it no longer comes in, or that is too wide for this grid, becomes the one it starts at
244
+ * here, and a tile on another's cells moves to the nearest room. What they have not seen yet takes the first room, a plugin's widgets as soon
245
+ * as it is on; what they took off stays off.
246
+ */
247
+ export function arranged(
248
+ tiles: readonly HomeTile[],
249
+ home: RestHome | null | undefined,
250
+ columns: HomeColumns,
251
+ ): PlacedTile[] {
252
+ const byId = new Map(tiles.map((tile) => [tile.id, tile]));
253
+ const hidden = new Set(home?.hidden ?? []);
254
+ const { entries, placed: keepsPlaces } = keptAt(home, columns);
255
+ const taken = new Set<number>();
256
+ const placed: PlacedTile[] = [];
257
+ const seen = new Set<string>();
258
+ const put = (tile: HomeTile, size: HomeTileSize, wanted: Place | undefined) => {
259
+ const shape = footprintOf(size, columns);
260
+ const place =
261
+ wanted === undefined
262
+ ? firstRoom(taken, shape, columns)
263
+ : fits(taken, wanted, shape, columns)
264
+ ? wanted
265
+ : nearestRoom(taken, shape, wanted, columns);
266
+ occupy(taken, place, shape, columns);
267
+ placed.push({ tile, size, ...place });
268
+ seen.add(tile.id);
269
+ };
270
+ for (const entry of entries) {
271
+ const tile = byId.get(entry.tile);
272
+ if (tile !== undefined && !hidden.has(tile.id) && !seen.has(tile.id)) {
273
+ const size = sizesAt(tile, columns).includes(entry.size)
274
+ ? entry.size
275
+ : startingSizeAt(tile, columns);
276
+ put(tile, size, keepsPlaces ? { x: entry.x, y: entry.y } : undefined);
277
+ }
278
+ }
279
+ for (const tile of tiles) {
280
+ if (!hidden.has(tile.id) && !seen.has(tile.id)) {
281
+ put(tile, startingSizeAt(tile, columns), undefined);
282
+ }
283
+ }
284
+ return placed;
285
+ }
286
+
287
+ /** How many rows the page takes. */
288
+ export function rowsOf(placed: readonly PlacedTile[], columns: number): number {
289
+ return Math.max(0, ...placed.map((tile) => tile.y + footprintOf(tile.size, columns).h));
290
+ }
291
+
292
+ const sameTile = (a: PlacedTile, b: PlacedTile): boolean => a.tile.id === b.tile.id;
293
+
294
+ const overlapping = (a: PlacedTile, b: PlacedTile, columns: number): boolean => {
295
+ const shapeA = footprintOf(a.size, columns);
296
+ const shapeB = footprintOf(b.size, columns);
297
+ return (
298
+ a.x < b.x + shapeB.w && b.x < a.x + shapeA.w && a.y < b.y + shapeB.h && b.y < a.y + shapeA.h
299
+ );
300
+ };
301
+
302
+ /** The tile where it is to stand, and those on its cells moved to the nearest room. */
303
+ function makingRoom(
304
+ others: readonly PlacedTile[],
305
+ at: PlacedTile,
306
+ columns: number,
307
+ ): readonly PlacedTile[] {
308
+ const inTheWay = others.filter((tile) => overlapping(tile, at, columns));
309
+ const staying = [...others.filter((tile) => !overlapping(tile, at, columns)), at];
310
+ const taken = occupied(staying, columns);
311
+ const moved = inReadingOrder(inTheWay).map((tile) => {
312
+ const shape = footprintOf(tile.size, columns);
313
+ const place = nearestRoom(taken, shape, tile, columns);
314
+ occupy(taken, place, shape, columns);
315
+ return { ...tile, ...place };
316
+ });
317
+ return [...staying, ...moved];
318
+ }
319
+
320
+ /**
321
+ * The page with a tile moved to a place, as a phone's home screen does it. Where the place is
322
+ * free it stands there. On a tile of its own shape, the tiles of that shape between the two
323
+ * places each move one place towards the one it left, so that a row of icons closes up. A smaller
324
+ * tile gives way to the nearest room, and a larger one stays: the tile lands in the room beside it.
325
+ */
326
+ export function moved(
327
+ placed: readonly PlacedTile[],
328
+ id: string,
329
+ x: number,
330
+ y: number,
331
+ columns: number,
332
+ ): readonly PlacedTile[] {
333
+ const moving = placed.find((tile) => tile.tile.id === id);
334
+ if (moving === undefined) {
335
+ return placed;
336
+ }
337
+ const shape = footprintOf(moving.size, columns);
338
+ const at: PlacedTile = {
339
+ ...moving,
340
+ x: Math.max(0, Math.min(x, columns - shape.w)),
341
+ y: Math.max(0, Math.min(y, MAX_HOME_ROW)),
342
+ };
343
+ const others = placed.filter((tile) => !sameTile(tile, moving));
344
+ const inTheWay = others.filter((tile) => overlapping(tile, at, columns));
345
+ const [first] = inTheWay;
346
+ if (first === undefined) {
347
+ return [...others, at];
348
+ }
349
+ const shapeOf = (tile: PlacedTile) => footprintOf(tile.size, columns);
350
+ const sameShape = (tile: PlacedTile) =>
351
+ shapeOf(tile).w === shape.w && shapeOf(tile).h === shape.h;
352
+ if (inTheWay.length === 1 && first.x === at.x && first.y === at.y && sameShape(first)) {
353
+ const peers = inReadingOrder(placed.filter(sameShape));
354
+ const places = peers.map(({ x: px, y: py }) => ({ x: px, y: py }));
355
+ const order = peers.filter((tile) => !sameTile(tile, moving));
356
+ order.splice(
357
+ peers.findIndex((tile) => sameTile(tile, first)),
358
+ 0,
359
+ moving,
360
+ );
361
+ return [
362
+ ...placed.filter((tile) => !sameShape(tile)),
363
+ ...order.map((tile, index) => ({ ...tile, ...places[index] })),
364
+ ];
365
+ }
366
+ const area = (tile: PlacedTile) => shapeOf(tile).w * shapeOf(tile).h;
367
+ if (inTheWay.some((tile) => area(tile) > area(moving))) {
368
+ return [...others, { ...at, ...nearestRoom(occupied(others, columns), shape, at, columns) }];
369
+ }
370
+ return makingRoom(others, at, columns);
371
+ }
372
+
373
+ /** How far apart in time the tiles in the way move, by the cells between them, in milliseconds. */
374
+ const WAIT_PER_CELL_MS = 22;
375
+ const LONGEST_WAIT_MS = 130;
376
+
377
+ /**
378
+ * How long a tile waits before it gives way to the tile being put down at `from`: the further off
379
+ * the later, so that the push runs outward from there, as water does.
380
+ */
381
+ export function waitToGiveWay(tile: Place, from: Place): number {
382
+ const cells = Math.hypot(tile.x - from.x, tile.y - from.y);
383
+ return Math.min(Math.round(cells * WAIT_PER_CELL_MS), LONGEST_WAIT_MS);
384
+ }
385
+
386
+ /** The page with a tile at another size, where it stands; what its new cells cover moves aside. */
387
+ export function resized(
388
+ placed: readonly PlacedTile[],
389
+ id: string,
390
+ size: HomeTileSize,
391
+ columns: number,
392
+ ): readonly PlacedTile[] {
393
+ const tile = placed.find((candidate) => candidate.tile.id === id);
394
+ if (tile === undefined) {
395
+ return placed;
396
+ }
397
+ const at = { ...tile, size, x: Math.min(tile.x, columns - footprintOf(size, columns).w) };
398
+ return makingRoom(
399
+ placed.filter((candidate) => !sameTile(candidate, tile)),
400
+ at,
401
+ columns,
402
+ );
403
+ }
404
+
405
+ /** The page with a tile back on it, at the first room, at the size it starts at. */
406
+ export function added(
407
+ placed: readonly PlacedTile[],
408
+ tile: HomeTile,
409
+ columns: number,
410
+ ): readonly PlacedTile[] {
411
+ const size = startingSizeAt(tile, columns);
412
+ const room = firstRoom(occupied(placed, columns), footprintOf(size, columns), columns);
413
+ return [...placed, { tile, size, ...room }];
414
+ }
415
+
416
+ /** What is kept of the page: the layout at this width, the others as they were. */
417
+ export function homeWith(
418
+ home: RestHome | null | undefined,
419
+ placed: readonly PlacedTile[],
420
+ hidden: readonly string[],
421
+ columns: HomeColumns,
422
+ ): RestHome {
423
+ const shown = new Set(placed.map((tile) => tile.tile.id));
424
+ // What the person may not see for now keeps its place, for the day it is back.
425
+ const away = (home?.layouts[columns] ?? []).filter(
426
+ (entry) => !shown.has(entry.tile) && !hidden.includes(entry.tile),
427
+ );
428
+ const entries: RestHomeTile[] = inReadingOrder(placed).map(({ tile, x, y, size }) => ({
429
+ tile: tile.id,
430
+ x,
431
+ y,
432
+ size,
433
+ }));
434
+ return {
435
+ hidden: hidden.slice(0, MAX_HOME_TILES),
436
+ layouts: { ...home?.layouts, [columns]: [...entries, ...away].slice(0, MAX_HOME_TILES) },
437
+ };
438
+ }