@skillcdn/console 0.3.2 → 0.4.0

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 (146) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +1 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +1 -1
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +96 -7
  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 +48 -17
  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 +7 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +7 -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/my-decisions.d.ts +20 -0
  48. package/dist/components/my-decisions.d.ts.map +1 -0
  49. package/dist/components/my-decisions.js +36 -0
  50. package/dist/components/my-decisions.js.map +1 -0
  51. package/dist/components/people.d.ts.map +1 -1
  52. package/dist/components/people.js +1 -1
  53. package/dist/components/people.js.map +1 -1
  54. package/dist/components/projects.d.ts.map +1 -1
  55. package/dist/components/projects.js +3 -3
  56. package/dist/components/projects.js.map +1 -1
  57. package/dist/components/roadmap.js +1 -1
  58. package/dist/components/roadmap.js.map +1 -1
  59. package/dist/components/runs.d.ts.map +1 -1
  60. package/dist/components/runs.js +1 -1
  61. package/dist/components/runs.js.map +1 -1
  62. package/dist/components/shell.d.ts.map +1 -1
  63. package/dist/components/shell.js +36 -1
  64. package/dist/components/shell.js.map +1 -1
  65. package/dist/components/task-list.js +1 -1
  66. package/dist/components/task-list.js.map +1 -1
  67. package/dist/components/task-view.d.ts.map +1 -1
  68. package/dist/components/task-view.js +2 -2
  69. package/dist/components/task-view.js.map +1 -1
  70. package/dist/components/ui.d.ts +10 -0
  71. package/dist/components/ui.d.ts.map +1 -1
  72. package/dist/components/ui.js +15 -11
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/console.css +1642 -433
  75. package/dist/console.d.ts.map +1 -1
  76. package/dist/console.js +70 -7
  77. package/dist/console.js.map +1 -1
  78. package/dist/data.d.ts +11 -1
  79. package/dist/data.d.ts.map +1 -1
  80. package/dist/data.js +2 -0
  81. package/dist/data.js.map +1 -1
  82. package/dist/i18n/en.d.ts +34 -0
  83. package/dist/i18n/en.d.ts.map +1 -1
  84. package/dist/i18n/en.js +36 -2
  85. package/dist/i18n/en.js.map +1 -1
  86. package/dist/i18n/ko.d.ts.map +1 -1
  87. package/dist/i18n/ko.js +37 -3
  88. package/dist/i18n/ko.js.map +1 -1
  89. package/dist/index.d.ts +3 -2
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +3 -2
  92. package/dist/index.js.map +1 -1
  93. package/dist/router.d.ts +9 -1
  94. package/dist/router.d.ts.map +1 -1
  95. package/dist/router.js +12 -0
  96. package/dist/router.js.map +1 -1
  97. package/dist/routes.d.ts +3 -1
  98. package/dist/routes.d.ts.map +1 -1
  99. package/dist/routes.js +3 -1
  100. package/dist/routes.js.map +1 -1
  101. package/dist/rules.js +1 -1
  102. package/dist/rules.js.map +1 -1
  103. package/dist/schemas.d.ts +242 -3
  104. package/dist/schemas.d.ts.map +1 -1
  105. package/dist/schemas.js +18 -4
  106. package/dist/schemas.js.map +1 -1
  107. package/docs/specs/cli.md +6 -5
  108. package/docs/specs/rest.md +11 -6
  109. package/package.json +2 -2
  110. package/skills/building-a-console/SKILL.md +3 -3
  111. package/skills/building-a-console/references/the-default-console.md +4 -4
  112. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  113. package/skills/working-the-board/SKILL.md +6 -4
  114. package/skills/working-the-board/references/terms.md +1 -1
  115. package/src/api.ts +2 -0
  116. package/src/cli/cli.ts +123 -7
  117. package/src/cli/format.ts +11 -4
  118. package/src/cli/help.ts +49 -16
  119. package/src/cli/pack.ts +17 -1
  120. package/src/client.ts +21 -1
  121. package/src/compat.ts +2 -2
  122. package/src/components/board.tsx +114 -21
  123. package/src/components/calendar.tsx +207 -102
  124. package/src/components/decision-form.tsx +71 -23
  125. package/src/components/decision-list.tsx +30 -3
  126. package/src/components/documents.tsx +27 -11
  127. package/src/components/event-feed.tsx +116 -33
  128. package/src/components/my-decisions.tsx +138 -0
  129. package/src/components/people.tsx +3 -1
  130. package/src/components/projects.tsx +7 -5
  131. package/src/components/roadmap.tsx +1 -1
  132. package/src/components/runs.tsx +5 -1
  133. package/src/components/shell.tsx +36 -1
  134. package/src/components/task-list.tsx +1 -1
  135. package/src/components/task-view.tsx +48 -47
  136. package/src/components/ui.tsx +64 -11
  137. package/src/console.tsx +129 -27
  138. package/src/data.ts +17 -0
  139. package/src/i18n/en.ts +38 -2
  140. package/src/i18n/ko.ts +39 -3
  141. package/src/index.ts +3 -0
  142. package/src/router.ts +18 -1
  143. package/src/routes.ts +3 -1
  144. package/src/rules.ts +1 -1
  145. package/src/schemas.ts +20 -4
  146. package/src/styles/console.css +1642 -433
@@ -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
  );
@@ -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
+ }
@@ -75,9 +75,11 @@ export function PeopleList(props: PeopleListProps) {
75
75
  {active.map((person) => (
76
76
  <li key={person.id} className="sc-person-row">
77
77
  <PersonChip person={person} size="md" />
78
+ {props.me?.id === person.id && (
79
+ <span className="sc-person-you">{words.common.you}</span>
80
+ )}
78
81
  <span className="sc-person-name sc-muted">
79
82
  {person.name === null ? "" : person.login}
80
- {props.me?.id === person.id && <span> {words.common.you}</span>}
81
83
  <span className="sc-person-note"> {people.source[person.source]}</span>
82
84
  </span>
83
85
  {props.onChangeRole === undefined ? (
@@ -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} />
@@ -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 {
@@ -186,6 +186,41 @@ function PersonMenu(props: {
186
186
 
187
187
  export function Shell(props: ShellProps) {
188
188
  const words = useWords();
189
+ // Where the tabs scroll sideways, the current one is brought into the bar's view.
190
+ const tabs = useRef<HTMLElement>(null);
191
+ const current = props.nav.find((item) => item.current)?.href;
192
+ useEffect(() => {
193
+ const bar = tabs.current;
194
+ if (current === undefined || bar === null) {
195
+ return;
196
+ }
197
+ const bring = () => {
198
+ const tab = bar.querySelector<HTMLElement>(".sc-tab-current");
199
+ if (tab === null) {
200
+ return;
201
+ }
202
+ const left = tab.offsetLeft - bar.offsetLeft;
203
+ // The last part of the bar fades where it scrolls (the stylesheet's mask): a tab there
204
+ // counts as out of view.
205
+ const seen = bar.scrollWidth > bar.clientWidth ? bar.clientWidth * 0.85 : bar.clientWidth;
206
+ if (left < bar.scrollLeft || left + tab.offsetWidth > bar.scrollLeft + seen) {
207
+ bar.scrollLeft = left - (bar.clientWidth - tab.offsetWidth) / 2;
208
+ }
209
+ };
210
+ bring();
211
+ // The tabs grow once the page's face has loaded, and the bar narrows with the window: either
212
+ // may put the current tab out of view again.
213
+ if (typeof ResizeObserver === "undefined") {
214
+ return;
215
+ }
216
+ const watch = new ResizeObserver(bring);
217
+ watch.observe(bar);
218
+ const inner = bar.firstElementChild;
219
+ if (inner !== null) {
220
+ watch.observe(inner);
221
+ }
222
+ return () => watch.disconnect();
223
+ }, [current]);
189
224
  const follow = (href: string) => (event: React.MouseEvent) => {
190
225
  if (isPlainClick(event)) {
191
226
  event.preventDefault();
@@ -259,7 +294,7 @@ export function Shell(props: ShellProps) {
259
294
  </div>
260
295
  </div>
261
296
  {props.nav.length > 0 && (
262
- <nav className="sc-tabs" aria-label={words.shell.pages}>
297
+ <nav ref={tabs} className="sc-tabs" aria-label={words.shell.pages}>
263
298
  <div className="sc-tabs-inner">
264
299
  {props.nav.map((item) => (
265
300
  <a
@@ -45,7 +45,7 @@ export function TaskList(props: TaskListProps) {
45
45
  {task.category !== null && <CategoryBadge category={task.category} />}
46
46
  {props.showState === true && <StateBadge state={task.state} />}
47
47
  {task.openDecisions > 0 && (
48
- <Badge tone="warning" title={words.board.decisionsTitle}>
48
+ <Badge tone="warning" kind="count" title={words.board.decisionsTitle}>
49
49
  {words.board.decisions(task.openDecisions)}
50
50
  </Badge>
51
51
  )}
@@ -142,26 +142,56 @@ export function TaskView(props: TaskViewProps) {
142
142
  return (
143
143
  <div className="sc-task">
144
144
  <header className="sc-task-header">
145
- {parent !== undefined && (
146
- <p className="sc-task-parent">
147
- {words.task.partOf}{" "}
148
- <a href={props.taskHref(parent)}>
149
- #{parent.number} {parent.title}
150
- </a>
151
- </p>
152
- )}
153
- <h1 className="sc-task-title">
154
- <span className="sc-card-number">#{task.number}</span> {task.title}
155
- </h1>
156
- <div className="sc-task-badges">
157
- <StateBadge state={task.state} />
158
- {task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
159
- {task.category !== null && <CategoryBadge category={task.category} />}
160
- <PriorityBadge priority={task.priority} />
161
- {task.dueOn !== null && (
162
- <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
145
+ <div className="sc-task-heading">
146
+ {parent !== undefined && (
147
+ <p className="sc-task-parent">
148
+ {words.task.partOf}{" "}
149
+ <a href={props.taskHref(parent)}>
150
+ #{parent.number} {parent.title}
151
+ </a>
152
+ </p>
163
153
  )}
154
+ <h1 className="sc-task-title">
155
+ <span className="sc-card-number">#{task.number}</span> {task.title}
156
+ </h1>
157
+ <div className="sc-task-badges">
158
+ <StateBadge state={task.state} />
159
+ {task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
160
+ {task.category !== null && <CategoryBadge category={task.category} />}
161
+ <PriorityBadge priority={task.priority} />
162
+ {task.dueOn !== null && (
163
+ <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
164
+ )}
165
+ </div>
164
166
  </div>
167
+ {props.formHref !== undefined && (
168
+ <div className="sc-task-actions">
169
+ <a className="sc-button sc-button-secondary" href={props.formHref("edit")}>
170
+ {words.common.edit}
171
+ </a>
172
+ <a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
173
+ {words.task.raiseDecision}
174
+ </a>
175
+ {props.onArchive !== undefined && task.archivedAt === null && (
176
+ <Button
177
+ variant="secondary"
178
+ disabled={props.busy === true}
179
+ onClick={() => props.onArchive?.(task)}
180
+ >
181
+ {words.task.archive}
182
+ </Button>
183
+ )}
184
+ {props.onRestore !== undefined && task.archivedAt !== null && (
185
+ <Button
186
+ variant="secondary"
187
+ disabled={props.busy === true}
188
+ onClick={() => props.onRestore?.(task)}
189
+ >
190
+ {words.task.restore}
191
+ </Button>
192
+ )}
193
+ </div>
194
+ )}
165
195
  </header>
166
196
  <div className="sc-task-body">
167
197
  <div className="sc-task-main">
@@ -193,7 +223,6 @@ export function TaskView(props: TaskViewProps) {
193
223
  <section className="sc-task-section" aria-label={words.task.raiseDecision}>
194
224
  <h2 className="sc-section-title">{words.task.raiseDecision}</h2>
195
225
  <DecisionForm
196
- tasks={props.tasks}
197
226
  taskId={task.id}
198
227
  busy={props.busy}
199
228
  onSubmit={props.onRaiseDecision}
@@ -360,34 +389,6 @@ export function TaskView(props: TaskViewProps) {
360
389
  </dd>
361
390
  </div>
362
391
  </dl>
363
- {props.formHref !== undefined && (
364
- <div className="sc-task-actions">
365
- <a className="sc-button sc-button-secondary" href={props.formHref("edit")}>
366
- {words.common.edit}
367
- </a>
368
- <a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
369
- {words.task.raiseDecision}
370
- </a>
371
- {props.onArchive !== undefined && task.archivedAt === null && (
372
- <Button
373
- variant="secondary"
374
- disabled={props.busy === true}
375
- onClick={() => props.onArchive?.(task)}
376
- >
377
- {words.task.archive}
378
- </Button>
379
- )}
380
- {props.onRestore !== undefined && task.archivedAt !== null && (
381
- <Button
382
- variant="secondary"
383
- disabled={props.busy === true}
384
- onClick={() => props.onRestore?.(task)}
385
- >
386
- {words.task.restore}
387
- </Button>
388
- )}
389
- </div>
390
- )}
391
392
  </aside>
392
393
  </div>
393
394
  </div>