@skillcdn/console 0.4.2 → 0.4.4

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 (243) hide show
  1. package/README.md +4 -4
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +120 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -2
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +38 -10
  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 +22 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +10 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +45 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts +1 -1
  21. package/dist/cli/plugin-cli.d.ts.map +1 -1
  22. package/dist/cli/plugin-cli.js +80 -5
  23. package/dist/cli/plugin-cli.js.map +1 -1
  24. package/dist/cli/plugin-init.d.ts +19 -0
  25. package/dist/cli/plugin-init.d.ts.map +1 -1
  26. package/dist/cli/plugin-init.js +141 -3
  27. package/dist/cli/plugin-init.js.map +1 -1
  28. package/dist/client.d.ts +7 -2
  29. package/dist/client.d.ts.map +1 -1
  30. package/dist/client.js +18 -5
  31. package/dist/client.js.map +1 -1
  32. package/dist/compat.js +1 -1
  33. package/dist/components/board.d.ts +8 -0
  34. package/dist/components/board.d.ts.map +1 -1
  35. package/dist/components/board.js +3 -3
  36. package/dist/components/board.js.map +1 -1
  37. package/dist/components/comments.d.ts.map +1 -1
  38. package/dist/components/comments.js +1 -1
  39. package/dist/components/comments.js.map +1 -1
  40. package/dist/components/decision-form.d.ts +8 -1
  41. package/dist/components/decision-form.d.ts.map +1 -1
  42. package/dist/components/decision-form.js +57 -7
  43. package/dist/components/decision-form.js.map +1 -1
  44. package/dist/components/decision-list.d.ts +9 -1
  45. package/dist/components/decision-list.d.ts.map +1 -1
  46. package/dist/components/decision-list.js +53 -7
  47. package/dist/components/decision-list.js.map +1 -1
  48. package/dist/components/event-feed.d.ts.map +1 -1
  49. package/dist/components/event-feed.js +7 -1
  50. package/dist/components/event-feed.js.map +1 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +2 -2
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/home.d.ts +35 -0
  55. package/dist/components/home.d.ts.map +1 -0
  56. package/dist/components/home.js +326 -0
  57. package/dist/components/home.js.map +1 -0
  58. package/dist/components/my-decisions.d.ts +2 -0
  59. package/dist/components/my-decisions.d.ts.map +1 -1
  60. package/dist/components/my-decisions.js +2 -2
  61. package/dist/components/my-decisions.js.map +1 -1
  62. package/dist/components/pack-connect.d.ts.map +1 -1
  63. package/dist/components/pack-connect.js +5 -4
  64. package/dist/components/pack-connect.js.map +1 -1
  65. package/dist/components/people.d.ts +2 -0
  66. package/dist/components/people.d.ts.map +1 -1
  67. package/dist/components/people.js +15 -2
  68. package/dist/components/people.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -12
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +53 -20
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts.map +1 -1
  74. package/dist/components/plugin-settings.js +151 -26
  75. package/dist/components/plugin-settings.js.map +1 -1
  76. package/dist/components/projects.d.ts +0 -8
  77. package/dist/components/projects.d.ts.map +1 -1
  78. package/dist/components/projects.js +1 -13
  79. package/dist/components/projects.js.map +1 -1
  80. package/dist/components/task-view.d.ts +2 -1
  81. package/dist/components/task-view.d.ts.map +1 -1
  82. package/dist/components/task-view.js +2 -1
  83. package/dist/components/task-view.js.map +1 -1
  84. package/dist/components/ui.d.ts.map +1 -1
  85. package/dist/components/ui.js +5 -2
  86. package/dist/components/ui.js.map +1 -1
  87. package/dist/connect-page.d.ts.map +1 -1
  88. package/dist/connect-page.js +5 -6
  89. package/dist/connect-page.js.map +1 -1
  90. package/dist/console.css +764 -192
  91. package/dist/console.d.ts +10 -3
  92. package/dist/console.d.ts.map +1 -1
  93. package/dist/console.js +49 -28
  94. package/dist/console.js.map +1 -1
  95. package/dist/data.d.ts +6 -3
  96. package/dist/data.d.ts.map +1 -1
  97. package/dist/data.js +31 -7
  98. package/dist/data.js.map +1 -1
  99. package/dist/docs-page.d.ts.map +1 -1
  100. package/dist/docs-page.js +6 -5
  101. package/dist/docs-page.js.map +1 -1
  102. package/dist/home-tiles.d.ts +92 -0
  103. package/dist/home-tiles.d.ts.map +1 -0
  104. package/dist/home-tiles.js +294 -0
  105. package/dist/home-tiles.js.map +1 -0
  106. package/dist/i18n/en.d.ts +180 -0
  107. package/dist/i18n/en.d.ts.map +1 -1
  108. package/dist/i18n/en.js +181 -1
  109. package/dist/i18n/en.js.map +1 -1
  110. package/dist/i18n/index.d.ts +4 -2
  111. package/dist/i18n/index.d.ts.map +1 -1
  112. package/dist/i18n/index.js +10 -11
  113. package/dist/i18n/index.js.map +1 -1
  114. package/dist/i18n/ko.d.ts.map +1 -1
  115. package/dist/i18n/ko.js +176 -3
  116. package/dist/i18n/ko.js.map +1 -1
  117. package/dist/index.d.ts +4 -3
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +4 -3
  120. package/dist/index.js.map +1 -1
  121. package/dist/limits.d.ts +8 -0
  122. package/dist/limits.d.ts.map +1 -1
  123. package/dist/limits.js +8 -0
  124. package/dist/limits.js.map +1 -1
  125. package/dist/plugin/bridge.d.ts +44 -3
  126. package/dist/plugin/bridge.d.ts.map +1 -1
  127. package/dist/plugin/bridge.js +26 -0
  128. package/dist/plugin/bridge.js.map +1 -1
  129. package/dist/plugin/capabilities.d.ts +7 -1
  130. package/dist/plugin/capabilities.d.ts.map +1 -1
  131. package/dist/plugin/capabilities.js +14 -1
  132. package/dist/plugin/capabilities.js.map +1 -1
  133. package/dist/plugin/context.d.ts +24 -1
  134. package/dist/plugin/context.d.ts.map +1 -1
  135. package/dist/plugin/define.d.ts +1 -0
  136. package/dist/plugin/define.d.ts.map +1 -1
  137. package/dist/plugin/define.js +1 -1
  138. package/dist/plugin/define.js.map +1 -1
  139. package/dist/plugin/limits.d.ts +5 -2
  140. package/dist/plugin/limits.d.ts.map +1 -1
  141. package/dist/plugin/limits.js +5 -2
  142. package/dist/plugin/limits.js.map +1 -1
  143. package/dist/plugin/manifest.d.ts +83 -6
  144. package/dist/plugin/manifest.d.ts.map +1 -1
  145. package/dist/plugin/manifest.js +111 -7
  146. package/dist/plugin/manifest.js.map +1 -1
  147. package/dist/plugin/rest-schemas.d.ts +76 -0
  148. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  149. package/dist/plugin/rest-schemas.js +23 -2
  150. package/dist/plugin/rest-schemas.js.map +1 -1
  151. package/dist/plugin/sandbox.d.ts +4 -3
  152. package/dist/plugin/sandbox.d.ts.map +1 -1
  153. package/dist/plugin/sandbox.js +33 -6
  154. package/dist/plugin/sandbox.js.map +1 -1
  155. package/dist/plugin/semver.d.ts +26 -0
  156. package/dist/plugin/semver.d.ts.map +1 -0
  157. package/dist/plugin/semver.js +76 -0
  158. package/dist/plugin/semver.js.map +1 -0
  159. package/dist/plugin/testing.d.ts.map +1 -1
  160. package/dist/plugin/testing.js +2 -1
  161. package/dist/plugin/testing.js.map +1 -1
  162. package/dist/plugin/web.d.ts +10 -1
  163. package/dist/plugin/web.d.ts.map +1 -1
  164. package/dist/plugin/web.js +34 -0
  165. package/dist/plugin/web.js.map +1 -1
  166. package/dist/plugin.d.ts +3 -2
  167. package/dist/plugin.d.ts.map +1 -1
  168. package/dist/plugin.js +3 -2
  169. package/dist/plugin.js.map +1 -1
  170. package/dist/rules.d.ts +2 -2
  171. package/dist/rules.js +3 -3
  172. package/dist/schemas.d.ts +574 -11
  173. package/dist/schemas.d.ts.map +1 -1
  174. package/dist/schemas.js +154 -25
  175. package/dist/schemas.js.map +1 -1
  176. package/dist/use-action.d.ts +8 -2
  177. package/dist/use-action.d.ts.map +1 -1
  178. package/dist/use-action.js +13 -6
  179. package/dist/use-action.js.map +1 -1
  180. package/dist/vocabulary.d.ts +3 -1
  181. package/dist/vocabulary.d.ts.map +1 -1
  182. package/dist/vocabulary.js +3 -0
  183. package/dist/vocabulary.js.map +1 -1
  184. package/docs/specs/cli.md +4 -3
  185. package/docs/specs/plugins.md +49 -35
  186. package/docs/specs/rest.md +20 -18
  187. package/package.json +7 -7
  188. package/skills/building-a-plugin/SKILL.md +10 -9
  189. package/skills/planning-the-board/references/setting-up.md +2 -1
  190. package/skills/working-the-board/SKILL.md +3 -3
  191. package/src/api.ts +10 -0
  192. package/src/cli/cli.ts +162 -39
  193. package/src/cli/format.ts +54 -10
  194. package/src/cli/help.ts +23 -14
  195. package/src/cli/plugin-check.ts +68 -2
  196. package/src/cli/plugin-cli.ts +104 -6
  197. package/src/cli/plugin-init.ts +178 -3
  198. package/src/client.ts +36 -9
  199. package/src/compat.ts +1 -1
  200. package/src/components/board.tsx +3 -3
  201. package/src/components/comments.tsx +1 -0
  202. package/src/components/decision-form.tsx +166 -29
  203. package/src/components/decision-list.tsx +116 -15
  204. package/src/components/event-feed.tsx +7 -1
  205. package/src/components/goals.tsx +4 -1
  206. package/src/components/home.tsx +615 -0
  207. package/src/components/my-decisions.tsx +4 -1
  208. package/src/components/pack-connect.tsx +5 -4
  209. package/src/components/people.tsx +136 -54
  210. package/src/components/plugin-frame.tsx +62 -63
  211. package/src/components/plugin-settings.tsx +428 -87
  212. package/src/components/projects.tsx +0 -55
  213. package/src/components/task-view.tsx +9 -1
  214. package/src/components/ui.tsx +6 -2
  215. package/src/connect-page.tsx +5 -6
  216. package/src/console.tsx +98 -42
  217. package/src/data.ts +41 -11
  218. package/src/docs-page.tsx +6 -5
  219. package/src/home-tiles.ts +438 -0
  220. package/src/i18n/en.ts +185 -0
  221. package/src/i18n/index.ts +17 -14
  222. package/src/i18n/ko.ts +181 -3
  223. package/src/index.ts +7 -5
  224. package/src/limits.ts +8 -0
  225. package/src/plugin/bridge.ts +77 -1
  226. package/src/plugin/capabilities.ts +17 -1
  227. package/src/plugin/context.ts +24 -2
  228. package/src/plugin/define.ts +4 -1
  229. package/src/plugin/limits.ts +5 -2
  230. package/src/plugin/manifest.ts +154 -10
  231. package/src/plugin/rest-schemas.ts +27 -1
  232. package/src/plugin/sandbox.ts +36 -8
  233. package/src/plugin/semver.ts +89 -0
  234. package/src/plugin/testing.ts +2 -1
  235. package/src/plugin/web.ts +49 -0
  236. package/src/plugin.ts +18 -0
  237. package/src/rules.ts +3 -3
  238. package/src/schemas.ts +190 -28
  239. package/src/styles/console.css +764 -192
  240. package/src/use-action.ts +16 -7
  241. package/src/vocabulary.ts +4 -0
  242. package/template/plugin/console-plugin.json +3 -1
  243. package/template/plugin/package.json +3 -3
@@ -99,61 +99,6 @@ export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
99
99
  );
100
100
  }
101
101
 
102
- export interface ProjectListProps {
103
- readonly projects: readonly RestProject[];
104
- /** The link to a project's board, as the composition routes it. */
105
- readonly projectHref: (project: RestProject) => string;
106
- readonly onOpen: (project: RestProject) => void;
107
- readonly empty?: ReactNode;
108
- }
109
-
110
- export function ProjectList(props: ProjectListProps) {
111
- const words = useWords().projects;
112
- if (props.projects.length === 0) {
113
- return <>{props.empty ?? null}</>;
114
- }
115
- return (
116
- <ul className="sc-projects" aria-label={words.list}>
117
- {props.projects.map((project) => (
118
- <li key={project.id} className="sc-project">
119
- <div className="sc-project-head">
120
- <ProjectMark project={project} size="md" />
121
- <a
122
- className="sc-project-name"
123
- href={props.projectHref(project)}
124
- onClick={(event) => {
125
- if (event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
126
- event.preventDefault();
127
- props.onOpen(project);
128
- }
129
- }}
130
- >
131
- {project.name}
132
- </a>
133
- <code className="sc-project-key">{project.key}</code>
134
- <ProjectRoleBadge role={project.role} />
135
- {project.visibility === "private" ? (
136
- <Badge tone="neutral">{words.private}</Badge>
137
- ) : (
138
- <Badge tone="warning">{words.public}</Badge>
139
- )}
140
- </div>
141
- {project.description.length > 0 && (
142
- <p className="sc-project-description">{project.description}</p>
143
- )}
144
- {project.openDecisions > 0 && (
145
- <p className="sc-project-meta">
146
- <Badge tone="warning" kind="count">
147
- {words.decisionsWaiting(project.openDecisions)}
148
- </Badge>
149
- </p>
150
- )}
151
- </li>
152
- ))}
153
- </ul>
154
- );
155
- }
156
-
157
102
  export interface ProjectFormProps {
158
103
  /** The project to change; left out, the form makes a new one, with a key. */
159
104
  readonly project?: RestProject | undefined;
@@ -1,3 +1,4 @@
1
+ import { agentLabel } from "../agent-name.js";
1
2
  import type {
2
3
  RestAnswerInput,
3
4
  RestCategory,
@@ -83,7 +84,8 @@ export interface TaskViewProps {
83
84
  readonly error?: string | undefined;
84
85
  readonly onChange: (task: RestTask, patch: RestTaskInput) => void;
85
86
  readonly onMove: (task: RestTask, state: TaskState) => void;
86
- readonly onRaiseDecision: (input: RestDecisionInput) => void;
87
+ /** Called with a decision raised about the task, and the files put on it (ADR-0055). */
88
+ readonly onRaiseDecision: (input: RestDecisionInput, files: readonly File[]) => void;
87
89
  readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
88
90
  /** Called to grow a decision's record with what followed. */
89
91
  readonly onUpdateDecision?:
@@ -325,6 +327,12 @@ export function TaskView(props: TaskViewProps) {
325
327
  <PersonChip person={task.owner} />
326
328
  </dd>
327
329
  </div>
330
+ {task.agent != null && (
331
+ <div>
332
+ <dt>{words.task.writtenThrough}</dt>
333
+ <dd>{agentLabel(task.agent)}</dd>
334
+ </div>
335
+ )}
328
336
  <div>
329
337
  <dt>{words.task.workers}</dt>
330
338
  <dd>
@@ -108,7 +108,10 @@ export function Avatar(props: {
108
108
  const remote = useRemotePictureSrc();
109
109
  const size = props.size ?? "md";
110
110
  const initial = personName(props.person).slice(0, 1).toUpperCase();
111
- if (props.person.avatar === null) {
111
+ const src = props.person.avatar === null ? undefined : remote(props.person.avatar);
112
+ // A picture that would not load gives way to the initial, and never to a broken image.
113
+ const [failed, setFailed] = useState<string | undefined>(undefined);
114
+ if (src === undefined || src === failed) {
112
115
  return (
113
116
  <span
114
117
  className={cx("sc-avatar", "sc-avatar-initial", `sc-avatar-${size}`)}
@@ -121,11 +124,12 @@ export function Avatar(props: {
121
124
  return (
122
125
  <img
123
126
  className={cx("sc-avatar", `sc-avatar-${size}`)}
124
- src={remote(props.person.avatar)}
127
+ src={src}
125
128
  alt=""
126
129
  loading="lazy"
127
130
  decoding="async"
128
131
  referrerPolicy="no-referrer"
132
+ onError={() => setFailed(src)}
129
133
  />
130
134
  );
131
135
  }
@@ -10,7 +10,7 @@ import { Callout, Spinner } from "./components/ui.js";
10
10
  import type { ConsoleData } from "./data.js";
11
11
  import { useWords } from "./i18n/index.js";
12
12
  import { connectHref, type Navigation, PATHS } from "./router.js";
13
- import { useAction } from "./use-action.js";
13
+ import { errorWords, useAction } from "./use-action.js";
14
14
 
15
15
  // The page where a person connects their agent (ADR-0011): the code typed when the address
16
16
  // does not carry one, what asks under it, the approval, and what came of it. Part of the
@@ -24,7 +24,8 @@ export interface ConnectPageProps {
24
24
  }
25
25
 
26
26
  export function ConnectPage(props: ConnectPageProps) {
27
- const words = useWords().connect;
27
+ const pack = useWords();
28
+ const words = pack.connect;
28
29
  const { data, navigation } = props;
29
30
  const code = props.code === undefined ? undefined : normalizeConnectCode(props.code);
30
31
  const [request, setRequest] = useState<RestConnectRequest | undefined>(undefined);
@@ -55,13 +56,11 @@ export function ConnectPage(props: ConnectPageProps) {
55
56
  setProblem(
56
57
  failure instanceof ApiError && failure.code === "connect.not_found"
57
58
  ? words.noSuchCode
58
- : failure instanceof ApiError
59
- ? failure.message
60
- : words.couldNotAsk,
59
+ : errorWords(failure, words.couldNotAsk, pack),
61
60
  );
62
61
  });
63
62
  return () => controller.abort();
64
- }, [code, connectRequest, words]);
63
+ }, [code, connectRequest, pack, words]);
65
64
 
66
65
  const head = (
67
66
  <div className="sc-page-head">
package/src/console.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type RestDecisionInput,
23
23
  type RestDecisionPatch,
24
24
  type RestEvent,
25
+ type RestMe,
25
26
  type RestMyDecisions,
26
27
  type RestPluginDetail,
27
28
  type RestProject,
@@ -47,18 +48,17 @@ import { DecisionForm } from "./components/decision-form.js";
47
48
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
48
49
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
49
50
  import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
51
+ import { HomeGrid, type HomeGridProps } from "./components/home.js";
50
52
  import { MyDecisions } from "./components/my-decisions.js";
51
53
  import { PackTokenWays } from "./components/pack-token.js";
52
54
  import { PAGE_SIZE } from "./components/paged.js";
53
55
  import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
54
- import { PluginFrame, PluginWidgets } from "./components/plugin-frame.js";
56
+ import { PluginFrame } from "./components/plugin-frame.js";
55
57
  import { PluginSettingsList, PluginSettingsView } from "./components/plugin-settings.js";
56
58
  import {
57
59
  MemberList,
58
60
  type MemberListProps,
59
61
  ProjectForm,
60
- ProjectList,
61
- type ProjectListProps,
62
62
  ProjectMark,
63
63
  } from "./components/projects.js";
64
64
  import { type RemotePictureSrc, RemotePicturesContext } from "./components/remote-pictures.js";
@@ -100,6 +100,7 @@ import { type ConsoleData, useConsoleData } from "./data.js";
100
100
  import { monthOf, today } from "./dates.js";
101
101
  import { DocsPage } from "./docs-page.js";
102
102
  import {
103
+ browserPack,
103
104
  chooseLanguage,
104
105
  DEFAULT_LANGUAGES,
105
106
  LanguageContext,
@@ -157,7 +158,7 @@ export interface ConsoleComponents {
157
158
  readonly TokenList: ComponentType<TokenListProps>;
158
159
  readonly PeopleList: ComponentType<PeopleListProps>;
159
160
  readonly SkillList: ComponentType<SkillListProps>;
160
- readonly ProjectList: ComponentType<ProjectListProps>;
161
+ readonly HomeGrid: ComponentType<HomeGridProps>;
161
162
  readonly MemberList: ComponentType<MemberListProps>;
162
163
  readonly Calendar: ComponentType<CalendarProps>;
163
164
  readonly GoalList: ComponentType<GoalListProps>;
@@ -175,7 +176,7 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
175
176
  TokenList,
176
177
  PeopleList,
177
178
  SkillList,
178
- ProjectList,
179
+ HomeGrid,
179
180
  MemberList,
180
181
  Calendar,
181
182
  GoalList,
@@ -356,6 +357,14 @@ function useFollowLinks(navigation: Navigation): void {
356
357
  }
357
358
 
358
359
  /** The key of the project a route is on, or nothing. */
360
+ /** The files chosen or pasted on a page, as the client sends them: each by its name, its bytes and its type where the browser knows one. */
361
+ const uploadsOf = (files: readonly File[]): FileUpload[] =>
362
+ files.map((file) => ({
363
+ name: file.name,
364
+ bytes: file,
365
+ ...(file.type === "" ? {} : { contentType: file.type }),
366
+ }));
367
+
359
368
  const projectOf = (route: Route): string | undefined =>
360
369
  "project" in route ? route.project : undefined;
361
370
 
@@ -376,6 +385,21 @@ function holdsToken(data: ConsoleData): boolean {
376
385
  return data.me !== undefined && data.me.agent !== null;
377
386
  }
378
387
 
388
+ /**
389
+ * The language to keep for a person the pages see for the first time (ADR-0065): the one their
390
+ * browser asks for, so that a plugin or an agent can write for them while they are away. Nothing
391
+ * for a person who has a language, for nobody, and where the pages only fall back on a language.
392
+ */
393
+ export function languageToKeep(
394
+ me: RestMe | undefined,
395
+ browser: readonly string[],
396
+ packs: readonly LanguagePack[],
397
+ ): string | undefined {
398
+ return me?.person !== null && me?.person !== undefined && me.language === null
399
+ ? browserPack(browser, packs)?.tag
400
+ : undefined;
401
+ }
402
+
379
403
  /**
380
404
  * The pages a console that holds a token does not offer: the tokens themselves, and the
381
405
  * approval of an agent's connection, which makes one; a person's own doing on the console's
@@ -405,21 +429,11 @@ function ProjectsPage(props: {
405
429
  readonly making: boolean;
406
430
  }) {
407
431
  const words = useWords().projects;
432
+ const homeWords = useWords().home;
408
433
  const { data, components, navigation, client, language } = props;
409
434
  const me = data.me;
410
- /** The plugins' widgets at a place of this page (ADR-0054), for the person's roles. */
411
- const widgets = (place: "home:top" | "home:bottom") =>
412
- me === undefined ? null : (
413
- <PluginWidgets
414
- plugins={data.plugins}
415
- place={place}
416
- me={me}
417
- language={language}
418
- client={client}
419
- navigation={navigation}
420
- nameOf={(name) => pluginWordOf(name, language)}
421
- />
422
- );
435
+ // The person arranges the page (ADR-0061): the projects and the widgets, tiles on one grid.
436
+ const [arranging, setArranging] = useState(false);
423
437
  const { busy, error, act } = useAction();
424
438
  const href = (project: RestProject) => projectHref(project.key);
425
439
  // A project is made by an administrator (ADR-0045): a member sees the list alone, the form's
@@ -430,14 +444,22 @@ function ProjectsPage(props: {
430
444
  <>
431
445
  <div className="sc-page-head">
432
446
  <h1 className="sc-page-title">{words.title}</h1>
433
- {administrator && !making && (
434
- <LinkButton href={PATHS.newProject} variant="primary">
435
- {words.newProject}
436
- </LinkButton>
447
+ {!arranging && (
448
+ <div className="sc-page-actions">
449
+ {me !== undefined && me.person !== null && (
450
+ <Button size="sm" variant="ghost" onClick={() => setArranging(true)}>
451
+ {homeWords.edit}
452
+ </Button>
453
+ )}
454
+ {administrator && !making && (
455
+ <LinkButton href={PATHS.newProject} variant="primary">
456
+ {words.newProject}
457
+ </LinkButton>
458
+ )}
459
+ </div>
437
460
  )}
438
461
  </div>
439
462
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
440
- {widgets("home:top")}
441
463
  {making && (
442
464
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
443
465
  <ProjectForm
@@ -457,10 +479,30 @@ function ProjectsPage(props: {
457
479
  />
458
480
  </section>
459
481
  )}
460
- <components.ProjectList
482
+ <components.HomeGrid
461
483
  projects={data.projects}
484
+ plugins={data.plugins}
485
+ home={me?.home}
486
+ editing={arranging}
487
+ onSave={(home) => data.actions.updateMe({ home })}
488
+ onDone={() => setArranging(false)}
462
489
  projectHref={href}
463
490
  onOpen={(project) => navigation.go(href(project))}
491
+ nameOf={(name) => pluginWordOf(name, language)}
492
+ renderWidget={(tile, size) =>
493
+ me === undefined ? null : (
494
+ <PluginFrame
495
+ plugin={tile.plugin}
496
+ widget={tile.widget.key}
497
+ size={size}
498
+ me={me}
499
+ language={language}
500
+ client={client}
501
+ navigation={navigation}
502
+ title={pluginWordOf(tile.widget.name, language)}
503
+ />
504
+ )
505
+ }
464
506
  empty={
465
507
  <EmptyState
466
508
  title={words.empty.title}
@@ -473,7 +515,6 @@ function ProjectsPage(props: {
473
515
  />
474
516
  }
475
517
  />
476
- {widgets("home:bottom")}
477
518
  </>
478
519
  );
479
520
  }
@@ -508,7 +549,7 @@ function PluginPage(props: {
508
549
  return (
509
550
  <>
510
551
  {pages.length > 1 && (
511
- <nav className="sc-subtabs" aria-label={pluginWordOf(plugin.name, language)}>
552
+ <nav className="sc-page-tabs" aria-label={pluginWordOf(plugin.name, language)}>
512
553
  {pages.map((candidate) => (
513
554
  <a
514
555
  key={candidate.key}
@@ -564,9 +605,9 @@ function PluginSettingsPage(props: {
564
605
  setDetail(await client.plugin(key).get());
565
606
  setFailure(undefined);
566
607
  } catch (error) {
567
- setFailure(error instanceof ApiError ? error.message : words.common.somethingWentWrong);
608
+ setFailure(errorWords(error, words.common.somethingWentWrong, words));
568
609
  }
569
- }, [client, key, words.common.somethingWentWrong]);
610
+ }, [client, key, words]);
570
611
  useEffect(() => {
571
612
  setDetail(undefined);
572
613
  void read();
@@ -863,9 +904,9 @@ function TaskPage(props: {
863
904
  })
864
905
  }
865
906
  onMove={(target, state) => void act(() => data.actions.updateTask(target.id, { state }))}
866
- onRaiseDecision={(input) =>
907
+ onRaiseDecision={(input, files) =>
867
908
  void act(async () => {
868
- await data.actions.raiseDecision(input);
909
+ await data.actions.raiseDecision(input, uploadsOf(files));
869
910
  navigation.replace(here);
870
911
  })
871
912
  }
@@ -889,14 +930,7 @@ function TaskPage(props: {
889
930
  }
890
931
  onComment={(draft) =>
891
932
  void act(async () => {
892
- await data.actions.comment(task.id, {
893
- body: draft.body,
894
- files: draft.files.map((file) => ({
895
- name: file.name,
896
- bytes: file,
897
- ...(file.type === "" ? {} : { contentType: file.type }),
898
- })),
899
- });
933
+ await data.actions.comment(task.id, { body: draft.body, files: uploadsOf(draft.files) });
900
934
  setComments(await taskComments(task.id));
901
935
  })
902
936
  }
@@ -1311,6 +1345,7 @@ function ProjectPage(props: {
1311
1345
  tasks={tasksById}
1312
1346
  taskHref={href}
1313
1347
  docHref={toDoc}
1348
+ fileHref={(fileId) => projectPath(key, "files", fileId)}
1314
1349
  decisionHref={(decision) => decisionHref(key, decision.id)}
1315
1350
  page={route.page}
1316
1351
  pageHref={(page) => decisionsHref(key, page)}
@@ -1337,9 +1372,9 @@ function ProjectPage(props: {
1337
1372
  deciders={data.members.map((member) => member.person)}
1338
1373
  me={data.me?.person ?? undefined}
1339
1374
  busy={busy}
1340
- onSubmit={(input: RestDecisionInput) =>
1375
+ onSubmit={(input: RestDecisionInput, files: readonly File[]) =>
1341
1376
  void act(async () => {
1342
- const made = await data.actions.raiseDecision(input);
1377
+ const made = await data.actions.raiseDecision(input, uploadsOf(files));
1343
1378
  navigation.replace(decisionHref(key, made.id));
1344
1379
  })
1345
1380
  }
@@ -1366,6 +1401,7 @@ function ProjectPage(props: {
1366
1401
  task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
1367
1402
  taskHref={href}
1368
1403
  docHref={toDoc}
1404
+ fileHref={(fileId) => projectPath(key, "files", fileId)}
1369
1405
  onAnswer={onAnswer}
1370
1406
  onUpdate={onUpdate}
1371
1407
  busy={busy}
@@ -2301,6 +2337,7 @@ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2301
2337
  addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
2302
2338
  raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
2303
2339
  decisionHref={decisionHref}
2340
+ fileHref={(projectKey, fileId) => projectPath(projectKey, "files", fileId)}
2304
2341
  onAnswer={onAnswer}
2305
2342
  busy={busy || addressed.busy || raised.busy}
2306
2343
  />
@@ -2502,6 +2539,12 @@ function WorkspacePage(props: {
2502
2539
  ? (person, status) => void act(() => data.actions.updatePerson(person.id, { status }))
2503
2540
  : undefined
2504
2541
  }
2542
+ onChangeManager={
2543
+ administrator
2544
+ ? (person, managerId) =>
2545
+ void act(() => data.actions.updatePerson(person.id, { managerId }))
2546
+ : undefined
2547
+ }
2505
2548
  onUnlist={
2506
2549
  administrator
2507
2550
  ? (listing) => void act(() => data.actions.removeListing(listing.id))
@@ -2575,6 +2618,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2575
2618
  const languages = config.languages ?? DEFAULT_LANGUAGES;
2576
2619
  const pages = config.pages ?? [];
2577
2620
 
2621
+ // biome-ignore lint/correctness/noComponentHookFactories: createConsole runs once, when a console is built, never during a render; App is that console's one root component, closing over its config.
2578
2622
  function App(props: { readonly initialPath?: string | undefined }) {
2579
2623
  const { location, navigation } = useNavigation(props.initialPath);
2580
2624
  useFollowLinks(navigation);
@@ -2587,7 +2631,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2587
2631
  const failure = signInFailureOf(location.search);
2588
2632
  const title = data.me?.workspace.name ?? fallbackTitle;
2589
2633
  const viaToken = holdsToken(data);
2590
- // The language: the person's choice, else the browser's, else the first the console speaks.
2634
+ // The language: the person's own, else the browser's, else the first the console speaks.
2591
2635
  const chosen = data.me?.language;
2592
2636
  const pack = useMemo(
2593
2637
  () =>
@@ -2600,6 +2644,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2600
2644
  );
2601
2645
  const words = pack.messages;
2602
2646
 
2647
+ const toKeep = languageToKeep(
2648
+ data.me,
2649
+ typeof navigator === "undefined" ? [] : navigator.languages,
2650
+ languages,
2651
+ );
2652
+ useEffect(() => {
2653
+ if (toKeep !== undefined) {
2654
+ void data.actions.updateMe({ language: toKeep }).catch(() => undefined);
2655
+ }
2656
+ }, [toKeep, data.actions]);
2657
+
2603
2658
  // An address that moved is shown at its new one, in its place.
2604
2659
  useEffect(() => {
2605
2660
  if (route.name === "moved") {
@@ -2627,7 +2682,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2627
2682
  title={words.common.couldNotReach}
2628
2683
  action={<Button onClick={data.reload}>{words.common.tryAgain}</Button>}
2629
2684
  >
2630
- {data.error}
2685
+ {errorWords(data.failure, data.error, words)}
2631
2686
  </Callout>
2632
2687
  )}
2633
2688
  </div>
@@ -2659,6 +2714,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2659
2714
  href: PATHS.decisions,
2660
2715
  label: words.nav.myDecisions,
2661
2716
  current: route.name === "my-decisions",
2717
+ count: data.me?.decisions?.waiting,
2662
2718
  },
2663
2719
  {
2664
2720
  href: PATHS.people,
package/src/data.ts CHANGED
@@ -125,7 +125,8 @@ export interface ConsoleActions {
125
125
  page: { readonly limit: number; readonly offset: number },
126
126
  signal?: AbortSignal,
127
127
  ): Promise<RestGoals>;
128
- raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
128
+ /** Raises a decision in the project; with `files`, the files put on it (ADR-0055). */
129
+ raiseDecision(input: RestDecisionInput, files?: readonly FileUpload[]): Promise<RestDecision>;
129
130
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
130
131
  /** Answers a decision of any project the person may see, by its project's key: for the person's own page of decisions (ADR-0042). */
131
132
  answerDecisionIn(projectKey: string, id: string, input: RestAnswerInput): Promise<RestDecision>;
@@ -172,7 +173,7 @@ export interface ConsoleActions {
172
173
  createListing(input: RestListingInput): Promise<RestListing>;
173
174
  /** Takes a listing away that nobody signed in with; for an administrator. */
174
175
  removeListing(id: string): Promise<void>;
175
- /** Keeps the person's own choice of language (ADR-0012). */
176
+ /** Keeps the person's language (ADR-0065) or their front page. */
176
177
  updateMe(patch: RestMePatch): Promise<RestMe>;
177
178
  /** Changes the workspace's settings (ADR-0035); for an administrator. `me` says them from then on. */
178
179
  updateWorkspace(patch: RestWorkspacePatch): Promise<RestWorkspace>;
@@ -213,8 +214,10 @@ export interface ConsoleActions {
213
214
  export interface ConsoleData {
214
215
  /** Who is signed in and what the board is called; `undefined` until the server has answered. */
215
216
  readonly me: RestMe | undefined;
216
- /** What went wrong asking who is signed in, for a person. */
217
+ /** What went wrong asking who is signed in, for a person, in English. */
217
218
  readonly error: string | undefined;
219
+ /** The failure itself, for a page to say in the person's language (`errorWords`). */
220
+ readonly failure?: unknown;
218
221
  /** True once the workspace has been loaded for the first time: the projects, the people, the tokens. */
219
222
  readonly loaded: boolean;
220
223
  /** The projects the person may see, by name. */
@@ -356,6 +359,7 @@ const wordsOf = (failure: unknown, fallback: string): string =>
356
359
  export function useConsoleData(client: ConsoleClient, projectKey: string | undefined): ConsoleData {
357
360
  const [me, setMe] = useState<RestMe | undefined>(undefined);
358
361
  const [error, setError] = useState<string | undefined>(undefined);
362
+ const [failure, setFailure] = useState<unknown>(undefined);
359
363
  const [loaded, setLoaded] = useState(false);
360
364
  const [projects, setProjects] = useState<readonly RestProject[]>([]);
361
365
  const [people, setPeople] = useState<readonly RestPerson[]>([]);
@@ -382,6 +386,20 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
382
386
 
383
387
  const reload = useCallback(() => setGeneration((current) => current + 1), []);
384
388
 
389
+ /**
390
+ * Asks again who is signed in, for what moves with the board: the decisions waiting for the
391
+ * person, said beside My decisions. What fails stays as it was.
392
+ */
393
+ const refreshMe = useCallback(
394
+ async (signal?: AbortSignal) => {
395
+ const answer = await client.me(signal);
396
+ if (signal?.aborted !== true) {
397
+ setMe(answer);
398
+ }
399
+ },
400
+ [client],
401
+ );
402
+
385
403
  /** Loads the workspace's lists again. What fails stays as it was. */
386
404
  const refreshWorkspace = useCallback(
387
405
  async (signal?: AbortSignal) => {
@@ -446,12 +464,14 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
446
464
  const controller = new AbortController();
447
465
  const { signal } = controller;
448
466
  setError(undefined);
467
+ setFailure(undefined);
449
468
  const run = async () => {
450
469
  let answer: RestMe;
451
470
  try {
452
471
  answer = await client.me(signal);
453
472
  } catch (failure) {
454
473
  if (!signal.aborted) {
474
+ setFailure(failure);
455
475
  setError(wordsOf(failure, "The server could not be reached."));
456
476
  }
457
477
  return;
@@ -477,6 +497,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
477
497
  setLoaded(true);
478
498
  } catch (failure) {
479
499
  if (!signal.aborted) {
500
+ setFailure(failure);
480
501
  setError(wordsOf(failure, "The workspace could not be loaded."));
481
502
  }
482
503
  }
@@ -571,6 +592,11 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
571
592
  if (event.kind.startsWith("project.")) {
572
593
  refreshWorkspace(signal).catch(() => undefined);
573
594
  }
595
+ // A decision raised or answered here may be one of the person's own: the count beside
596
+ // My decisions follows it.
597
+ if (event.kind.startsWith("decision.")) {
598
+ refreshMe(signal).catch(() => undefined);
599
+ }
574
600
  }, RELOAD_DELAY_MS);
575
601
  };
576
602
  for (const kind of EVENT_KINDS) {
@@ -584,7 +610,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
584
610
  source?.close();
585
611
  setLive(false);
586
612
  };
587
- }, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace]);
613
+ }, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace, refreshMe]);
588
614
 
589
615
  const actions = useMemo<ConsoleActions>(() => {
590
616
  /** The project the page is on, for an action that needs one. */
@@ -684,16 +710,19 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
684
710
  scope().tasks({ archived: false, ...searchWordsOf(query), ...page }, signal),
685
711
  findGoals: (query, page, signal) =>
686
712
  scope().goals({ open: true, ...searchWordsOf(query), ...page }, signal),
687
- async raiseDecision(input) {
688
- const decision = await scope().raiseDecision(input);
689
- await refreshProject();
713
+ async raiseDecision(input, files) {
714
+ const decision = await scope().raiseDecision(input, files);
715
+ await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
716
+ return decision;
717
+ },
718
+ async answerDecisionIn(projectKey, id, input) {
719
+ const decision = await client.project(projectKey).answerDecision(id, input);
720
+ await refreshMe().catch(() => undefined);
690
721
  return decision;
691
722
  },
692
- answerDecisionIn: (projectKey, id, input) =>
693
- client.project(projectKey).answerDecision(id, input),
694
723
  async answerDecision(id, input) {
695
724
  const decision = await scope().answerDecision(id, input);
696
- await refreshProject();
725
+ await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
697
726
  return decision;
698
727
  },
699
728
  async updateDecision(id, patch) {
@@ -834,11 +863,12 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
834
863
  setLoaded(false);
835
864
  },
836
865
  };
837
- }, [client, projectKey, refreshProject, refreshWorkspace]);
866
+ }, [client, projectKey, refreshProject, refreshWorkspace, refreshMe]);
838
867
 
839
868
  return {
840
869
  me,
841
870
  error,
871
+ failure,
842
872
  loaded,
843
873
  projects,
844
874
  people,
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);