@skillcdn/console 0.3.2 → 0.4.1

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 (179) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +132 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +6 -4
  11. package/dist/cli/format.js.map +1 -1
  12. package/dist/cli/help.d.ts.map +1 -1
  13. package/dist/cli/help.js +72 -21
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/pack.d.ts +7 -2
  16. package/dist/cli/pack.d.ts.map +1 -1
  17. package/dist/cli/pack.js +16 -2
  18. package/dist/cli/pack.js.map +1 -1
  19. package/dist/client.d.ts +19 -3
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +11 -1
  22. package/dist/client.js.map +1 -1
  23. package/dist/compat.d.ts +1 -1
  24. package/dist/compat.js +2 -2
  25. package/dist/components/board.d.ts +12 -1
  26. package/dist/components/board.d.ts.map +1 -1
  27. package/dist/components/board.js +52 -4
  28. package/dist/components/board.js.map +1 -1
  29. package/dist/components/calendar.d.ts +4 -0
  30. package/dist/components/calendar.d.ts.map +1 -1
  31. package/dist/components/calendar.js +61 -22
  32. package/dist/components/calendar.js.map +1 -1
  33. package/dist/components/decision-form.d.ts +12 -4
  34. package/dist/components/decision-form.d.ts.map +1 -1
  35. package/dist/components/decision-form.js +24 -6
  36. package/dist/components/decision-form.js.map +1 -1
  37. package/dist/components/decision-list.d.ts +4 -0
  38. package/dist/components/decision-list.d.ts.map +1 -1
  39. package/dist/components/decision-list.js +9 -6
  40. package/dist/components/decision-list.js.map +1 -1
  41. package/dist/components/documents.d.ts.map +1 -1
  42. package/dist/components/documents.js +3 -3
  43. package/dist/components/documents.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +63 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/markdown.d.ts.map +1 -1
  48. package/dist/components/markdown.js +6 -3
  49. package/dist/components/markdown.js.map +1 -1
  50. package/dist/components/my-decisions.d.ts +20 -0
  51. package/dist/components/my-decisions.d.ts.map +1 -0
  52. package/dist/components/my-decisions.js +36 -0
  53. package/dist/components/my-decisions.js.map +1 -0
  54. package/dist/components/pack-connect.d.ts +3 -1
  55. package/dist/components/pack-connect.d.ts.map +1 -1
  56. package/dist/components/pack-connect.js +4 -4
  57. package/dist/components/pack-connect.js.map +1 -1
  58. package/dist/components/pack-token.d.ts +3 -1
  59. package/dist/components/pack-token.d.ts.map +1 -1
  60. package/dist/components/pack-token.js +1 -1
  61. package/dist/components/pack-token.js.map +1 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +14 -3
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/projects.d.ts.map +1 -1
  66. package/dist/components/projects.js +3 -3
  67. package/dist/components/projects.js.map +1 -1
  68. package/dist/components/remote-pictures.d.ts +6 -0
  69. package/dist/components/remote-pictures.d.ts.map +1 -0
  70. package/dist/components/remote-pictures.js +8 -0
  71. package/dist/components/remote-pictures.js.map +1 -0
  72. package/dist/components/roadmap.js +1 -1
  73. package/dist/components/roadmap.js.map +1 -1
  74. package/dist/components/runs.d.ts.map +1 -1
  75. package/dist/components/runs.js +1 -1
  76. package/dist/components/runs.js.map +1 -1
  77. package/dist/components/shell.d.ts.map +1 -1
  78. package/dist/components/shell.js +36 -1
  79. package/dist/components/shell.js.map +1 -1
  80. package/dist/components/sign-in.d.ts.map +1 -1
  81. package/dist/components/sign-in.js +49 -2
  82. package/dist/components/sign-in.js.map +1 -1
  83. package/dist/components/task-list.js +1 -1
  84. package/dist/components/task-list.js.map +1 -1
  85. package/dist/components/task-view.d.ts.map +1 -1
  86. package/dist/components/task-view.js +2 -2
  87. package/dist/components/task-view.js.map +1 -1
  88. package/dist/components/ui.d.ts +13 -2
  89. package/dist/components/ui.d.ts.map +1 -1
  90. package/dist/components/ui.js +21 -14
  91. package/dist/components/ui.js.map +1 -1
  92. package/dist/console.css +1686 -425
  93. package/dist/console.d.ts.map +1 -1
  94. package/dist/console.js +83 -13
  95. package/dist/console.js.map +1 -1
  96. package/dist/data.d.ts +15 -2
  97. package/dist/data.d.ts.map +1 -1
  98. package/dist/data.js +18 -19
  99. package/dist/data.js.map +1 -1
  100. package/dist/i18n/en.d.ts +48 -1
  101. package/dist/i18n/en.d.ts.map +1 -1
  102. package/dist/i18n/en.js +50 -3
  103. package/dist/i18n/en.js.map +1 -1
  104. package/dist/i18n/ko.d.ts.map +1 -1
  105. package/dist/i18n/ko.js +47 -4
  106. package/dist/i18n/ko.js.map +1 -1
  107. package/dist/index.d.ts +3 -2
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +3 -2
  110. package/dist/index.js.map +1 -1
  111. package/dist/limits.d.ts +14 -0
  112. package/dist/limits.d.ts.map +1 -1
  113. package/dist/limits.js +14 -0
  114. package/dist/limits.js.map +1 -1
  115. package/dist/router.d.ts +9 -1
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +12 -0
  118. package/dist/router.js.map +1 -1
  119. package/dist/routes.d.ts +5 -1
  120. package/dist/routes.d.ts.map +1 -1
  121. package/dist/routes.js +5 -1
  122. package/dist/routes.js.map +1 -1
  123. package/dist/rules.js +1 -1
  124. package/dist/rules.js.map +1 -1
  125. package/dist/schemas.d.ts +272 -4
  126. package/dist/schemas.d.ts.map +1 -1
  127. package/dist/schemas.js +33 -8
  128. package/dist/schemas.js.map +1 -1
  129. package/dist/web.d.ts.map +1 -1
  130. package/dist/web.js +3 -2
  131. package/dist/web.js.map +1 -1
  132. package/docs/specs/cli.md +11 -10
  133. package/docs/specs/rest.md +33 -17
  134. package/package.json +2 -2
  135. package/skills/building-a-console/SKILL.md +5 -5
  136. package/skills/building-a-console/references/the-default-console.md +4 -4
  137. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  138. package/skills/working-the-board/SKILL.md +6 -4
  139. package/skills/working-the-board/references/setting-up.md +1 -1
  140. package/skills/working-the-board/references/terms.md +1 -1
  141. package/src/api.ts +10 -0
  142. package/src/cli/cli.ts +162 -19
  143. package/src/cli/format.ts +11 -4
  144. package/src/cli/help.ts +79 -20
  145. package/src/cli/pack.ts +17 -1
  146. package/src/client.ts +51 -2
  147. package/src/compat.ts +2 -2
  148. package/src/components/board.tsx +114 -21
  149. package/src/components/calendar.tsx +207 -102
  150. package/src/components/decision-form.tsx +71 -23
  151. package/src/components/decision-list.tsx +30 -3
  152. package/src/components/documents.tsx +27 -11
  153. package/src/components/event-feed.tsx +116 -33
  154. package/src/components/markdown.tsx +6 -3
  155. package/src/components/my-decisions.tsx +138 -0
  156. package/src/components/pack-connect.tsx +10 -7
  157. package/src/components/pack-token.tsx +4 -1
  158. package/src/components/people.tsx +20 -8
  159. package/src/components/projects.tsx +7 -5
  160. package/src/components/remote-pictures.ts +19 -0
  161. package/src/components/roadmap.tsx +1 -1
  162. package/src/components/runs.tsx +5 -1
  163. package/src/components/shell.tsx +36 -1
  164. package/src/components/sign-in.tsx +114 -13
  165. package/src/components/task-list.tsx +1 -1
  166. package/src/components/task-view.tsx +48 -47
  167. package/src/components/ui.tsx +70 -14
  168. package/src/console.tsx +153 -32
  169. package/src/data.ts +37 -20
  170. package/src/i18n/en.ts +53 -3
  171. package/src/i18n/ko.ts +50 -4
  172. package/src/index.ts +3 -0
  173. package/src/limits.ts +14 -0
  174. package/src/router.ts +18 -1
  175. package/src/routes.ts +5 -1
  176. package/src/rules.ts +1 -1
  177. package/src/schemas.ts +37 -8
  178. package/src/styles/console.css +1686 -425
  179. package/src/web.ts +3 -2
@@ -440,12 +440,21 @@ export function DocumentFiles(props: DocumentFilesProps) {
440
440
  }
441
441
  }}
442
442
  >
443
- <input
444
- className="sc-input"
445
- type="file"
446
- aria-label={words.fileToAttach}
447
- onChange={(event) => setChosen(event.target.files?.[0] ?? undefined)}
448
- />
443
+ <div className="sc-doc-attach-file">
444
+ <label className="sc-button sc-button-secondary sc-file-choice">
445
+ {words.chooseFile}
446
+ <input
447
+ key={chosen === undefined ? "none" : "some"}
448
+ className="sc-file-input"
449
+ type="file"
450
+ aria-label={words.fileToAttach}
451
+ onChange={(event) => setChosen(event.target.files?.[0] ?? undefined)}
452
+ />
453
+ </label>
454
+ <span className={cx("sc-doc-attach-chosen", chosen === undefined && "sc-muted")}>
455
+ {chosen === undefined ? words.noFileChosen : chosen.name}
456
+ </span>
457
+ </div>
449
458
  <input
450
459
  className="sc-input"
451
460
  placeholder={words.labelPlaceholder}
@@ -453,7 +462,11 @@ export function DocumentFiles(props: DocumentFilesProps) {
453
462
  value={label}
454
463
  onChange={(event) => setLabel(event.target.value)}
455
464
  />
456
- <Button type="submit" size="sm" disabled={chosen === undefined || props.busy === true}>
465
+ <Button
466
+ type="submit"
467
+ variant="primary"
468
+ disabled={chosen === undefined || props.busy === true}
469
+ >
457
470
  {words.attach}
458
471
  </Button>
459
472
  </form>
@@ -555,7 +568,7 @@ export function DocumentView(props: DocumentViewProps) {
555
568
  </dd>
556
569
  </div>
557
570
  <div>
558
- <dt>{docs.writtenBy}</dt>
571
+ <dt>{docs.author}</dt>
559
572
  <dd>
560
573
  <Writer by={shown.author} agent={shown.agent} /> <Time iso={shown.createdAt} />
561
574
  </dd>
@@ -641,9 +654,12 @@ export function DocumentView(props: DocumentViewProps) {
641
654
  >
642
655
  {docs.version(entry.number)}
643
656
  </a>
644
- )}{" "}
645
- {entry.title} · <Writer by={entry.author} agent={entry.agent} />{" "}
646
- <Time iso={entry.createdAt} />
657
+ )}
658
+ <span className="sc-version-title">{entry.title}</span>
659
+ <span className="sc-version-meta">
660
+ <Writer by={entry.author} agent={entry.agent} />{" "}
661
+ <Time iso={entry.createdAt} />
662
+ </span>
647
663
  </li>
648
664
  ))}
649
665
  </ol>
@@ -1,9 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { agentLabel, type RestEvent } from "../api.js";
3
+ import { dayOf } from "../dates.js";
3
4
  import { en, type Messages } from "../i18n/en.js";
4
- import { useWords } from "../i18n/index.js";
5
+ import { useLanguage, useWords } from "../i18n/index.js";
5
6
  import { ShowMore, usePaged } from "./paged.js";
6
- import { PersonChip, Time } from "./ui.js";
7
+ import { formatInstant, PersonChip } from "./ui.js";
7
8
 
8
9
  // The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
9
10
  // as which agent when through one, and what. It takes its data as props and nothing from the
@@ -103,7 +104,9 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
103
104
  ? feed.event.runFinished(task)
104
105
  : data.status === "failed"
105
106
  ? feed.event.runFailed(task)
106
- : feed.event.runAbandoned(task);
107
+ : data.reason === "unheard"
108
+ ? feed.event.runUnheard(task, data.agent ?? feed.someone)
109
+ : feed.event.runAbandoned(task);
107
110
  case "document.written":
108
111
  return feed.event.documentWritten(page, data.version);
109
112
  case "document.archived":
@@ -123,8 +126,65 @@ export interface EventFeedProps {
123
126
  readonly empty?: ReactNode;
124
127
  }
125
128
 
129
+ /** The day an event fell on, by this machine's clock, as the calendar spells a day. */
130
+ function dayOfEvent(event: RestEvent): string {
131
+ const date = new Date(event.createdAt);
132
+ return Number.isNaN(date.getTime()) ? event.createdAt : dayOf(date);
133
+ }
134
+
135
+ /** A day as the feed heads it: the weekday and the date, with the year where it is not this one. */
136
+ function formatFeedDay(day: string, language: string, now: Date = new Date()): string {
137
+ const date = new Date(`${day}T00:00:00`);
138
+ if (Number.isNaN(date.getTime())) {
139
+ return day;
140
+ }
141
+ return date.toLocaleDateString(language, {
142
+ weekday: "short",
143
+ month: "long",
144
+ day: "numeric",
145
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
146
+ });
147
+ }
148
+
149
+ /** Whether two events were done by the same person as the same agent, so the name is said once. */
150
+ function sameHands(a: RestEvent, b: RestEvent): boolean {
151
+ return (a.actor?.id ?? null) === (b.actor?.id ?? null) && a.agent === b.agent;
152
+ }
153
+
154
+ /** The hour of an event, for a row whose day is in the heading above it. */
155
+ function formatClock(iso: string, language: string): string {
156
+ const date = new Date(iso);
157
+ return Number.isNaN(date.getTime())
158
+ ? iso
159
+ : date.toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
160
+ }
161
+
162
+ type FeedRun = { readonly actor: RestEvent; readonly events: RestEvent[] };
163
+ type FeedDay = { readonly day: string; readonly runs: FeedRun[] };
164
+
165
+ /** The events by the day they fell on, and within a day by runs of the same hands; newest first. */
166
+ function groupFeed(events: readonly RestEvent[]): FeedDay[] {
167
+ const days: FeedDay[] = [];
168
+ for (const event of events) {
169
+ const day = dayOfEvent(event);
170
+ let last = days[days.length - 1];
171
+ if (last === undefined || last.day !== day) {
172
+ last = { day, runs: [] };
173
+ days.push(last);
174
+ }
175
+ const run = last.runs[last.runs.length - 1];
176
+ if (run !== undefined && sameHands(run.actor, event)) {
177
+ run.events.push(event);
178
+ } else {
179
+ last.runs.push({ actor: event, events: [event] });
180
+ }
181
+ }
182
+ return days;
183
+ }
184
+
126
185
  export function EventFeed(props: EventFeedProps) {
127
186
  const words = useWords();
187
+ const { tag } = useLanguage();
128
188
  const newestFirst = [...props.events].reverse();
129
189
  const { shown, rest, more } = usePaged(newestFirst);
130
190
  if (props.events.length === 0) {
@@ -132,37 +192,60 @@ export function EventFeed(props: EventFeedProps) {
132
192
  }
133
193
  return (
134
194
  <>
135
- <ol className="sc-feed" aria-label={words.feed.list}>
136
- {shown.map((event) => {
137
- const href = props.href?.(event);
138
- const said = describeEvent(event, words);
139
- return (
140
- <li key={event.id} className="sc-feed-item">
141
- <span className="sc-feed-actor">
142
- {event.actor === null ? (
143
- <span className="sc-person-login">{words.feed.console}</span>
144
- ) : (
145
- <PersonChip person={event.actor} />
146
- )}
147
- {event.agent !== null && (
148
- <span
149
- className="sc-feed-agent"
150
- title={
151
- agentLabel(event.agent) === event.agent ? words.feed.asTitle : event.agent
152
- }
153
- >
154
- {words.feed.as(agentLabel(event.agent))}
195
+ <section className="sc-feed" aria-label={words.feed.list}>
196
+ {groupFeed(shown).map((group) => (
197
+ <section key={group.day} className="sc-feed-day">
198
+ <h3 className="sc-feed-day-title">
199
+ <time dateTime={group.day}>{formatFeedDay(group.day, tag)}</time>
200
+ </h3>
201
+ <ol className="sc-feed-day-list">
202
+ {group.runs.map((run) => (
203
+ <li key={run.actor.id} className="sc-feed-run">
204
+ <span className="sc-feed-actor">
205
+ {run.actor.actor === null ? (
206
+ <span className="sc-person-login">{words.feed.console}</span>
207
+ ) : (
208
+ <PersonChip person={run.actor.actor} />
209
+ )}
210
+ {run.actor.agent !== null && (
211
+ <span
212
+ className="sc-feed-agent"
213
+ title={
214
+ agentLabel(run.actor.agent) === run.actor.agent
215
+ ? words.feed.asTitle
216
+ : run.actor.agent
217
+ }
218
+ >
219
+ {words.feed.as(agentLabel(run.actor.agent))}
220
+ </span>
221
+ )}
155
222
  </span>
156
- )}
157
- </span>
158
- <span className="sc-feed-words">
159
- {href === undefined ? said : <a href={href}>{said}</a>}
160
- </span>
161
- <Time iso={event.createdAt} />
162
- </li>
163
- );
164
- })}
165
- </ol>
223
+ <ol className="sc-feed-run-list">
224
+ {run.events.map((event) => {
225
+ const href = props.href?.(event);
226
+ const said = describeEvent(event, words);
227
+ return (
228
+ <li key={event.id} className="sc-feed-item">
229
+ <time
230
+ className="sc-time sc-feed-clock"
231
+ dateTime={event.createdAt}
232
+ title={formatInstant(event.createdAt, tag)}
233
+ >
234
+ {formatClock(event.createdAt, tag)}
235
+ </time>
236
+ <span className="sc-feed-words">
237
+ {href === undefined ? said : <a href={href}>{said}</a>}
238
+ </span>
239
+ </li>
240
+ );
241
+ })}
242
+ </ol>
243
+ </li>
244
+ ))}
245
+ </ol>
246
+ </section>
247
+ ))}
248
+ </section>
166
249
  <ShowMore rest={rest} onMore={more} />
167
250
  </>
168
251
  );
@@ -2,11 +2,13 @@ import ReactMarkdown from "react-markdown";
2
2
  import remarkGfm from "remark-gfm";
3
3
  import { isDocumentPath } from "../documents.js";
4
4
  import { type Picture, PictureThumb } from "./pictures.js";
5
+ import { useRemotePictureSrc } from "./remote-pictures.js";
5
6
 
6
7
  // What people and agents write is untrusted. It is rendered as React elements, never as HTML:
7
8
  // raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
8
- // and pictures come over https only, lazily and without a referrer. A link whose destination
9
- // is a document's path (ADR-0009) leads to the document's page, when the page knows where.
9
+ // and a picture from elsewhere, named over https, is read through the console (ADR-0048),
10
+ // lazily and without a referrer. A link whose destination is a document's path (ADR-0009)
11
+ // leads to the document's page, when the page knows where.
10
12
 
11
13
  const SAFE_LINK = /^(https?:|mailto:)/i;
12
14
  const SAFE_IMAGE = /^https:\/\//i;
@@ -29,6 +31,7 @@ export interface MarkdownProps {
29
31
  }
30
32
 
31
33
  export function Markdown(props: MarkdownProps) {
34
+ const remote = useRemotePictureSrc();
32
35
  return (
33
36
  <div className="sc-prose">
34
37
  <ReactMarkdown
@@ -62,7 +65,7 @@ export function Markdown(props: MarkdownProps) {
62
65
  img({ src, alt, title }) {
63
66
  const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
64
67
  const url =
65
- named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
68
+ named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? remote(src) : undefined);
66
69
  if (url === undefined) {
67
70
  return <span className="sc-prose-image-text">{alt ?? ""}</span>;
68
71
  }
@@ -0,0 +1,138 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RestAnswerInput, RestDecision, RestMyDecisions } from "../api.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { DecisionCard, DecisionLog } from "./decision-list.js";
5
+ import { ProjectMark } from "./projects.js";
6
+ import { Button } from "./ui.js";
7
+
8
+ // A person's decisions (ADR-0042), across the projects they may see, laid out as a project's
9
+ // decisions page is: in the first column the decisions that wait for their answer, as cards
10
+ // answered here, and under them the decisions they asked of someone else, as cards that say
11
+ // whom they wait for; in the second the record, the answered ones of both kinds, newest first.
12
+ // Every card and row names the project, and who asked and who answers, as the cards do
13
+ // everywhere. Takes its data as props and nothing from the network; the page reads more when
14
+ // asked.
15
+
16
+ /** One kind of a person's decisions, a page at a time. */
17
+ export interface DecisionsPage {
18
+ readonly items: RestMyDecisions["items"];
19
+ /** Whether there is more after what is shown; `onMore` reads it. */
20
+ readonly more?: boolean | undefined;
21
+ readonly onMore?: (() => void) | undefined;
22
+ }
23
+
24
+ export interface MyDecisionsProps {
25
+ /** The decisions addressed to the person: waiting and answered. */
26
+ readonly addressed: DecisionsPage;
27
+ /** The decisions the person asked of someone else: waiting and answered. */
28
+ readonly raised: DecisionsPage;
29
+ readonly decisionHref: (projectKey: string, decisionId: string) => string;
30
+ /** Called with the person's answer on a decision of theirs, in its project. Left out, a waiting decision is answered on its own page. */
31
+ readonly onAnswer?:
32
+ | ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
33
+ | undefined;
34
+ readonly busy?: boolean | undefined;
35
+ }
36
+
37
+ type Item = RestMyDecisions["items"][number];
38
+
39
+ /** The project a decision is in, as the plate names one. */
40
+ function InProject(props: { readonly project: Item["project"] }) {
41
+ const words = useWords();
42
+ return (
43
+ <span className="sc-task-row-project">
44
+ <ProjectMark project={props.project} size="sm" /> {words.work.inProject(props.project.name)}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ function More(props: {
50
+ readonly page: DecisionsPage;
51
+ readonly label: string;
52
+ readonly busy?: boolean | undefined;
53
+ }) {
54
+ const { page } = props;
55
+ if (page.more !== true || page.onMore === undefined) {
56
+ return null;
57
+ }
58
+ return (
59
+ <div className="sc-paged-more">
60
+ <Button variant="secondary" size="sm" disabled={props.busy} onClick={page.onMore}>
61
+ {props.label}
62
+ </Button>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function MyDecisions(props: MyDecisionsProps) {
68
+ const words = useWords();
69
+ const own = words.myDecisions;
70
+ const { onAnswer } = props;
71
+ const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
72
+ const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
73
+ const record = [...props.addressed.items, ...props.raised.items]
74
+ .filter(({ decision }) => decision.answer !== null)
75
+ .sort((a, b) => (b.decision.answer?.at ?? "").localeCompare(a.decision.answer?.at ?? ""));
76
+ const projectOf = new Map(record.map((item) => [item.decision.id, item.project]));
77
+ const card = (item: Item, answerable: boolean): ReactNode => (
78
+ <DecisionCard
79
+ key={item.decision.id}
80
+ decision={item.decision}
81
+ href={props.decisionHref(item.project.key, item.decision.id)}
82
+ project={<InProject project={item.project} />}
83
+ onAnswer={
84
+ answerable && onAnswer !== undefined
85
+ ? (decision, input) => onAnswer(item.project.key, decision, input)
86
+ : undefined
87
+ }
88
+ busy={props.busy}
89
+ />
90
+ );
91
+ return (
92
+ <div className="sc-decisions">
93
+ <div className="sc-decisions-waiting">
94
+ <section aria-label={own.waiting}>
95
+ <h2 className="sc-section-title">
96
+ {own.waiting} <span className="sc-column-count">{waiting.length}</span>
97
+ </h2>
98
+ {waiting.length === 0 ? (
99
+ <p className="sc-muted">{own.noneWaiting}</p>
100
+ ) : (
101
+ waiting.map((item) => card(item, true))
102
+ )}
103
+ <More page={props.addressed} label={own.more} busy={props.busy} />
104
+ </section>
105
+ <section aria-label={own.raised}>
106
+ <h2 className="sc-section-title">
107
+ {own.raised} <span className="sc-column-count">{asked.length}</span>
108
+ </h2>
109
+ {asked.length === 0 ? (
110
+ <p className="sc-muted">{own.noneRaised}</p>
111
+ ) : (
112
+ asked.map((item) => card(item, false))
113
+ )}
114
+ <More page={props.raised} label={own.more} busy={props.busy} />
115
+ </section>
116
+ </div>
117
+ <section aria-label={words.decisions.record}>
118
+ <h2 className="sc-section-title">
119
+ {words.decisions.record} <span className="sc-column-count">{record.length}</span>
120
+ </h2>
121
+ {record.length === 0 ? (
122
+ <p className="sc-muted">{own.noneAnswered}</p>
123
+ ) : (
124
+ <DecisionLog
125
+ decisions={record.map((item) => item.decision)}
126
+ decisionHref={(decision) =>
127
+ props.decisionHref(projectOf.get(decision.id)?.key ?? "", decision.id)
128
+ }
129
+ projectOf={(decision) => {
130
+ const project = projectOf.get(decision.id);
131
+ return project === undefined ? undefined : <InProject project={project} />;
132
+ }}
133
+ />
134
+ )}
135
+ </section>
136
+ </div>
137
+ );
138
+ }
@@ -6,8 +6,9 @@ import { Button, Time } from "./ui.js";
6
6
  // A private pack connected with a code a person approves (ADR-0036): an owner, or for the
7
7
  // workspace's token an administrator, asks, the console asks the deployment and shows what it
8
8
  // answered, the code and the page, and the person approves there; the page asks the console
9
- // again at the interval the deployment named until the token is kept or the request ended.
10
- // Takes its data through the callbacks and keeps nothing itself.
9
+ // to claim the token at the interval the deployment named, until the token is kept or the
10
+ // request ended; reading how the request stands, when the page opens, changes nothing
11
+ // (ADR-0046). Takes its data through the callbacks and keeps nothing itself.
11
12
 
12
13
  /**
13
14
  * How a connection stands, as the component needs it: what the API answers for a project's
@@ -20,8 +21,10 @@ export type PackConnectState =
20
21
  export interface PackConnectProps {
21
22
  /** Asks the deployment to connect; answers what the person is shown. */
22
23
  readonly onStart: () => Promise<RestPackConnectStarted>;
23
- /** How the connection stands; the token is kept when the person approved. */
24
+ /** How the connection stands, read when the page opens; changes nothing. */
24
25
  readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
26
+ /** Asks the console to claim the token at the deployment: pending still, connected, or ended. */
27
+ readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
25
28
  /** Gives the connection up. */
26
29
  readonly onCancel: () => Promise<void>;
27
30
  /** Called once the token is kept. */
@@ -47,7 +50,7 @@ const MIN_INTERVAL_SECONDS = 2;
47
50
  export function PackConnect(props: PackConnectProps) {
48
51
  const words = useWords();
49
52
  const pack = words.projects.packToken;
50
- const { poll, onConnected } = props;
53
+ const { poll, claim, onConnected } = props;
51
54
  const [shown, setShown] = useState<Shown>({ kind: "idle" });
52
55
  const [problem, setProblem] = useState<string | undefined>(undefined);
53
56
 
@@ -64,7 +67,7 @@ export function PackConnect(props: PackConnectProps) {
64
67
  return () => controller.abort();
65
68
  }, [poll]);
66
69
 
67
- // While waiting, the console is asked again at the interval the deployment named; a new
70
+ // While waiting, the console is asked to claim at the interval the deployment named; a new
68
71
  // answer, pending still, is a new state, which schedules the next asking.
69
72
  useEffect(() => {
70
73
  if (shown.kind !== "waiting") {
@@ -72,7 +75,7 @@ export function PackConnect(props: PackConnectProps) {
72
75
  }
73
76
  const controller = new AbortController();
74
77
  const timer = setTimeout(() => {
75
- poll(controller.signal)
78
+ claim(controller.signal)
76
79
  .then((state) => {
77
80
  if (controller.signal.aborted) {
78
81
  return;
@@ -102,7 +105,7 @@ export function PackConnect(props: PackConnectProps) {
102
105
  controller.abort();
103
106
  clearTimeout(timer);
104
107
  };
105
- }, [shown, poll, onConnected]);
108
+ }, [shown, claim, onConnected]);
106
109
 
107
110
  const start = () => {
108
111
  setProblem(undefined);
@@ -20,8 +20,10 @@ export interface PackTokenWaysProps {
20
20
  readonly busy?: boolean | undefined;
21
21
  /** Asks the deployment to connect; answers what the person is shown. */
22
22
  readonly onStart: () => Promise<RestPackConnectStarted>;
23
- /** How the connection stands; the token is kept when the person approved. */
23
+ /** How the connection stands, read when the page opens; changes nothing. */
24
24
  readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
25
+ /** Asks the console to claim the token at the deployment (ADR-0046). */
26
+ readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
25
27
  /** Gives the connection up. */
26
28
  readonly onCancelConnect: () => Promise<void>;
27
29
  /** Called once the token is kept. */
@@ -46,6 +48,7 @@ export function PackTokenWays(props: PackTokenWaysProps) {
46
48
  <PackConnect
47
49
  onStart={props.onStart}
48
50
  poll={props.poll}
51
+ claim={props.claim}
49
52
  onCancel={props.onCancelConnect}
50
53
  onConnected={props.onConnected}
51
54
  busy={props.busy}
@@ -57,6 +57,19 @@ export function PeopleList(props: PeopleListProps) {
57
57
  person.source !== "organization" &&
58
58
  props.me?.id !== person.id;
59
59
  const organization = props.organization ?? undefined;
60
+ /** What let the person in, by its name: the organization, the domain of their login, or an administrator. */
61
+ const noteOf = (person: RestPerson): string => {
62
+ if (person.source === "organization") {
63
+ return organization === undefined
64
+ ? people.source.organization
65
+ : people.ofOrganization(organization.name);
66
+ }
67
+ if (person.source === "domain") {
68
+ const at = person.login.lastIndexOf("@");
69
+ return at === -1 ? people.source.domain : people.ofDomain(person.login.slice(at + 1));
70
+ }
71
+ return people.source.listed;
72
+ };
60
73
  return (
61
74
  <div className="sc-people-page">
62
75
  {organization !== undefined && (
@@ -75,10 +88,12 @@ export function PeopleList(props: PeopleListProps) {
75
88
  {active.map((person) => (
76
89
  <li key={person.id} className="sc-person-row">
77
90
  <PersonChip person={person} size="md" />
91
+ {props.me?.id === person.id && (
92
+ <span className="sc-person-you">{words.common.you}</span>
93
+ )}
78
94
  <span className="sc-person-name sc-muted">
79
- {person.name === null ? "" : person.login}
80
- {props.me?.id === person.id && <span> {words.common.you}</span>}
81
- <span className="sc-person-note"> {people.source[person.source]}</span>
95
+ {person.name !== null && <>{person.login} · </>}
96
+ <span className="sc-person-note">{noteOf(person)}</span>
82
97
  </span>
83
98
  {props.onChangeRole === undefined ? (
84
99
  <RoleBadge role={person.role} />
@@ -161,12 +176,9 @@ export function PeopleList(props: PeopleListProps) {
161
176
  <li key={person.id} className="sc-person-row sc-person-row-out">
162
177
  <PersonChip person={person} size="md" />
163
178
  <span className="sc-person-name sc-muted">
164
- {person.name === null ? "" : person.login}
179
+ {person.name !== null && <>{person.login} · </>}
165
180
  <span className="sc-person-note">
166
- {" "}
167
- {person.source === "organization"
168
- ? people.leftOrganization
169
- : people.source[person.source]}
181
+ {person.source === "organization" ? people.leftOrganization : noteOf(person)}
170
182
  {person.deactivatedAt !== null && (
171
183
  <>
172
184
  {" "}
@@ -93,7 +93,7 @@ export const VISIBILITY_LABELS: Readonly<Record<ProjectVisibility, string>> = {
93
93
  export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
94
94
  const words = useWords();
95
95
  return (
96
- <Badge tone={props.role === "owner" ? "point" : "neutral"}>
96
+ <Badge tone={props.role === "owner" ? "accent" : "neutral"}>
97
97
  {words.vocabulary.projectRole[props.role]}
98
98
  </Badge>
99
99
  );
@@ -143,7 +143,9 @@ export function ProjectList(props: ProjectListProps) {
143
143
  )}
144
144
  {project.openDecisions > 0 && (
145
145
  <p className="sc-project-meta">
146
- <Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
146
+ <Badge tone="warning" kind="count">
147
+ {words.decisionsWaiting(project.openDecisions)}
148
+ </Badge>
147
149
  </p>
148
150
  )}
149
151
  </li>
@@ -444,11 +446,11 @@ export function MemberList(props: MemberListProps) {
444
446
  {props.members.map((member) => (
445
447
  <li key={member.person.id} className="sc-person-row">
446
448
  <PersonChip person={member.person} size="md" />
449
+ {props.me?.id === member.person.id && (
450
+ <span className="sc-person-you">{words.common.you}</span>
451
+ )}
447
452
  <span className="sc-person-name">
448
453
  {member.person.name ?? ""}
449
- {props.me?.id === member.person.id && (
450
- <span className="sc-muted"> {words.common.you}</span>
451
- )}
452
454
  <span className="sc-muted">
453
455
  {" "}
454
456
  {members.since} <Time iso={member.addedAt} />
@@ -0,0 +1,19 @@
1
+ import { createContext, useContext } from "react";
2
+ import { REST_ROUTES } from "../routes.js";
3
+
4
+ // Where a picture from elsewhere is read (ADR-0048): through the console, which fetches it and
5
+ // keeps it a while, so that the person's browser reaches nobody's server and the pages load
6
+ // nothing from elsewhere. The composition provides the client's way to it; a component outside
7
+ // one reads it at the API's own path, which a console served beside its API answers.
8
+
9
+ /** The address a picture from elsewhere is read at, given the https URL that names it. */
10
+ export type RemotePictureSrc = (url: string) => string;
11
+
12
+ export const defaultRemotePictureSrc: RemotePictureSrc = (url) =>
13
+ `${REST_ROUTES.pictures}?src=${encodeURIComponent(url)}`;
14
+
15
+ export const RemotePicturesContext = createContext<RemotePictureSrc>(defaultRemotePictureSrc);
16
+
17
+ export function useRemotePictureSrc(): RemotePictureSrc {
18
+ return useContext(RemotePicturesContext);
19
+ }
@@ -157,7 +157,7 @@ export function GoalTimeline(props: GoalTimelineProps) {
157
157
  return (
158
158
  <div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
159
159
  <div className="sc-timeline-label">
160
- <a className="sc-timeline-goal" href={props.goalHref(goal)}>
160
+ <a className="sc-timeline-goal" href={props.goalHref(goal)} title={goal.title}>
161
161
  {goal.title}
162
162
  </a>
163
163
  <GoalStateBadge state={goal.state} />
@@ -32,7 +32,11 @@ export function RunStatusBadge(props: { readonly status: RunStatus }) {
32
32
  : props.status === "failed"
33
33
  ? "danger"
34
34
  : "neutral";
35
- return <Badge tone={tone}>{words.vocabulary.runStatus[props.status]}</Badge>;
35
+ return (
36
+ <Badge tone={tone} kind="state">
37
+ {words.vocabulary.runStatus[props.status]}
38
+ </Badge>
39
+ );
36
40
  }
37
41
 
38
42
  export interface RunCardProps {