@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
@@ -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
@@ -1,10 +1,11 @@
1
- import { loginPath, type RestProvider, type SignInFailure } from "../api.js";
1
+ import { type MouseEvent, useEffect, useState } from "react";
2
+ import { loginPath, type ProviderKey, type RestProvider, type SignInFailure } from "../api.js";
2
3
  import { useWords } from "../i18n/index.js";
3
4
  import type { Brand } from "./shell.js";
4
- import { Callout } from "./ui.js";
5
+ import { Callout, cx } from "./ui.js";
5
6
 
6
7
  // The page a person sees before they are anyone: the way to each provider the deployment has,
7
- // and what went wrong the last time, when something did.
8
+ // who gets in through it, and what went wrong the last time, when something did.
8
9
 
9
10
  export interface SignInProps {
10
11
  readonly title: string;
@@ -19,6 +20,35 @@ export interface SignInProps {
19
20
 
20
21
  export function SignIn(props: SignInProps) {
21
22
  const words = useWords().signIn;
23
+ // The way being taken: its button says so from the click until the page leaves, and says so
24
+ // no more when the browser brings this page back from its cache, as on the way back.
25
+ const [taken, setTaken] = useState<ProviderKey | undefined>(undefined);
26
+ useEffect(() => {
27
+ const shown = (event: PageTransitionEvent): void => {
28
+ if (event.persisted) {
29
+ setTaken(undefined);
30
+ }
31
+ };
32
+ window.addEventListener("pageshow", shown);
33
+ return () => window.removeEventListener("pageshow", shown);
34
+ }, []);
35
+ const take = (provider: ProviderKey) => (event: MouseEvent<HTMLAnchorElement>) => {
36
+ if (taken !== undefined) {
37
+ // One way at a time: a second click, on any of the buttons, goes nowhere.
38
+ event.preventDefault();
39
+ return;
40
+ }
41
+ setTaken(provider);
42
+ };
43
+ const admitsOf = (admits: NonNullable<RestProvider["admits"]>): string => {
44
+ if (admits.organization !== null) {
45
+ return words.admits.organization(admits.organization);
46
+ }
47
+ if (admits.domains.length > 0) {
48
+ return words.admits.domains(admits.domains.join(", "));
49
+ }
50
+ return words.admits.listed;
51
+ };
22
52
  return (
23
53
  <div className="sc-sign-in">
24
54
  {props.brand?.symbol !== undefined && (
@@ -44,18 +74,89 @@ export function SignIn(props: SignInProps) {
44
74
  </Callout>
45
75
  ) : (
46
76
  <div className="sc-sign-in-ways">
47
- {props.providers.map((provider) => (
48
- // A real navigation: signing in happens at the provider, not in this page.
49
- <a
50
- key={provider.key}
51
- className="sc-button sc-button-primary sc-sign-in-button"
52
- href={loginPath(provider.key, props.returnTo)}
53
- >
54
- {words.continueWith(provider.label)}
55
- </a>
56
- ))}
77
+ {props.providers.map((provider) => {
78
+ const going = taken === provider.key;
79
+ return (
80
+ <div key={provider.key} className="sc-sign-in-way">
81
+ {/* A real navigation: signing in happens at the provider, not in this page. */}
82
+ <a
83
+ className={cx(
84
+ "sc-button sc-button-primary sc-sign-in-button",
85
+ going && "sc-sign-in-button-going",
86
+ )}
87
+ href={loginPath(provider.key, props.returnTo)}
88
+ aria-busy={going ? true : undefined}
89
+ aria-disabled={taken !== undefined && !going ? true : undefined}
90
+ onClick={take(provider.key)}
91
+ >
92
+ <ProviderMark provider={provider.key} />
93
+ <span className="sc-sign-in-button-label">
94
+ {going ? words.going(provider.label) : words.signInWith(provider.label)}
95
+ </span>
96
+ {going && (
97
+ <span className="sc-spinner sc-sign-in-spinner" aria-hidden="true">
98
+ <span className="sc-spinner-ring" />
99
+ </span>
100
+ )}
101
+ </a>
102
+ {provider.admits !== undefined && (
103
+ <p className="sc-sign-in-admits">{admitsOf(provider.admits)}</p>
104
+ )}
105
+ </div>
106
+ );
107
+ })}
57
108
  </div>
58
109
  )}
59
110
  </div>
60
111
  );
61
112
  }
113
+
114
+ /**
115
+ * The provider's mark, as people know it from sign-in buttons elsewhere, beside the label and
116
+ * decorative: the host's own mark in the button's text color, Google's lettermark on a light
117
+ * tile, since its colors are its own. A provider without a mark shows the label alone.
118
+ */
119
+ function ProviderMark(props: { readonly provider: ProviderKey }) {
120
+ if (props.provider === "gh") {
121
+ return (
122
+ <svg
123
+ className="sc-sign-in-mark"
124
+ viewBox="0 0 16 16"
125
+ width="20"
126
+ height="20"
127
+ aria-hidden="true"
128
+ focusable="false"
129
+ >
130
+ <path
131
+ fill="currentColor"
132
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"
133
+ />
134
+ </svg>
135
+ );
136
+ }
137
+ if (props.provider === "google") {
138
+ return (
139
+ <span className="sc-sign-in-mark sc-sign-in-mark-tile" aria-hidden="true">
140
+ <svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true" focusable="false">
141
+ <path
142
+ fill="#FFC107"
143
+ d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
144
+ />
145
+ <path
146
+ fill="#FF3D00"
147
+ d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"
148
+ />
149
+ <path
150
+ fill="#4CAF50"
151
+ d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"
152
+ />
153
+ <path
154
+ fill="#1976D2"
155
+ d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"
156
+ />
157
+ </svg>
158
+ </span>
159
+ );
160
+ }
161
+ return null;
162
+ }
@@ -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>
@@ -11,6 +11,7 @@ import {
11
11
  } from "../api.js";
12
12
  import { dayOf, daysBetween, formatDay, today } from "../dates.js";
13
13
  import { useLanguage, useWords } from "../i18n/index.js";
14
+ import { useRemotePictureSrc } from "./remote-pictures.js";
14
15
 
15
16
  // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
16
17
  // are prefixed `sc-` so that a custom console's own styles never collide with them. The words
@@ -43,11 +44,23 @@ export function Button(
43
44
 
44
45
  export function Badge(props: {
45
46
  readonly tone?: "neutral" | "accent" | "point" | "success" | "warning" | "danger";
47
+ /** What the badge says, which the stylesheet shows in its shape: a state carries a dot, a priority is outlined, a due day is a square-cornered tag, a count is figures. */
48
+ readonly kind?: "state" | "priority" | "due" | "count" | undefined;
49
+ /** A class of the badge's own beside the tone's and the kind's, for a step within a kind. */
50
+ readonly className?: string | undefined;
46
51
  readonly title?: string;
47
52
  readonly children: ReactNode;
48
53
  }) {
49
54
  return (
50
- <span className={cx("sc-badge", `sc-badge-${props.tone ?? "neutral"}`)} title={props.title}>
55
+ <span
56
+ className={cx(
57
+ "sc-badge",
58
+ `sc-badge-${props.tone ?? "neutral"}`,
59
+ props.kind !== undefined && `sc-badge-kind-${props.kind}`,
60
+ props.className,
61
+ )}
62
+ title={props.title}
63
+ >
51
64
  {props.children}
52
65
  </span>
53
66
  );
@@ -84,13 +97,15 @@ export function personName(person: {
84
97
  }
85
98
 
86
99
  /**
87
- * A person's picture as the git host serves it: loaded lazily, without a referrer, and gone as
88
- * a whole when there is none or it cannot be loaded, leaving the initial of the name instead.
100
+ * A person's picture as the git host serves it, read through the console (ADR-0048): loaded
101
+ * lazily, without a referrer, and gone as a whole when there is none or it cannot be loaded,
102
+ * leaving the initial of the name instead.
89
103
  */
90
104
  export function Avatar(props: {
91
105
  readonly person: RestPerson;
92
106
  readonly size?: "xs" | "sm" | "md" | "lg";
93
107
  }) {
108
+ const remote = useRemotePictureSrc();
94
109
  const size = props.size ?? "md";
95
110
  const initial = personName(props.person).slice(0, 1).toUpperCase();
96
111
  if (props.person.avatar === null) {
@@ -106,7 +121,7 @@ export function Avatar(props: {
106
121
  return (
107
122
  <img
108
123
  className={cx("sc-avatar", `sc-avatar-${size}`)}
109
- src={props.person.avatar}
124
+ src={remote(props.person.avatar)}
110
125
  alt=""
111
126
  loading="lazy"
112
127
  decoding="async"
@@ -195,7 +210,7 @@ export const ROLE_LABELS: Readonly<Record<PersonRole, string>> = {
195
210
  export function RoleBadge(props: { readonly role: PersonRole }) {
196
211
  const words = useWords();
197
212
  return (
198
- <Badge tone={props.role === "admin" ? "point" : "neutral"}>
213
+ <Badge tone={props.role === "admin" ? "accent" : "neutral"}>
199
214
  {words.vocabulary.role[props.role]}
200
215
  </Badge>
201
216
  );
@@ -211,7 +226,11 @@ export function StateBadge(props: { readonly state: TaskState }) {
211
226
  : props.state === "in_progress" || props.state === "in_review"
212
227
  ? "accent"
213
228
  : "neutral";
214
- return <Badge tone={tone}>{words.vocabulary.state[props.state]}</Badge>;
229
+ return (
230
+ <Badge tone={tone} kind="state">
231
+ {words.vocabulary.state[props.state]}
232
+ </Badge>
233
+ );
215
234
  }
216
235
 
217
236
  export function PriorityBadge(props: { readonly priority: TaskPriority }) {
@@ -221,7 +240,11 @@ export function PriorityBadge(props: { readonly priority: TaskPriority }) {
221
240
  }
222
241
  const tone =
223
242
  props.priority === "urgent" ? "danger" : props.priority === "high" ? "point" : "neutral";
224
- return <Badge tone={tone}>{words.vocabulary.priority[props.priority]}</Badge>;
243
+ return (
244
+ <Badge tone={tone} kind="priority">
245
+ {words.vocabulary.priority[props.priority]}
246
+ </Badge>
247
+ );
225
248
  }
226
249
 
227
250
  /** A category as a badge, in its colour: a class the stylesheet paints, never a value typed by hand. */
@@ -245,7 +268,11 @@ export function GoalStateBadge(props: { readonly state: GoalState }) {
245
268
  : props.state === "dropped"
246
269
  ? "neutral"
247
270
  : "neutral";
248
- return <Badge tone={tone}>{words.vocabulary.goalState[props.state]}</Badge>;
271
+ return (
272
+ <Badge tone={tone} kind="state">
273
+ {words.vocabulary.goalState[props.state]}
274
+ </Badge>
275
+ );
249
276
  }
250
277
 
251
278
  /**
@@ -266,29 +293,45 @@ export function DueBadge(props: {
266
293
  const days = daysBetween(now, props.dueOn);
267
294
  const day = formatDay(props.dueOn, tag);
268
295
  if (!open) {
269
- return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
296
+ return (
297
+ <Badge tone="neutral" kind="due">
298
+ {words.board.due.on(day)}
299
+ </Badge>
300
+ );
270
301
  }
271
302
  if (days < 0) {
272
303
  return (
273
- <Badge tone="danger" title={day}>
304
+ <Badge tone="danger" kind="due" title={day}>
274
305
  {words.board.due.overdue(-days)}
275
306
  </Badge>
276
307
  );
277
308
  }
278
309
  if (days === 0) {
279
- return <Badge tone="warning">{words.board.due.today}</Badge>;
310
+ return (
311
+ <Badge tone="point" kind="due" className="sc-badge-due-today">
312
+ {words.board.due.today}
313
+ </Badge>
314
+ );
280
315
  }
281
316
  if (days === 1) {
282
- return <Badge tone="warning">{words.board.due.tomorrow}</Badge>;
317
+ return (
318
+ <Badge tone="point" kind="due">
319
+ {words.board.due.tomorrow}
320
+ </Badge>
321
+ );
283
322
  }
284
323
  if (days <= 7) {
285
324
  return (
286
- <Badge tone="point" title={day}>
325
+ <Badge tone="warning" kind="due" title={day}>
287
326
  {words.board.due.inDays(days)}
288
327
  </Badge>
289
328
  );
290
329
  }
291
- return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
330
+ return (
331
+ <Badge tone="neutral" kind="due">
332
+ {words.board.due.on(day)}
333
+ </Badge>
334
+ );
292
335
  }
293
336
 
294
337
  /** How far a goal is, read from its tasks: the done ones, the ones at work, and the rest. */
@@ -402,3 +445,16 @@ export function Time(props: {
402
445
  </time>
403
446
  );
404
447
  }
448
+
449
+ /** A link that reads as a button: where a form is, for instance. */
450
+ export function LinkButton(props: {
451
+ readonly href: string;
452
+ readonly variant?: "primary" | "secondary";
453
+ readonly children: ReactNode;
454
+ }) {
455
+ return (
456
+ <a className={`sc-button sc-button-${props.variant ?? "secondary"}`} href={props.href}>
457
+ {props.children}
458
+ </a>
459
+ );
460
+ }