@skillcdn/console 0.1.17 → 0.1.19

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 (154) hide show
  1. package/README.md +4 -4
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +3 -2
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +2 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +11 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +496 -51
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +11 -2
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +114 -5
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +49 -25
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/cli/install.d.ts +21 -0
  27. package/dist/cli/install.d.ts.map +1 -0
  28. package/dist/cli/install.js +87 -0
  29. package/dist/cli/install.js.map +1 -0
  30. package/dist/cli/main.js +22 -3
  31. package/dist/cli/main.js.map +1 -1
  32. package/dist/cli/notices.d.ts +13 -0
  33. package/dist/cli/notices.d.ts.map +1 -0
  34. package/dist/cli/notices.js +31 -0
  35. package/dist/cli/notices.js.map +1 -0
  36. package/dist/client.d.ts +36 -3
  37. package/dist/client.d.ts.map +1 -1
  38. package/dist/client.js +22 -4
  39. package/dist/client.js.map +1 -1
  40. package/dist/compat.d.ts +66 -0
  41. package/dist/compat.d.ts.map +1 -0
  42. package/dist/compat.js +115 -0
  43. package/dist/compat.js.map +1 -0
  44. package/dist/components/board.d.ts +9 -1
  45. package/dist/components/board.d.ts.map +1 -1
  46. package/dist/components/board.js +82 -5
  47. package/dist/components/board.js.map +1 -1
  48. package/dist/components/calendar.d.ts +3 -1
  49. package/dist/components/calendar.d.ts.map +1 -1
  50. package/dist/components/calendar.js +21 -2
  51. package/dist/components/calendar.js.map +1 -1
  52. package/dist/components/comments.d.ts.map +1 -1
  53. package/dist/components/comments.js +12 -4
  54. package/dist/components/comments.js.map +1 -1
  55. package/dist/components/event-feed.d.ts.map +1 -1
  56. package/dist/components/event-feed.js +4 -0
  57. package/dist/components/event-feed.js.map +1 -1
  58. package/dist/components/goals.d.ts +20 -2
  59. package/dist/components/goals.d.ts.map +1 -1
  60. package/dist/components/goals.js +50 -13
  61. package/dist/components/goals.js.map +1 -1
  62. package/dist/components/markdown.d.ts +6 -0
  63. package/dist/components/markdown.d.ts.map +1 -1
  64. package/dist/components/markdown.js +7 -0
  65. package/dist/components/markdown.js.map +1 -1
  66. package/dist/components/pictures.d.ts +68 -0
  67. package/dist/components/pictures.d.ts.map +1 -0
  68. package/dist/components/pictures.js +213 -0
  69. package/dist/components/pictures.js.map +1 -0
  70. package/dist/components/projects.d.ts +12 -0
  71. package/dist/components/projects.d.ts.map +1 -1
  72. package/dist/components/projects.js +15 -0
  73. package/dist/components/projects.js.map +1 -1
  74. package/dist/components/roadmap.d.ts +37 -0
  75. package/dist/components/roadmap.d.ts.map +1 -0
  76. package/dist/components/roadmap.js +76 -0
  77. package/dist/components/roadmap.js.map +1 -0
  78. package/dist/components/task-view.d.ts +3 -0
  79. package/dist/components/task-view.d.ts.map +1 -1
  80. package/dist/components/task-view.js +6 -4
  81. package/dist/components/task-view.js.map +1 -1
  82. package/dist/components/tokens.d.ts +2 -0
  83. package/dist/components/tokens.d.ts.map +1 -1
  84. package/dist/components/tokens.js +13 -3
  85. package/dist/components/tokens.js.map +1 -1
  86. package/dist/console.css +486 -3
  87. package/dist/console.d.ts.map +1 -1
  88. package/dist/console.js +132 -17
  89. package/dist/console.js.map +1 -1
  90. package/dist/data.d.ts +16 -1
  91. package/dist/data.d.ts.map +1 -1
  92. package/dist/data.js +48 -3
  93. package/dist/data.js.map +1 -1
  94. package/dist/i18n/en.d.ts +76 -0
  95. package/dist/i18n/en.d.ts.map +1 -1
  96. package/dist/i18n/en.js +74 -2
  97. package/dist/i18n/en.js.map +1 -1
  98. package/dist/i18n/ko.d.ts.map +1 -1
  99. package/dist/i18n/ko.js +74 -2
  100. package/dist/i18n/ko.js.map +1 -1
  101. package/dist/index.d.ts +6 -4
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +6 -4
  104. package/dist/index.js.map +1 -1
  105. package/dist/router.d.ts +31 -7
  106. package/dist/router.d.ts.map +1 -1
  107. package/dist/router.js +48 -8
  108. package/dist/router.js.map +1 -1
  109. package/dist/rules.js +5 -5
  110. package/dist/rules.js.map +1 -1
  111. package/dist/schemas.d.ts +200 -0
  112. package/dist/schemas.d.ts.map +1 -1
  113. package/dist/schemas.js +96 -0
  114. package/dist/schemas.js.map +1 -1
  115. package/dist/vocabulary.d.ts +2 -2
  116. package/dist/vocabulary.d.ts.map +1 -1
  117. package/dist/vocabulary.js +3 -0
  118. package/dist/vocabulary.js.map +1 -1
  119. package/docs/specs/cli.md +26 -15
  120. package/docs/specs/rest.md +31 -9
  121. package/package.json +3 -2
  122. package/skills/working-the-board/SKILL.md +11 -4
  123. package/src/api.ts +36 -0
  124. package/src/cli/cli.ts +616 -54
  125. package/src/cli/credentials.ts +276 -14
  126. package/src/cli/directory.ts +43 -6
  127. package/src/cli/format.ts +146 -5
  128. package/src/cli/help.ts +51 -25
  129. package/src/cli/install.ts +111 -0
  130. package/src/cli/main.ts +23 -3
  131. package/src/cli/notices.ts +48 -0
  132. package/src/client.ts +91 -6
  133. package/src/compat.ts +173 -0
  134. package/src/components/board.tsx +154 -8
  135. package/src/components/calendar.tsx +41 -1
  136. package/src/components/comments.tsx +20 -10
  137. package/src/components/event-feed.tsx +4 -0
  138. package/src/components/goals.tsx +234 -88
  139. package/src/components/markdown.tsx +19 -0
  140. package/src/components/pictures.tsx +389 -0
  141. package/src/components/projects.tsx +68 -0
  142. package/src/components/roadmap.tsx +242 -0
  143. package/src/components/task-view.tsx +43 -2
  144. package/src/components/tokens.tsx +38 -1
  145. package/src/console.tsx +278 -4
  146. package/src/data.ts +73 -2
  147. package/src/i18n/en.ts +77 -2
  148. package/src/i18n/ko.ts +77 -2
  149. package/src/index.ts +35 -0
  150. package/src/router.ts +70 -11
  151. package/src/rules.ts +5 -5
  152. package/src/schemas.ts +117 -0
  153. package/src/styles/console.css +486 -3
  154. package/src/vocabulary.ts +3 -0
package/src/compat.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Whether the command and the console go together (ADR-0025). The command names its version on
2
+ // every request, in the product token of its user-agent; the console answers every request of
3
+ // the API with its own version and the range of command versions it takes, and refuses a
4
+ // command outside the range with `426` and the words to update. One rule for both sides, kept
5
+ // here so that the command and the console read the same one, and narrowed by the console's
6
+ // policy below only, never widened: what the rule promises stays predictable.
7
+
8
+ /** The product token the command names itself with: `skillcdn-console/<version>`. */
9
+ export const CLIENT_PRODUCT = "skillcdn-console";
10
+ /** On every answer of the API: the console's version. */
11
+ export const VERSION_HEADER = "console-version";
12
+ /** On every answer of the API: the range of command versions the console takes, as npm spells one. */
13
+ export const CLIENT_RANGE_HEADER = "console-client-range";
14
+
15
+ export interface Version {
16
+ readonly major: number;
17
+ readonly minor: number;
18
+ readonly patch: number;
19
+ }
20
+
21
+ /** `x.y.z`, each a number of at most nine digits without a leading zero; no tag, no build. */
22
+ const VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
23
+ const MAX_VERSION_LENGTH = 32;
24
+
25
+ /** A version as the package spells one, or nothing for any other text. */
26
+ export function parseVersion(text: string | null | undefined): Version | undefined {
27
+ if (typeof text !== "string" || text.length > MAX_VERSION_LENGTH) {
28
+ return undefined;
29
+ }
30
+ const match = VERSION.exec(text);
31
+ return match === null
32
+ ? undefined
33
+ : { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
34
+ }
35
+
36
+ export function formatVersion(version: Version): string {
37
+ return `${version.major}.${version.minor}.${version.patch}`;
38
+ }
39
+
40
+ /** Negative when `a` is the older, zero when they are the same, positive when `a` is the newer. */
41
+ export function compareVersions(a: Version, b: Version): number {
42
+ return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
43
+ }
44
+
45
+ /** A half-open range of versions: from `atLeast` on, and before `below`. */
46
+ export interface VersionRange {
47
+ readonly atLeast: Version;
48
+ readonly below: Version;
49
+ }
50
+
51
+ /** The range as npm spells one: `>=0.1.18 <0.2.0`. */
52
+ export function formatRange(range: VersionRange): string {
53
+ return `>=${formatVersion(range.atLeast)} <${formatVersion(range.below)}`;
54
+ }
55
+
56
+ const RANGE = /^>=(\S+) <(\S+)$/;
57
+ const MAX_RANGE_LENGTH = 2 * MAX_VERSION_LENGTH + 4;
58
+
59
+ /** A range as `formatRange` spells one, or nothing for any other text. */
60
+ export function parseRange(text: string | null | undefined): VersionRange | undefined {
61
+ if (typeof text !== "string" || text.length > MAX_RANGE_LENGTH) {
62
+ return undefined;
63
+ }
64
+ const match = RANGE.exec(text);
65
+ const atLeast = parseVersion(match?.[1]);
66
+ const below = parseVersion(match?.[2]);
67
+ return atLeast !== undefined && below !== undefined && compareVersions(atLeast, below) < 0
68
+ ? { atLeast, below }
69
+ : undefined;
70
+ }
71
+
72
+ export function inRange(version: Version, range: VersionRange): boolean {
73
+ return compareVersions(version, range.atLeast) >= 0 && compareVersions(version, range.below) < 0;
74
+ }
75
+
76
+ /**
77
+ * The common rule. Before 1.0, a command from the console's own version on and within the
78
+ * console's minor (`^0.1.18`): any release may change the contract until then (ADR-0007), so the
79
+ * console's own version is the floor, and the minor is the line. From 1.0, the console's major
80
+ * and a minor within one of the console's, either way, whatever the patch: a minor adds and a
81
+ * major breaks, as the version promises from then on.
82
+ */
83
+ export function commonRange(server: Version): VersionRange {
84
+ if (server.major === 0) {
85
+ return { atLeast: server, below: { major: 0, minor: server.minor + 1, patch: 0 } };
86
+ }
87
+ return {
88
+ atLeast: { major: server.major, minor: Math.max(0, server.minor - 1), patch: 0 },
89
+ below: { major: server.major, minor: server.minor + 2, patch: 0 },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * What the console narrows the common rule by, and nothing it widens it by: a floor above the
95
+ * rule's, for a contract that moved within what the rule still allows, and versions refused
96
+ * whatever the rule says, for a release whose command is wrong. Changed with a changeset that
97
+ * says why.
98
+ */
99
+ export interface ClientPolicy {
100
+ /** The oldest command this console answers, when the common rule's floor is too low. */
101
+ readonly atLeast?: string | undefined;
102
+ /** Versions of the command refused outright. */
103
+ readonly refused?: readonly string[] | undefined;
104
+ }
105
+
106
+ /** The console's policy today: the common rule as it is. */
107
+ export const CLIENT_POLICY: ClientPolicy = {};
108
+
109
+ /** The range of command versions a console of `server` takes: the common rule, narrowed by the policy's floor where that floor is within it. */
110
+ export function clientRange(server: Version, policy: ClientPolicy = CLIENT_POLICY): VersionRange {
111
+ const common = commonRange(server);
112
+ const floor = parseVersion(policy.atLeast);
113
+ return floor !== undefined &&
114
+ compareVersions(floor, common.atLeast) > 0 &&
115
+ compareVersions(floor, common.below) < 0
116
+ ? { atLeast: floor, below: common.below }
117
+ : common;
118
+ }
119
+
120
+ /**
121
+ * What a command of one version is to a console of another: within the range and the same
122
+ * (`current`), older (`behind`) or newer (`ahead`), which the console answers; or outside it,
123
+ * older (`outdated`) or newer (`too_new`), which the console refuses.
124
+ */
125
+ export type ClientVerdict = "current" | "behind" | "ahead" | "outdated" | "too_new";
126
+
127
+ export interface ClientJudgement {
128
+ readonly verdict: ClientVerdict;
129
+ readonly range: VersionRange;
130
+ }
131
+
132
+ export function judgeClient(
133
+ client: Version,
134
+ server: Version,
135
+ policy: ClientPolicy = CLIENT_POLICY,
136
+ ): ClientJudgement {
137
+ const range = clientRange(server, policy);
138
+ const order = compareVersions(client, server);
139
+ const refused = (policy.refused ?? []).some((text) => {
140
+ const version = parseVersion(text);
141
+ return version !== undefined && compareVersions(version, client) === 0;
142
+ });
143
+ if (refused) {
144
+ return { verdict: order > 0 ? "too_new" : "outdated", range };
145
+ }
146
+ if (compareVersions(client, range.atLeast) < 0) {
147
+ return { verdict: "outdated", range };
148
+ }
149
+ if (compareVersions(client, range.below) >= 0) {
150
+ return { verdict: "too_new", range };
151
+ }
152
+ return { verdict: order === 0 ? "current" : order < 0 ? "behind" : "ahead", range };
153
+ }
154
+
155
+ /** What the command sends as its user-agent: its product token, and, in a comment, where it runs. */
156
+ export function userAgentOf(version: string, comment?: string): string {
157
+ // A comment holds printable ASCII without parentheses or backslashes; nothing else is said.
158
+ const safe = comment?.replaceAll(/[^\x20-\x27\x2a-\x5b\x5d-\x7e]/g, "").trim();
159
+ return `${CLIENT_PRODUCT}/${version}${safe === undefined || safe.length === 0 ? "" : ` (${safe})`}`;
160
+ }
161
+
162
+ const MAX_USER_AGENT_LENGTH = 512;
163
+ const CLIENT_TOKEN = new RegExp(
164
+ `(?:^|\\s)${CLIENT_PRODUCT}/(\\d{1,9}\\.\\d{1,9}\\.\\d{1,9})(?![\\w.-])`,
165
+ );
166
+
167
+ /** The version of the command a request's user-agent names, or nothing for any other client. */
168
+ export function clientVersionOf(userAgent: string | null | undefined): Version | undefined {
169
+ if (typeof userAgent !== "string") {
170
+ return undefined;
171
+ }
172
+ return parseVersion(CLIENT_TOKEN.exec(userAgent.slice(0, MAX_USER_AGENT_LENGTH))?.[1]);
173
+ }
@@ -1,4 +1,11 @@
1
- import type { ReactNode } from "react";
1
+ import {
2
+ type ReactNode,
3
+ type PointerEvent as ReactPointerEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
2
9
  import {
3
10
  type RestCategory,
4
11
  type RestGoal,
@@ -23,8 +30,10 @@ import {
23
30
  // The board: one column per state, the tasks in it, the sooner due first and the newest after.
24
31
  // A card says what a person needs at a glance and nothing more: the number and the title, the
25
32
  // category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
26
- // said, since the board cannot know (ADR-0020); the task's page shows its runs. It takes its
27
- // data as props and nothing from the network; what it is told on a click goes back up.
33
+ // said, since the board cannot know (ADR-0020); the task's page shows its runs. A card is moved
34
+ // to another column by its grip, with a mouse or a finger, and by the select on it with the
35
+ // keyboard; either way the move goes back up and the board is told. It takes its data as props
36
+ // and nothing from the network; what it is told on a click goes back up.
28
37
 
29
38
  export interface BoardProps {
30
39
  readonly tasks: readonly RestTask[];
@@ -37,6 +46,31 @@ export interface BoardProps {
37
46
  readonly today?: string | undefined;
38
47
  /** What a column shows when it has nothing; the way to write the first task, for example. */
39
48
  readonly empty?: ReactNode;
49
+ /** Where the tasks put away are (ADR-0029); given, the done and dropped columns lead there. */
50
+ readonly archiveHref?: string | undefined;
51
+ }
52
+
53
+ /** How far a pointer travels on the grip before the card is dragged, and not pressed. */
54
+ const DRAG_SLACK = 4;
55
+ /** How near the pointer comes to the board's edge before the board scrolls to show more columns. */
56
+ const EDGE = 48;
57
+ const EDGE_STEP = 12;
58
+
59
+ /** The state a column stands for, read off its `data-column`; nothing for a value that is not one. */
60
+ export function columnStateOf(value: string | null | undefined): TaskState | undefined {
61
+ return TASK_STATES.find((state) => state === value);
62
+ }
63
+
64
+ /** A card on its way to another column. */
65
+ interface Drag {
66
+ readonly task: RestTask;
67
+ readonly from: { readonly x: number; readonly y: number };
68
+ readonly x: number;
69
+ readonly y: number;
70
+ /** Whether the pointer has travelled far enough for this to be a drag. */
71
+ readonly moving: boolean;
72
+ /** The column under the pointer, or none. */
73
+ readonly over: TaskState | undefined;
40
74
  }
41
75
 
42
76
  export function TaskCard(props: {
@@ -45,11 +79,30 @@ export function TaskCard(props: {
45
79
  readonly onOpen: (task: RestTask) => void;
46
80
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
47
81
  readonly today?: string | undefined;
82
+ /** Called when the card's grip is pressed, to drag it to another column; left out, the card has no grip. */
83
+ readonly onGrip?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
84
+ /** Whether the card is on its way to another column. */
85
+ readonly dragging?: boolean | undefined;
48
86
  }) {
49
87
  const words = useWords();
50
88
  const { task } = props;
51
89
  return (
52
- <article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
90
+ <article
91
+ className={cx("sc-card", props.dragging === true && "sc-card-dragging")}
92
+ aria-label={`#${task.number} ${task.title}`}
93
+ >
94
+ {props.onGrip !== undefined && (
95
+ <button
96
+ type="button"
97
+ className="sc-card-grip"
98
+ title={words.board.grip}
99
+ aria-hidden="true"
100
+ tabIndex={-1}
101
+ onPointerDown={(event) => props.onGrip?.(task, event)}
102
+ >
103
+ ⋮⋮
104
+ </button>
105
+ )}
53
106
  <a
54
107
  className="sc-card-title"
55
108
  href={props.href}
@@ -111,19 +164,101 @@ export function byDue(a: RestTask, b: RestTask): number {
111
164
 
112
165
  export function Board(props: BoardProps) {
113
166
  const words = useWords();
167
+ const { onMove } = props;
168
+ const board = useRef<HTMLDivElement>(null);
169
+ const [drag, setDrag] = useState<Drag | null>(null);
170
+ // What the pointer's release reads, so that the move is made outside a state update.
171
+ const current = useRef<Drag | null>(null);
172
+ current.current = drag;
173
+
174
+ const grip = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
175
+ if (event.pointerType === "mouse" && event.button !== 0) {
176
+ return;
177
+ }
178
+ // The press is the drag's beginning and nothing else: no text is selected on the way.
179
+ event.preventDefault();
180
+ const from = { x: event.clientX, y: event.clientY };
181
+ setDrag({ task, from, x: from.x, y: from.y, moving: false, over: undefined });
182
+ }, []);
183
+
184
+ // While a card is held, the pointer is followed over the whole document: the column under it
185
+ // is marked, the board scrolls when the pointer nears its edge, and the release moves the task.
186
+ const held = drag !== null;
187
+ useEffect(() => {
188
+ if (!held) {
189
+ return;
190
+ }
191
+ const follow = (event: PointerEvent) => {
192
+ const { clientX: x, clientY: y } = event;
193
+ const under = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]");
194
+ setDrag((before) => {
195
+ if (before === null) {
196
+ return before;
197
+ }
198
+ const moving =
199
+ before.moving || Math.hypot(x - before.from.x, y - before.from.y) > DRAG_SLACK;
200
+ return {
201
+ ...before,
202
+ x,
203
+ y,
204
+ moving,
205
+ over: moving ? columnStateOf(under?.dataset.column) : undefined,
206
+ };
207
+ });
208
+ const element = board.current;
209
+ if (element !== null) {
210
+ const rect = element.getBoundingClientRect();
211
+ if (x < rect.left + EDGE) {
212
+ element.scrollLeft -= EDGE_STEP;
213
+ } else if (x > rect.right - EDGE) {
214
+ element.scrollLeft += EDGE_STEP;
215
+ }
216
+ }
217
+ };
218
+ const release = () => {
219
+ const last = current.current;
220
+ if (last?.moving === true && last.over !== undefined && last.over !== last.task.state) {
221
+ onMove?.(last.task, last.over);
222
+ }
223
+ setDrag(null);
224
+ };
225
+ window.addEventListener("pointermove", follow);
226
+ window.addEventListener("pointerup", release);
227
+ window.addEventListener("pointercancel", release);
228
+ return () => {
229
+ window.removeEventListener("pointermove", follow);
230
+ window.removeEventListener("pointerup", release);
231
+ window.removeEventListener("pointercancel", release);
232
+ };
233
+ }, [held, onMove]);
234
+
235
+ /** The drag under way, once the pointer has travelled; nothing while a card is merely pressed. */
236
+ const dragging = drag?.moving === true ? drag : null;
114
237
  return (
115
- <div className="sc-board">
238
+ <div ref={board} className={cx("sc-board", dragging !== null && "sc-board-dragging")}>
116
239
  {TASK_STATES.map((state) => {
117
240
  const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
118
241
  return (
119
242
  <section
120
243
  key={state}
121
- className={cx("sc-column", `sc-column-${state}`)}
244
+ className={cx(
245
+ "sc-column",
246
+ `sc-column-${state}`,
247
+ dragging?.over === state && "sc-column-over",
248
+ )}
122
249
  aria-label={words.vocabulary.state[state]}
250
+ data-column={state}
123
251
  >
124
252
  <header className="sc-column-header">
125
253
  <h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
126
- <span className="sc-column-count">{tasks.length}</span>
254
+ <span className="sc-column-meta">
255
+ {props.archiveHref !== undefined && (state === "done" || state === "dropped") && (
256
+ <a className="sc-column-archive" href={props.archiveHref}>
257
+ {words.board.archive}
258
+ </a>
259
+ )}
260
+ <span className="sc-column-count">{tasks.length}</span>
261
+ </span>
127
262
  </header>
128
263
  <div className="sc-column-body">
129
264
  {tasks.map((task) => (
@@ -132,8 +267,10 @@ export function Board(props: BoardProps) {
132
267
  task={task}
133
268
  href={props.taskHref(task)}
134
269
  onOpen={props.onOpen}
135
- onMove={props.onMove}
270
+ onMove={onMove}
136
271
  today={props.today}
272
+ onGrip={onMove === undefined ? undefined : grip}
273
+ dragging={dragging?.task.id === task.id}
137
274
  />
138
275
  ))}
139
276
  {tasks.length === 0 && state === "idea" && props.empty}
@@ -141,6 +278,15 @@ export function Board(props: BoardProps) {
141
278
  </section>
142
279
  );
143
280
  })}
281
+ {dragging !== null && (
282
+ <div
283
+ className="sc-drag-ghost"
284
+ aria-hidden="true"
285
+ style={{ transform: `translate(${dragging.x + 12}px, ${dragging.y + 12}px)` }}
286
+ >
287
+ <span className="sc-card-number">#{dragging.task.number}</span> {dragging.task.title}
288
+ </div>
289
+ )}
144
290
  </div>
145
291
  );
146
292
  }
@@ -48,9 +48,11 @@ export interface CalendarProps {
48
48
  readonly today?: string | undefined;
49
49
  /** The workspace's categories, for the legend to say what the colours mean. */
50
50
  readonly categories?: readonly RestCategory[] | undefined;
51
- /** The project's goals, so that the task under the pointer is shown with its goal's title. */
51
+ /** The project's goals, so that the task under the pointer is shown with its goal's title, and, given `goalHref`, so that each goal marks its target day. */
52
52
  readonly goals?: readonly RestGoal[] | undefined;
53
53
  readonly taskHref: (task: RestTask) => string;
54
+ /** Where a goal's page is: given, the goals mark their target days on the month (ADR-0026). */
55
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
54
56
  /** Where the calendar is on another month. */
55
57
  readonly monthHref: (month: string) => string;
56
58
  }
@@ -324,12 +326,32 @@ interface WeekRowProps {
324
326
  readonly pointed: Pointed | null;
325
327
  /** The project's goals, so that the item under the pointer names its goal; none across projects. */
326
328
  readonly goals?: readonly RestGoal[] | undefined;
329
+ /** The goals whose target day is each day, where the goals mark the month. */
330
+ readonly goalsOn?: ReadonlyMap<string, readonly RestGoal[]> | undefined;
331
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
327
332
  /** The pointer, or the focus, arrived on a task's item on a day. */
328
333
  readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
329
334
  /** It left. */
330
335
  readonly onLeave: () => void;
331
336
  }
332
337
 
338
+ /** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
339
+ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly RestGoal[]> {
340
+ const on = new Map<string, RestGoal[]>();
341
+ for (const goal of goals) {
342
+ if (goal.dueOn === null || goal.state === "dropped") {
343
+ continue;
344
+ }
345
+ const list = on.get(goal.dueOn);
346
+ if (list === undefined) {
347
+ on.set(goal.dueOn, [goal]);
348
+ } else {
349
+ list.push(goal);
350
+ }
351
+ }
352
+ return on;
353
+ }
354
+
333
355
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
334
356
  function WeekRow(props: WeekRowProps) {
335
357
  const { week, segments, now } = props;
@@ -361,6 +383,21 @@ function WeekRow(props: WeekRowProps) {
361
383
  )}
362
384
  >
363
385
  <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
386
+ {props.goalHref !== undefined &&
387
+ (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
388
+ <a
389
+ key={goal.id}
390
+ className={cx(
391
+ "sc-calendar-goal",
392
+ `sc-category-${goal.category?.color ?? "gray"}`,
393
+ goal.state === "done" && "sc-calendar-over",
394
+ )}
395
+ href={props.goalHref?.(goal)}
396
+ >
397
+ <span className="sc-calendar-flag" aria-hidden="true" />
398
+ <span className="sc-calendar-task-title">{goal.title}</span>
399
+ </a>
400
+ ))}
364
401
  {Array.from({ length: lanes }, (_, lane) => {
365
402
  const segment = segments.find(
366
403
  (candidate) =>
@@ -435,6 +472,7 @@ export function Calendar(props: CalendarProps) {
435
472
  const firstDay = firstDayOfWeek(tag);
436
473
  const weeks = monthGrid(props.month, firstDay);
437
474
  const on = onDays(props.tasks);
475
+ const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
438
476
  const inMonth = (day: string) => monthOf(day) === props.month;
439
477
  const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
440
478
  const nothing =
@@ -492,6 +530,8 @@ export function Calendar(props: CalendarProps) {
492
530
  taskHref={props.taskHref}
493
531
  pointed={pointed}
494
532
  goals={props.goals}
533
+ goalsOn={goalsOn}
534
+ goalHref={props.goalHref}
495
535
  onPoint={point}
496
536
  onLeave={leave}
497
537
  people
@@ -12,12 +12,14 @@ import {
12
12
  import { useWords } from "../i18n/index.js";
13
13
  import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
14
14
  import { Markdown } from "./markdown.js";
15
+ import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
15
16
  import { RunCard } from "./runs.js";
16
17
  import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
17
18
 
18
19
  // The discussion on a task (ADR-0024): what people and agents said about it, each word with
19
20
  // the files put in it, read in one thread with the runs on the task and the decisions about
20
- // it, in the order they came. Takes its data as props and nothing from the network.
21
+ // it, in the order they came. A picture is a thumbnail that opens large over the page. Takes
22
+ // its data as props and nothing from the network.
21
23
 
22
24
  /** The kinds of picture the pages show in place: what a browser renders and cannot run. */
23
25
  const PICTURE_TYPES: ReadonlySet<string> = new Set([
@@ -48,6 +50,7 @@ export interface CommentCardProps {
48
50
  export function CommentCard(props: CommentCardProps) {
49
51
  const words = useWords();
50
52
  const { comment } = props;
53
+ const pictures = usePicture();
51
54
  const hrefOf = (file: RestCommentFile) => props.fileHref?.(file);
52
55
  // A picture the text names by its file name is shown where the text puts it; the rest of
53
56
  // the files follow the text, pictures in place and everything else as a link.
@@ -77,7 +80,12 @@ export function CommentCard(props: CommentCardProps) {
77
80
  </span>
78
81
  </header>
79
82
  {comment.body.trim().length > 0 && (
80
- <Markdown source={comment.body} docHref={props.docHref} fileSrc={named} />
83
+ <Markdown
84
+ source={comment.body}
85
+ docHref={props.docHref}
86
+ fileSrc={named}
87
+ onOpenPicture={pictures.open}
88
+ />
81
89
  )}
82
90
  {following.length > 0 && (
83
91
  <ul className="sc-comment-files" aria-label={words.comments.files}>
@@ -87,14 +95,15 @@ export function CommentCard(props: CommentCardProps) {
87
95
  if (href !== undefined && isPicture(file.contentType)) {
88
96
  return (
89
97
  <li key={attached.id} className="sc-comment-picture">
90
- <a href={href} rel="noopener nofollow ugc" target="_blank">
91
- <img
92
- src={href}
93
- alt={words.comments.picture(file.name)}
94
- loading="lazy"
95
- decoding="async"
96
- />
97
- </a>
98
+ <PictureThumb
99
+ picture={{
100
+ src: href,
101
+ alt: words.comments.picture(file.name),
102
+ name: file.name,
103
+ size: file.size,
104
+ }}
105
+ onOpen={pictures.open}
106
+ />
98
107
  <span className="sc-muted">
99
108
  {file.name}, {formatBytes(file.size)}
100
109
  </span>
@@ -116,6 +125,7 @@ export function CommentCard(props: CommentCardProps) {
116
125
  })}
117
126
  </ul>
118
127
  )}
128
+ <Lightbox picture={pictures.shown} onClose={pictures.close} />
119
129
  </article>
120
130
  );
121
131
  }
@@ -78,6 +78,10 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
78
78
  return feed.event.taskUpdated(fields, task);
79
79
  case "task.commented":
80
80
  return feed.event.taskCommented(task, data.excerpt);
81
+ case "task.archived":
82
+ return feed.event.taskArchived(task);
83
+ case "task.restored":
84
+ return feed.event.taskRestored(task);
81
85
  case "decision.raised":
82
86
  return feed.event.decisionRaised(question);
83
87
  case "decision.answered":