@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
@@ -23,6 +23,8 @@ import {
23
23
  type RestProjectPatch,
24
24
  } from "../api.js";
25
25
  import { useWords } from "../i18n/index.js";
26
+ import { Picker } from "./picker.js";
27
+ import { peopleSearch } from "./searches.js";
26
28
  import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
27
29
 
28
30
  /**
@@ -99,61 +101,6 @@ export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
99
101
  );
100
102
  }
101
103
 
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
104
  export interface ProjectFormProps {
158
105
  /** The project to change; left out, the form makes a new one, with a key. */
159
106
  readonly project?: RestProject | undefined;
@@ -517,21 +464,19 @@ export function MemberList(props: MemberListProps) {
517
464
  }}
518
465
  >
519
466
  <div className="sc-field-row">
520
- <label className="sc-field">
467
+ <div className="sc-field">
521
468
  <span className="sc-field-label">{members.person}</span>
522
- <select
523
- className="sc-input"
524
- value={personId}
525
- onChange={(event) => setPersonId(event.target.value)}
526
- >
527
- <option value="">{members.chooseSomeone}</option>
528
- {candidates.map((person) => (
529
- <option key={person.id} value={person.id}>
530
- {personName(person)}
531
- </option>
532
- ))}
533
- </select>
534
- </label>
469
+ <Picker
470
+ value={candidates.find((person) => person.id === personId)}
471
+ search={peopleSearch(candidates)}
472
+ keyOf={(person) => person.id}
473
+ render={(person) => <PersonChip person={person} size="sm" />}
474
+ label={members.person}
475
+ placeholder={members.chooseSomeone}
476
+ disabled={props.busy === true}
477
+ onChange={(person) => setPersonId(person?.id ?? "")}
478
+ />
479
+ </div>
535
480
  <label className="sc-field">
536
481
  <span className="sc-field-label">{members.as}</span>
537
482
  <select
@@ -0,0 +1,47 @@
1
+ import type { RestGoal, RestPerson, RestProject } from "../api.js";
2
+ import type { PickerSearch } from "./picker.js";
3
+ import { personName } from "./ui.js";
4
+
5
+ // What a picker asks for, answered from a listing the page already holds (ADR-0037): the people,
6
+ // the projects or the goals the page was given, found by the words typed, a page at a time, so
7
+ // that choosing one among many is the same wherever one is chosen, and the board's filters, a
8
+ // setting, a manager or a worker never offer a list too long to read.
9
+
10
+ /** A page of `items` found by the words typed, as a picker searches; everything while the words are empty. */
11
+ export function listSearch<T>(
12
+ items: readonly T[],
13
+ matches: (item: T, words: string) => boolean,
14
+ ): PickerSearch<T> {
15
+ return async (query, page) => {
16
+ const words = query.trim().toLowerCase();
17
+ const found = words.length === 0 ? items : items.filter((item) => matches(item, words));
18
+ return {
19
+ items: found.slice(page.offset, page.offset + page.limit),
20
+ more: found.length > page.offset + page.limit,
21
+ };
22
+ };
23
+ }
24
+
25
+ /** The people given, found by name or login. */
26
+ export function peopleSearch(people: readonly RestPerson[]): PickerSearch<RestPerson> {
27
+ return listSearch(
28
+ people,
29
+ (person, words) =>
30
+ personName(person).toLowerCase().includes(words) ||
31
+ person.login.toLowerCase().includes(words),
32
+ );
33
+ }
34
+
35
+ /** The projects given, found by name or key. */
36
+ export function projectsSearch(projects: readonly RestProject[]): PickerSearch<RestProject> {
37
+ return listSearch(
38
+ projects,
39
+ (project, words) =>
40
+ project.name.toLowerCase().includes(words) || project.key.toLowerCase().includes(words),
41
+ );
42
+ }
43
+
44
+ /** The goals given, found by title. */
45
+ export function goalsSearch(goals: readonly RestGoal[]): PickerSearch<RestGoal> {
46
+ return listSearch(goals, (goal, words) => goal.title.toLowerCase().includes(words));
47
+ }
@@ -16,7 +16,8 @@ import {
16
16
  } from "../api.js";
17
17
  import { useWords } from "../i18n/index.js";
18
18
  import { Picker, type PickerSearch } from "./picker.js";
19
- import { Button, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
19
+ import { peopleSearch } from "./searches.js";
20
+ import { Button, Chip, GoalStateBadge, PersonChip, personName, StateBadge } from "./ui.js";
20
21
 
21
22
  // Writing a task, or changing one. The bounds are the package's, said before a request is made.
22
23
  // The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
@@ -222,37 +223,31 @@ export function TaskForm(props: TaskFormProps) {
222
223
  <legend className="sc-field-label">{form.workers}</legend>
223
224
  <div className="sc-chips">
224
225
  {chosenWorkers.map((person) => (
225
- <span key={person.id} className="sc-chip">
226
+ <Chip
227
+ key={person.id}
228
+ onRemove={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
229
+ removeLabel={`${words.common.remove}: ${personName(person)}`}
230
+ >
226
231
  <PersonChip person={person} />
227
- <button
228
- type="button"
229
- className="sc-chip-remove"
230
- aria-label={`${words.common.remove}: ${personName(person)}`}
231
- onClick={() => setWorkerIds(workerIds.filter((id) => id !== person.id))}
232
- >
233
- <span aria-hidden="true">&times;</span>
234
- </button>
235
- </span>
232
+ </Chip>
236
233
  ))}
237
- {chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>}
238
- {otherPeople.length > 0 && workerIds.length < MAX_WORKERS && (
239
- <select
240
- className="sc-input sc-chip-add"
241
- aria-label={form.addWorker}
242
- value=""
243
- onChange={(event) => {
244
- if (event.target.value !== "") {
245
- setWorkerIds([...workerIds, event.target.value]);
234
+ {otherPeople.length > 0 && workerIds.length < MAX_WORKERS ? (
235
+ <Picker
236
+ className="sc-picker-add"
237
+ value={undefined}
238
+ search={peopleSearch(otherPeople)}
239
+ keyOf={(person) => person.id}
240
+ render={(person) => <PersonChip person={person} size="sm" />}
241
+ label={form.addWorker}
242
+ placeholder={form.addWorker}
243
+ onChange={(person) => {
244
+ if (person !== undefined) {
245
+ setWorkerIds([...workerIds, person.id]);
246
246
  }
247
247
  }}
248
- >
249
- <option value="">{form.addWorker}</option>
250
- {otherPeople.map((person) => (
251
- <option key={person.id} value={person.id}>
252
- {personName(person)}
253
- </option>
254
- ))}
255
- </select>
248
+ />
249
+ ) : (
250
+ chosenWorkers.length === 0 && <span className="sc-muted">{words.common.nobody}</span>
256
251
  )}
257
252
  </div>
258
253
  </fieldset>
@@ -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
  }
@@ -160,6 +164,42 @@ export function PersonChip(props: { readonly person: RestPerson; readonly size?:
160
164
  );
161
165
  }
162
166
 
167
+ /**
168
+ * A thing named among others in a row (`sc-chips`): a person (a `PersonChip` inside), a team, an
169
+ * item of a list, a holder of a role. Given `onRemove`, it carries the way to take it out, named
170
+ * for a screen reader by `removeLabel`; left out, the chip is read, not changed. Every row of
171
+ * things a person adds to and takes from is made of these, so that they read alike everywhere.
172
+ */
173
+ export function Chip(props: {
174
+ readonly children: ReactNode;
175
+ readonly onRemove?: (() => void) | undefined;
176
+ /** What the way out says to a screen reader: the word for removing, and what the chip names. */
177
+ readonly removeLabel?: string | undefined;
178
+ readonly disabled?: boolean | undefined;
179
+ readonly title?: string | undefined;
180
+ /** A class of the chip's own beside `sc-chip`. */
181
+ readonly className?: string | undefined;
182
+ }) {
183
+ const words = useWords().common;
184
+ return (
185
+ <span className={cx("sc-chip", props.className)} title={props.title}>
186
+ <span className="sc-chip-label">{props.children}</span>
187
+ {props.onRemove !== undefined && (
188
+ <button
189
+ type="button"
190
+ className="sc-chip-remove"
191
+ aria-label={props.removeLabel ?? words.remove}
192
+ title={props.removeLabel ?? words.remove}
193
+ disabled={props.disabled}
194
+ onClick={props.onRemove}
195
+ >
196
+ <span aria-hidden="true">&times;</span>
197
+ </button>
198
+ )}
199
+ </span>
200
+ );
201
+ }
202
+
163
203
  /** How many of a task's workers are shown as pictures before the rest are a count. */
164
204
  const WORKERS_SHOWN = 3;
165
205
 
@@ -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">