@skillcdn/console 0.4.3 → 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 (231) 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 +95 -20
  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 +32 -9
  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 +17 -14
  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 +4 -0
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +2 -0
  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 +3 -3
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -0
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +23 -5
  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 +7 -1
  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/pack-connect.d.ts.map +1 -1
  55. package/dist/components/pack-connect.js +5 -4
  56. package/dist/components/pack-connect.js.map +1 -1
  57. package/dist/components/people.d.ts +2 -0
  58. package/dist/components/people.d.ts.map +1 -1
  59. package/dist/components/people.js +15 -2
  60. package/dist/components/people.js.map +1 -1
  61. package/dist/components/plugin-frame.d.ts +3 -12
  62. package/dist/components/plugin-frame.d.ts.map +1 -1
  63. package/dist/components/plugin-frame.js +53 -20
  64. package/dist/components/plugin-frame.js.map +1 -1
  65. package/dist/components/plugin-settings.d.ts.map +1 -1
  66. package/dist/components/plugin-settings.js +151 -26
  67. package/dist/components/plugin-settings.js.map +1 -1
  68. package/dist/components/projects.d.ts +0 -8
  69. package/dist/components/projects.d.ts.map +1 -1
  70. package/dist/components/projects.js +1 -13
  71. package/dist/components/projects.js.map +1 -1
  72. package/dist/components/task-view.d.ts.map +1 -1
  73. package/dist/components/task-view.js +2 -1
  74. package/dist/components/task-view.js.map +1 -1
  75. package/dist/components/ui.d.ts.map +1 -1
  76. package/dist/components/ui.js +5 -2
  77. package/dist/components/ui.js.map +1 -1
  78. package/dist/connect-page.d.ts.map +1 -1
  79. package/dist/connect-page.js +5 -6
  80. package/dist/connect-page.js.map +1 -1
  81. package/dist/console.css +761 -191
  82. package/dist/console.d.ts +10 -3
  83. package/dist/console.d.ts.map +1 -1
  84. package/dist/console.js +33 -13
  85. package/dist/console.js.map +1 -1
  86. package/dist/data.d.ts +4 -2
  87. package/dist/data.d.ts.map +1 -1
  88. package/dist/data.js +5 -0
  89. package/dist/data.js.map +1 -1
  90. package/dist/docs-page.d.ts.map +1 -1
  91. package/dist/docs-page.js +6 -5
  92. package/dist/docs-page.js.map +1 -1
  93. package/dist/home-tiles.d.ts +92 -0
  94. package/dist/home-tiles.d.ts.map +1 -0
  95. package/dist/home-tiles.js +294 -0
  96. package/dist/home-tiles.js.map +1 -0
  97. package/dist/i18n/en.d.ts +174 -0
  98. package/dist/i18n/en.d.ts.map +1 -1
  99. package/dist/i18n/en.js +175 -1
  100. package/dist/i18n/en.js.map +1 -1
  101. package/dist/i18n/index.d.ts +4 -2
  102. package/dist/i18n/index.d.ts.map +1 -1
  103. package/dist/i18n/index.js +10 -11
  104. package/dist/i18n/index.js.map +1 -1
  105. package/dist/i18n/ko.d.ts.map +1 -1
  106. package/dist/i18n/ko.js +170 -3
  107. package/dist/i18n/ko.js.map +1 -1
  108. package/dist/index.d.ts +4 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +4 -3
  111. package/dist/index.js.map +1 -1
  112. package/dist/limits.d.ts +6 -0
  113. package/dist/limits.d.ts.map +1 -1
  114. package/dist/limits.js +6 -0
  115. package/dist/limits.js.map +1 -1
  116. package/dist/plugin/bridge.d.ts +44 -3
  117. package/dist/plugin/bridge.d.ts.map +1 -1
  118. package/dist/plugin/bridge.js +26 -0
  119. package/dist/plugin/bridge.js.map +1 -1
  120. package/dist/plugin/capabilities.d.ts +7 -1
  121. package/dist/plugin/capabilities.d.ts.map +1 -1
  122. package/dist/plugin/capabilities.js +14 -1
  123. package/dist/plugin/capabilities.js.map +1 -1
  124. package/dist/plugin/context.d.ts +18 -1
  125. package/dist/plugin/context.d.ts.map +1 -1
  126. package/dist/plugin/define.d.ts +1 -0
  127. package/dist/plugin/define.d.ts.map +1 -1
  128. package/dist/plugin/define.js +1 -1
  129. package/dist/plugin/define.js.map +1 -1
  130. package/dist/plugin/limits.d.ts +5 -2
  131. package/dist/plugin/limits.d.ts.map +1 -1
  132. package/dist/plugin/limits.js +5 -2
  133. package/dist/plugin/limits.js.map +1 -1
  134. package/dist/plugin/manifest.d.ts +83 -6
  135. package/dist/plugin/manifest.d.ts.map +1 -1
  136. package/dist/plugin/manifest.js +111 -7
  137. package/dist/plugin/manifest.js.map +1 -1
  138. package/dist/plugin/rest-schemas.d.ts +76 -0
  139. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  140. package/dist/plugin/rest-schemas.js +23 -2
  141. package/dist/plugin/rest-schemas.js.map +1 -1
  142. package/dist/plugin/sandbox.d.ts +4 -3
  143. package/dist/plugin/sandbox.d.ts.map +1 -1
  144. package/dist/plugin/sandbox.js +33 -6
  145. package/dist/plugin/sandbox.js.map +1 -1
  146. package/dist/plugin/semver.d.ts +26 -0
  147. package/dist/plugin/semver.d.ts.map +1 -0
  148. package/dist/plugin/semver.js +76 -0
  149. package/dist/plugin/semver.js.map +1 -0
  150. package/dist/plugin/testing.d.ts.map +1 -1
  151. package/dist/plugin/testing.js +2 -1
  152. package/dist/plugin/testing.js.map +1 -1
  153. package/dist/plugin/web.d.ts +10 -1
  154. package/dist/plugin/web.d.ts.map +1 -1
  155. package/dist/plugin/web.js +34 -0
  156. package/dist/plugin/web.js.map +1 -1
  157. package/dist/plugin.d.ts +3 -2
  158. package/dist/plugin.d.ts.map +1 -1
  159. package/dist/plugin.js +3 -2
  160. package/dist/plugin.js.map +1 -1
  161. package/dist/rules.d.ts +2 -2
  162. package/dist/rules.js +3 -3
  163. package/dist/schemas.d.ts +497 -2
  164. package/dist/schemas.d.ts.map +1 -1
  165. package/dist/schemas.js +96 -9
  166. package/dist/schemas.js.map +1 -1
  167. package/dist/use-action.d.ts +8 -2
  168. package/dist/use-action.d.ts.map +1 -1
  169. package/dist/use-action.js +13 -6
  170. package/dist/use-action.js.map +1 -1
  171. package/dist/vocabulary.d.ts +3 -1
  172. package/dist/vocabulary.d.ts.map +1 -1
  173. package/dist/vocabulary.js +3 -0
  174. package/dist/vocabulary.js.map +1 -1
  175. package/docs/specs/cli.md +4 -3
  176. package/docs/specs/plugins.md +46 -33
  177. package/docs/specs/rest.md +16 -15
  178. package/package.json +1 -1
  179. package/skills/building-a-plugin/SKILL.md +8 -7
  180. package/skills/planning-the-board/references/setting-up.md +2 -1
  181. package/skills/working-the-board/SKILL.md +3 -3
  182. package/src/api.ts +6 -0
  183. package/src/cli/cli.ts +124 -19
  184. package/src/cli/format.ts +42 -9
  185. package/src/cli/help.ts +17 -13
  186. package/src/cli/plugin-check.ts +41 -2
  187. package/src/cli/plugin-cli.ts +10 -1
  188. package/src/cli/plugin-init.ts +8 -0
  189. package/src/client.ts +8 -0
  190. package/src/compat.ts +1 -1
  191. package/src/components/board.tsx +3 -3
  192. package/src/components/decision-form.tsx +75 -19
  193. package/src/components/decision-list.tsx +45 -13
  194. package/src/components/event-feed.tsx +7 -1
  195. package/src/components/goals.tsx +4 -1
  196. package/src/components/home.tsx +615 -0
  197. package/src/components/pack-connect.tsx +5 -4
  198. package/src/components/people.tsx +136 -54
  199. package/src/components/plugin-frame.tsx +62 -63
  200. package/src/components/plugin-settings.tsx +428 -87
  201. package/src/components/projects.tsx +0 -55
  202. package/src/components/task-view.tsx +7 -0
  203. package/src/components/ui.tsx +6 -2
  204. package/src/connect-page.tsx +5 -6
  205. package/src/console.tsx +80 -30
  206. package/src/data.ts +9 -2
  207. package/src/docs-page.tsx +6 -5
  208. package/src/home-tiles.ts +438 -0
  209. package/src/i18n/en.ts +178 -0
  210. package/src/i18n/index.ts +17 -14
  211. package/src/i18n/ko.ts +174 -3
  212. package/src/index.ts +7 -5
  213. package/src/limits.ts +6 -0
  214. package/src/plugin/bridge.ts +77 -1
  215. package/src/plugin/capabilities.ts +17 -1
  216. package/src/plugin/context.ts +18 -1
  217. package/src/plugin/define.ts +4 -1
  218. package/src/plugin/limits.ts +5 -2
  219. package/src/plugin/manifest.ts +154 -10
  220. package/src/plugin/rest-schemas.ts +27 -1
  221. package/src/plugin/sandbox.ts +36 -8
  222. package/src/plugin/semver.ts +89 -0
  223. package/src/plugin/testing.ts +2 -1
  224. package/src/plugin/web.ts +49 -0
  225. package/src/plugin.ts +18 -0
  226. package/src/rules.ts +3 -3
  227. package/src/schemas.ts +118 -10
  228. package/src/styles/console.css +761 -191
  229. package/src/use-action.ts +16 -7
  230. package/src/vocabulary.ts +4 -0
  231. package/template/plugin/console-plugin.json +3 -1
@@ -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,
@@ -326,6 +327,12 @@ export function TaskView(props: TaskViewProps) {
326
327
  <PersonChip person={task.owner} />
327
328
  </dd>
328
329
  </div>
330
+ {task.agent != null && (
331
+ <div>
332
+ <dt>{words.task.writtenThrough}</dt>
333
+ <dd>{agentLabel(task.agent)}</dd>
334
+ </div>
335
+ )}
329
336
  <div>
330
337
  <dt>{words.task.workers}</dt>
331
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,
@@ -384,6 +385,21 @@ function holdsToken(data: ConsoleData): boolean {
384
385
  return data.me !== undefined && data.me.agent !== null;
385
386
  }
386
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
+
387
403
  /**
388
404
  * The pages a console that holds a token does not offer: the tokens themselves, and the
389
405
  * approval of an agent's connection, which makes one; a person's own doing on the console's
@@ -413,21 +429,11 @@ function ProjectsPage(props: {
413
429
  readonly making: boolean;
414
430
  }) {
415
431
  const words = useWords().projects;
432
+ const homeWords = useWords().home;
416
433
  const { data, components, navigation, client, language } = props;
417
434
  const me = data.me;
418
- /** The plugins' widgets at a place of this page (ADR-0054), for the person's roles. */
419
- const widgets = (place: "home:top" | "home:bottom") =>
420
- me === undefined ? null : (
421
- <PluginWidgets
422
- plugins={data.plugins}
423
- place={place}
424
- me={me}
425
- language={language}
426
- client={client}
427
- navigation={navigation}
428
- nameOf={(name) => pluginWordOf(name, language)}
429
- />
430
- );
435
+ // The person arranges the page (ADR-0061): the projects and the widgets, tiles on one grid.
436
+ const [arranging, setArranging] = useState(false);
431
437
  const { busy, error, act } = useAction();
432
438
  const href = (project: RestProject) => projectHref(project.key);
433
439
  // A project is made by an administrator (ADR-0045): a member sees the list alone, the form's
@@ -438,14 +444,22 @@ function ProjectsPage(props: {
438
444
  <>
439
445
  <div className="sc-page-head">
440
446
  <h1 className="sc-page-title">{words.title}</h1>
441
- {administrator && !making && (
442
- <LinkButton href={PATHS.newProject} variant="primary">
443
- {words.newProject}
444
- </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>
445
460
  )}
446
461
  </div>
447
462
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
448
- {widgets("home:top")}
449
463
  {making && (
450
464
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
451
465
  <ProjectForm
@@ -465,10 +479,30 @@ function ProjectsPage(props: {
465
479
  />
466
480
  </section>
467
481
  )}
468
- <components.ProjectList
482
+ <components.HomeGrid
469
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)}
470
489
  projectHref={href}
471
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
+ }
472
506
  empty={
473
507
  <EmptyState
474
508
  title={words.empty.title}
@@ -481,7 +515,6 @@ function ProjectsPage(props: {
481
515
  />
482
516
  }
483
517
  />
484
- {widgets("home:bottom")}
485
518
  </>
486
519
  );
487
520
  }
@@ -516,7 +549,7 @@ function PluginPage(props: {
516
549
  return (
517
550
  <>
518
551
  {pages.length > 1 && (
519
- <nav className="sc-subtabs" aria-label={pluginWordOf(plugin.name, language)}>
552
+ <nav className="sc-page-tabs" aria-label={pluginWordOf(plugin.name, language)}>
520
553
  {pages.map((candidate) => (
521
554
  <a
522
555
  key={candidate.key}
@@ -572,9 +605,9 @@ function PluginSettingsPage(props: {
572
605
  setDetail(await client.plugin(key).get());
573
606
  setFailure(undefined);
574
607
  } catch (error) {
575
- setFailure(error instanceof ApiError ? error.message : words.common.somethingWentWrong);
608
+ setFailure(errorWords(error, words.common.somethingWentWrong, words));
576
609
  }
577
- }, [client, key, words.common.somethingWentWrong]);
610
+ }, [client, key, words]);
578
611
  useEffect(() => {
579
612
  setDetail(undefined);
580
613
  void read();
@@ -2506,6 +2539,12 @@ function WorkspacePage(props: {
2506
2539
  ? (person, status) => void act(() => data.actions.updatePerson(person.id, { status }))
2507
2540
  : undefined
2508
2541
  }
2542
+ onChangeManager={
2543
+ administrator
2544
+ ? (person, managerId) =>
2545
+ void act(() => data.actions.updatePerson(person.id, { managerId }))
2546
+ : undefined
2547
+ }
2509
2548
  onUnlist={
2510
2549
  administrator
2511
2550
  ? (listing) => void act(() => data.actions.removeListing(listing.id))
@@ -2592,7 +2631,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2592
2631
  const failure = signInFailureOf(location.search);
2593
2632
  const title = data.me?.workspace.name ?? fallbackTitle;
2594
2633
  const viaToken = holdsToken(data);
2595
- // 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.
2596
2635
  const chosen = data.me?.language;
2597
2636
  const pack = useMemo(
2598
2637
  () =>
@@ -2605,6 +2644,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2605
2644
  );
2606
2645
  const words = pack.messages;
2607
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
+
2608
2658
  // An address that moved is shown at its new one, in its place.
2609
2659
  useEffect(() => {
2610
2660
  if (route.name === "moved") {
@@ -2632,7 +2682,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2632
2682
  title={words.common.couldNotReach}
2633
2683
  action={<Button onClick={data.reload}>{words.common.tryAgain}</Button>}
2634
2684
  >
2635
- {data.error}
2685
+ {errorWords(data.failure, data.error, words)}
2636
2686
  </Callout>
2637
2687
  )}
2638
2688
  </div>
package/src/data.ts CHANGED
@@ -173,7 +173,7 @@ export interface ConsoleActions {
173
173
  createListing(input: RestListingInput): Promise<RestListing>;
174
174
  /** Takes a listing away that nobody signed in with; for an administrator. */
175
175
  removeListing(id: string): Promise<void>;
176
- /** Keeps the person's own choice of language (ADR-0012). */
176
+ /** Keeps the person's language (ADR-0065) or their front page. */
177
177
  updateMe(patch: RestMePatch): Promise<RestMe>;
178
178
  /** Changes the workspace's settings (ADR-0035); for an administrator. `me` says them from then on. */
179
179
  updateWorkspace(patch: RestWorkspacePatch): Promise<RestWorkspace>;
@@ -214,8 +214,10 @@ export interface ConsoleActions {
214
214
  export interface ConsoleData {
215
215
  /** Who is signed in and what the board is called; `undefined` until the server has answered. */
216
216
  readonly me: RestMe | undefined;
217
- /** 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. */
218
218
  readonly error: string | undefined;
219
+ /** The failure itself, for a page to say in the person's language (`errorWords`). */
220
+ readonly failure?: unknown;
219
221
  /** True once the workspace has been loaded for the first time: the projects, the people, the tokens. */
220
222
  readonly loaded: boolean;
221
223
  /** The projects the person may see, by name. */
@@ -357,6 +359,7 @@ const wordsOf = (failure: unknown, fallback: string): string =>
357
359
  export function useConsoleData(client: ConsoleClient, projectKey: string | undefined): ConsoleData {
358
360
  const [me, setMe] = useState<RestMe | undefined>(undefined);
359
361
  const [error, setError] = useState<string | undefined>(undefined);
362
+ const [failure, setFailure] = useState<unknown>(undefined);
360
363
  const [loaded, setLoaded] = useState(false);
361
364
  const [projects, setProjects] = useState<readonly RestProject[]>([]);
362
365
  const [people, setPeople] = useState<readonly RestPerson[]>([]);
@@ -461,12 +464,14 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
461
464
  const controller = new AbortController();
462
465
  const { signal } = controller;
463
466
  setError(undefined);
467
+ setFailure(undefined);
464
468
  const run = async () => {
465
469
  let answer: RestMe;
466
470
  try {
467
471
  answer = await client.me(signal);
468
472
  } catch (failure) {
469
473
  if (!signal.aborted) {
474
+ setFailure(failure);
470
475
  setError(wordsOf(failure, "The server could not be reached."));
471
476
  }
472
477
  return;
@@ -492,6 +497,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
492
497
  setLoaded(true);
493
498
  } catch (failure) {
494
499
  if (!signal.aborted) {
500
+ setFailure(failure);
495
501
  setError(wordsOf(failure, "The workspace could not be loaded."));
496
502
  }
497
503
  }
@@ -862,6 +868,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
862
868
  return {
863
869
  me,
864
870
  error,
871
+ failure,
865
872
  loaded,
866
873
  projects,
867
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);