@skillcdn/console 0.4.2 → 0.4.4

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 (243) hide show
  1. package/README.md +4 -4
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +120 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -2
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +38 -10
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +22 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +10 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +45 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts +1 -1
  21. package/dist/cli/plugin-cli.d.ts.map +1 -1
  22. package/dist/cli/plugin-cli.js +80 -5
  23. package/dist/cli/plugin-cli.js.map +1 -1
  24. package/dist/cli/plugin-init.d.ts +19 -0
  25. package/dist/cli/plugin-init.d.ts.map +1 -1
  26. package/dist/cli/plugin-init.js +141 -3
  27. package/dist/cli/plugin-init.js.map +1 -1
  28. package/dist/client.d.ts +7 -2
  29. package/dist/client.d.ts.map +1 -1
  30. package/dist/client.js +18 -5
  31. package/dist/client.js.map +1 -1
  32. package/dist/compat.js +1 -1
  33. package/dist/components/board.d.ts +8 -0
  34. package/dist/components/board.d.ts.map +1 -1
  35. package/dist/components/board.js +3 -3
  36. package/dist/components/board.js.map +1 -1
  37. package/dist/components/comments.d.ts.map +1 -1
  38. package/dist/components/comments.js +1 -1
  39. package/dist/components/comments.js.map +1 -1
  40. package/dist/components/decision-form.d.ts +8 -1
  41. package/dist/components/decision-form.d.ts.map +1 -1
  42. package/dist/components/decision-form.js +57 -7
  43. package/dist/components/decision-form.js.map +1 -1
  44. package/dist/components/decision-list.d.ts +9 -1
  45. package/dist/components/decision-list.d.ts.map +1 -1
  46. package/dist/components/decision-list.js +53 -7
  47. package/dist/components/decision-list.js.map +1 -1
  48. package/dist/components/event-feed.d.ts.map +1 -1
  49. package/dist/components/event-feed.js +7 -1
  50. package/dist/components/event-feed.js.map +1 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +2 -2
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/home.d.ts +35 -0
  55. package/dist/components/home.d.ts.map +1 -0
  56. package/dist/components/home.js +326 -0
  57. package/dist/components/home.js.map +1 -0
  58. package/dist/components/my-decisions.d.ts +2 -0
  59. package/dist/components/my-decisions.d.ts.map +1 -1
  60. package/dist/components/my-decisions.js +2 -2
  61. package/dist/components/my-decisions.js.map +1 -1
  62. package/dist/components/pack-connect.d.ts.map +1 -1
  63. package/dist/components/pack-connect.js +5 -4
  64. package/dist/components/pack-connect.js.map +1 -1
  65. package/dist/components/people.d.ts +2 -0
  66. package/dist/components/people.d.ts.map +1 -1
  67. package/dist/components/people.js +15 -2
  68. package/dist/components/people.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -12
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +53 -20
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts.map +1 -1
  74. package/dist/components/plugin-settings.js +151 -26
  75. package/dist/components/plugin-settings.js.map +1 -1
  76. package/dist/components/projects.d.ts +0 -8
  77. package/dist/components/projects.d.ts.map +1 -1
  78. package/dist/components/projects.js +1 -13
  79. package/dist/components/projects.js.map +1 -1
  80. package/dist/components/task-view.d.ts +2 -1
  81. package/dist/components/task-view.d.ts.map +1 -1
  82. package/dist/components/task-view.js +2 -1
  83. package/dist/components/task-view.js.map +1 -1
  84. package/dist/components/ui.d.ts.map +1 -1
  85. package/dist/components/ui.js +5 -2
  86. package/dist/components/ui.js.map +1 -1
  87. package/dist/connect-page.d.ts.map +1 -1
  88. package/dist/connect-page.js +5 -6
  89. package/dist/connect-page.js.map +1 -1
  90. package/dist/console.css +764 -192
  91. package/dist/console.d.ts +10 -3
  92. package/dist/console.d.ts.map +1 -1
  93. package/dist/console.js +49 -28
  94. package/dist/console.js.map +1 -1
  95. package/dist/data.d.ts +6 -3
  96. package/dist/data.d.ts.map +1 -1
  97. package/dist/data.js +31 -7
  98. package/dist/data.js.map +1 -1
  99. package/dist/docs-page.d.ts.map +1 -1
  100. package/dist/docs-page.js +6 -5
  101. package/dist/docs-page.js.map +1 -1
  102. package/dist/home-tiles.d.ts +92 -0
  103. package/dist/home-tiles.d.ts.map +1 -0
  104. package/dist/home-tiles.js +294 -0
  105. package/dist/home-tiles.js.map +1 -0
  106. package/dist/i18n/en.d.ts +180 -0
  107. package/dist/i18n/en.d.ts.map +1 -1
  108. package/dist/i18n/en.js +181 -1
  109. package/dist/i18n/en.js.map +1 -1
  110. package/dist/i18n/index.d.ts +4 -2
  111. package/dist/i18n/index.d.ts.map +1 -1
  112. package/dist/i18n/index.js +10 -11
  113. package/dist/i18n/index.js.map +1 -1
  114. package/dist/i18n/ko.d.ts.map +1 -1
  115. package/dist/i18n/ko.js +176 -3
  116. package/dist/i18n/ko.js.map +1 -1
  117. package/dist/index.d.ts +4 -3
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +4 -3
  120. package/dist/index.js.map +1 -1
  121. package/dist/limits.d.ts +8 -0
  122. package/dist/limits.d.ts.map +1 -1
  123. package/dist/limits.js +8 -0
  124. package/dist/limits.js.map +1 -1
  125. package/dist/plugin/bridge.d.ts +44 -3
  126. package/dist/plugin/bridge.d.ts.map +1 -1
  127. package/dist/plugin/bridge.js +26 -0
  128. package/dist/plugin/bridge.js.map +1 -1
  129. package/dist/plugin/capabilities.d.ts +7 -1
  130. package/dist/plugin/capabilities.d.ts.map +1 -1
  131. package/dist/plugin/capabilities.js +14 -1
  132. package/dist/plugin/capabilities.js.map +1 -1
  133. package/dist/plugin/context.d.ts +24 -1
  134. package/dist/plugin/context.d.ts.map +1 -1
  135. package/dist/plugin/define.d.ts +1 -0
  136. package/dist/plugin/define.d.ts.map +1 -1
  137. package/dist/plugin/define.js +1 -1
  138. package/dist/plugin/define.js.map +1 -1
  139. package/dist/plugin/limits.d.ts +5 -2
  140. package/dist/plugin/limits.d.ts.map +1 -1
  141. package/dist/plugin/limits.js +5 -2
  142. package/dist/plugin/limits.js.map +1 -1
  143. package/dist/plugin/manifest.d.ts +83 -6
  144. package/dist/plugin/manifest.d.ts.map +1 -1
  145. package/dist/plugin/manifest.js +111 -7
  146. package/dist/plugin/manifest.js.map +1 -1
  147. package/dist/plugin/rest-schemas.d.ts +76 -0
  148. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  149. package/dist/plugin/rest-schemas.js +23 -2
  150. package/dist/plugin/rest-schemas.js.map +1 -1
  151. package/dist/plugin/sandbox.d.ts +4 -3
  152. package/dist/plugin/sandbox.d.ts.map +1 -1
  153. package/dist/plugin/sandbox.js +33 -6
  154. package/dist/plugin/sandbox.js.map +1 -1
  155. package/dist/plugin/semver.d.ts +26 -0
  156. package/dist/plugin/semver.d.ts.map +1 -0
  157. package/dist/plugin/semver.js +76 -0
  158. package/dist/plugin/semver.js.map +1 -0
  159. package/dist/plugin/testing.d.ts.map +1 -1
  160. package/dist/plugin/testing.js +2 -1
  161. package/dist/plugin/testing.js.map +1 -1
  162. package/dist/plugin/web.d.ts +10 -1
  163. package/dist/plugin/web.d.ts.map +1 -1
  164. package/dist/plugin/web.js +34 -0
  165. package/dist/plugin/web.js.map +1 -1
  166. package/dist/plugin.d.ts +3 -2
  167. package/dist/plugin.d.ts.map +1 -1
  168. package/dist/plugin.js +3 -2
  169. package/dist/plugin.js.map +1 -1
  170. package/dist/rules.d.ts +2 -2
  171. package/dist/rules.js +3 -3
  172. package/dist/schemas.d.ts +574 -11
  173. package/dist/schemas.d.ts.map +1 -1
  174. package/dist/schemas.js +154 -25
  175. package/dist/schemas.js.map +1 -1
  176. package/dist/use-action.d.ts +8 -2
  177. package/dist/use-action.d.ts.map +1 -1
  178. package/dist/use-action.js +13 -6
  179. package/dist/use-action.js.map +1 -1
  180. package/dist/vocabulary.d.ts +3 -1
  181. package/dist/vocabulary.d.ts.map +1 -1
  182. package/dist/vocabulary.js +3 -0
  183. package/dist/vocabulary.js.map +1 -1
  184. package/docs/specs/cli.md +4 -3
  185. package/docs/specs/plugins.md +49 -35
  186. package/docs/specs/rest.md +20 -18
  187. package/package.json +7 -7
  188. package/skills/building-a-plugin/SKILL.md +10 -9
  189. package/skills/planning-the-board/references/setting-up.md +2 -1
  190. package/skills/working-the-board/SKILL.md +3 -3
  191. package/src/api.ts +10 -0
  192. package/src/cli/cli.ts +162 -39
  193. package/src/cli/format.ts +54 -10
  194. package/src/cli/help.ts +23 -14
  195. package/src/cli/plugin-check.ts +68 -2
  196. package/src/cli/plugin-cli.ts +104 -6
  197. package/src/cli/plugin-init.ts +178 -3
  198. package/src/client.ts +36 -9
  199. package/src/compat.ts +1 -1
  200. package/src/components/board.tsx +3 -3
  201. package/src/components/comments.tsx +1 -0
  202. package/src/components/decision-form.tsx +166 -29
  203. package/src/components/decision-list.tsx +116 -15
  204. package/src/components/event-feed.tsx +7 -1
  205. package/src/components/goals.tsx +4 -1
  206. package/src/components/home.tsx +615 -0
  207. package/src/components/my-decisions.tsx +4 -1
  208. package/src/components/pack-connect.tsx +5 -4
  209. package/src/components/people.tsx +136 -54
  210. package/src/components/plugin-frame.tsx +62 -63
  211. package/src/components/plugin-settings.tsx +428 -87
  212. package/src/components/projects.tsx +0 -55
  213. package/src/components/task-view.tsx +9 -1
  214. package/src/components/ui.tsx +6 -2
  215. package/src/connect-page.tsx +5 -6
  216. package/src/console.tsx +98 -42
  217. package/src/data.ts +41 -11
  218. package/src/docs-page.tsx +6 -5
  219. package/src/home-tiles.ts +438 -0
  220. package/src/i18n/en.ts +185 -0
  221. package/src/i18n/index.ts +17 -14
  222. package/src/i18n/ko.ts +181 -3
  223. package/src/index.ts +7 -5
  224. package/src/limits.ts +8 -0
  225. package/src/plugin/bridge.ts +77 -1
  226. package/src/plugin/capabilities.ts +17 -1
  227. package/src/plugin/context.ts +24 -2
  228. package/src/plugin/define.ts +4 -1
  229. package/src/plugin/limits.ts +5 -2
  230. package/src/plugin/manifest.ts +154 -10
  231. package/src/plugin/rest-schemas.ts +27 -1
  232. package/src/plugin/sandbox.ts +36 -8
  233. package/src/plugin/semver.ts +89 -0
  234. package/src/plugin/testing.ts +2 -1
  235. package/src/plugin/web.ts +49 -0
  236. package/src/plugin.ts +18 -0
  237. package/src/rules.ts +3 -3
  238. package/src/schemas.ts +190 -28
  239. package/src/styles/console.css +764 -192
  240. package/src/use-action.ts +16 -7
  241. package/src/vocabulary.ts +4 -0
  242. package/template/plugin/console-plugin.json +3 -1
  243. package/template/plugin/package.json +3 -3
@@ -0,0 +1,615 @@
1
+ import {
2
+ type CSSProperties,
3
+ type KeyboardEvent as ReactKeyboardEvent,
4
+ type ReactNode,
5
+ type PointerEvent as ReactPointerEvent,
6
+ type RefObject,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import {
13
+ added,
14
+ arranged,
15
+ columnsFor,
16
+ footprintOf,
17
+ HOME_TILE_CELLS,
18
+ type HomeColumns,
19
+ type HomeTile,
20
+ type HomeTileSize,
21
+ type HomeWidgetTile,
22
+ homeTiles,
23
+ homeWith,
24
+ moved,
25
+ type PlacedTile,
26
+ resized,
27
+ rowsOf,
28
+ sizesAt,
29
+ waitToGiveWay,
30
+ } from "../home-tiles.js";
31
+ import { useWords } from "../i18n/index.js";
32
+ import type { PluginWidgetSizeName } from "../plugin/bridge.js";
33
+ import type { RestHome, RestPlugin, RestProject } from "../schemas.js";
34
+ import { useAction } from "../use-action.js";
35
+ import { DRAG_SLACK, edgeScrollOf, HOLD_MS, HOLD_SLACK } from "./board.js";
36
+ import { ProjectMark, ProjectRoleBadge } from "./projects.js";
37
+ import { Badge, Button, Callout, cx } from "./ui.js";
38
+
39
+ // The front page (ADR-0061): one grid of tiles, the projects and the plugins' widgets alike. While
40
+ // the person arranges it nothing on it opens: a tile is taken in hand and put down anywhere, as a
41
+ // card on the board is, those in the way moving aside as icons do on a phone, and each carries
42
+ // the way off the page and the sizes it comes in. Takes its data as props and nothing from the
43
+ // network; a widget's frame is the composition's to draw.
44
+
45
+ /** The rows of room below the last tile while the page is arranged, to put one down in. */
46
+ const SPARE_ROWS = 2;
47
+
48
+ const NONE: readonly string[] = [];
49
+
50
+ export interface ProjectTileProps {
51
+ readonly project: RestProject;
52
+ readonly size: HomeTileSize;
53
+ /** The way into the project; left out while the page is arranged, when a tile opens nothing. */
54
+ readonly href?: string | undefined;
55
+ readonly onOpen?: ((project: RestProject) => void) | undefined;
56
+ }
57
+
58
+ /** A project on the front page: its mark and its name as an icon; at a larger size, what it is too. */
59
+ export function ProjectTile(props: ProjectTileProps) {
60
+ const words = useWords().projects;
61
+ const { project, onOpen } = props;
62
+ const card = props.size !== "icon";
63
+ const inside = (
64
+ <>
65
+ <span className="sc-tile-mark">
66
+ <ProjectMark project={project} size="lg" />
67
+ </span>
68
+ <span className="sc-tile-name">{project.name}</span>
69
+ {card && (
70
+ <span className="sc-tile-meta">
71
+ <code className="sc-project-key">{project.key}</code>
72
+ <ProjectRoleBadge role={project.role} />
73
+ {project.visibility === "private" ? (
74
+ <Badge tone="neutral">{words.private}</Badge>
75
+ ) : (
76
+ <Badge tone="warning">{words.public}</Badge>
77
+ )}
78
+ </span>
79
+ )}
80
+ {card && project.description.length > 0 && (
81
+ <span className="sc-tile-about">{project.description}</span>
82
+ )}
83
+ {project.openDecisions > 0 && (
84
+ <span
85
+ className="sc-nav-count sc-tile-count"
86
+ title={words.decisionsWaiting(project.openDecisions)}
87
+ >
88
+ {project.openDecisions}
89
+ <span className="sc-visually-hidden">
90
+ {" "}
91
+ {words.decisionsWaiting(project.openDecisions)}
92
+ </span>
93
+ </span>
94
+ )}
95
+ </>
96
+ );
97
+ const className = cx("sc-tile-pane", "sc-tile-project", card && "sc-tile-project-card");
98
+ if (props.href === undefined) {
99
+ return <div className={className}>{inside}</div>;
100
+ }
101
+ return (
102
+ <a
103
+ className={className}
104
+ href={props.href}
105
+ onClick={(event) => {
106
+ if (
107
+ onOpen !== undefined &&
108
+ event.button === 0 &&
109
+ !event.metaKey &&
110
+ !event.ctrlKey &&
111
+ !event.shiftKey
112
+ ) {
113
+ event.preventDefault();
114
+ onOpen(project);
115
+ }
116
+ }}
117
+ >
118
+ {inside}
119
+ </a>
120
+ );
121
+ }
122
+
123
+ /** The sizes a tile comes in, each a little picture of its shape, the one it has lit. */
124
+ function SizeChoice(props: {
125
+ readonly tile: PlacedTile;
126
+ readonly sizes: readonly HomeTileSize[];
127
+ readonly onChoose: (size: HomeTileSize) => void;
128
+ }) {
129
+ const words = useWords().home;
130
+ return (
131
+ <fieldset className="sc-tile-sizes" aria-label={words.size}>
132
+ {props.sizes.map((size) => (
133
+ <button
134
+ key={size}
135
+ type="button"
136
+ className={cx("sc-tile-size", size === props.tile.size && "sc-tile-size-on")}
137
+ aria-pressed={size === props.tile.size}
138
+ aria-label={words.sizes[size]}
139
+ title={words.sizes[size]}
140
+ onClick={() => props.onChoose(size)}
141
+ >
142
+ <span
143
+ className="sc-tile-size-glyph"
144
+ style={
145
+ {
146
+ "--sc-tile-w": HOME_TILE_CELLS[size].columns,
147
+ "--sc-tile-h": HOME_TILE_CELLS[size].rows,
148
+ } as CSSProperties
149
+ }
150
+ />
151
+ </button>
152
+ ))}
153
+ </fieldset>
154
+ );
155
+ }
156
+
157
+ interface Point {
158
+ readonly x: number;
159
+ readonly y: number;
160
+ }
161
+
162
+ /** A tile in hand. */
163
+ interface Drag {
164
+ readonly id: string;
165
+ readonly from: Point;
166
+ readonly x: number;
167
+ readonly y: number;
168
+ /** Where on the tile it was taken hold of, so that it moves whole under the pointer. */
169
+ readonly grip: Point;
170
+ /** Whether it has come loose: the mouse has travelled, or the finger has rested. */
171
+ readonly moving: boolean;
172
+ /** Whether the press is a finger's, which must rest before the tile comes loose. */
173
+ readonly hold: boolean;
174
+ /** Where it is now, in pixels from the corner of the grid. */
175
+ readonly left: number;
176
+ readonly top: number;
177
+ /** The cell it would be put down on. */
178
+ readonly cell: Point;
179
+ }
180
+
181
+ /** How many columns the grid has for the width the page gives it, followed as the width changes. */
182
+ function useHomeColumns(): readonly [RefObject<HTMLDivElement | null>, HomeColumns] {
183
+ const element = useRef<HTMLDivElement>(null);
184
+ const [columns, setColumns] = useState<HomeColumns>(8);
185
+ useLayoutEffect(() => {
186
+ const node = element.current;
187
+ if (node === null) {
188
+ return;
189
+ }
190
+ const measure = () => setColumns(columnsFor(node.getBoundingClientRect().width));
191
+ measure();
192
+ if (typeof ResizeObserver === "undefined") {
193
+ return;
194
+ }
195
+ const observer = new ResizeObserver(measure);
196
+ observer.observe(node);
197
+ return () => observer.disconnect();
198
+ }, []);
199
+ return [element, columns];
200
+ }
201
+
202
+ export interface HomeGridProps {
203
+ readonly projects: readonly RestProject[];
204
+ readonly plugins: readonly RestPlugin[];
205
+ /** What the person kept of the page, or nothing yet. */
206
+ readonly home: RestHome | null | undefined;
207
+ /** Whether the person is arranging the page. */
208
+ readonly editing: boolean;
209
+ /** Keeps what they arranged; they stay on the page's editing until it is kept. */
210
+ readonly onSave: (home: RestHome) => Promise<unknown>;
211
+ /** The person is done arranging: what they did is kept, or was let go. */
212
+ readonly onDone: () => void;
213
+ readonly projectHref: (project: RestProject) => string;
214
+ readonly onOpen: (project: RestProject) => void;
215
+ /** A name a manifest gives, in the person's language. */
216
+ readonly nameOf: (name: Readonly<Record<string, string>>) => string;
217
+ /** A widget's frame, at the size it has. */
218
+ readonly renderWidget: (tile: HomeWidgetTile, size: PluginWidgetSizeName) => ReactNode;
219
+ /** What stands on the page while it holds nothing. */
220
+ readonly empty?: ReactNode;
221
+ }
222
+
223
+ export function HomeGrid(props: HomeGridProps) {
224
+ const allWords = useWords();
225
+ const words = allWords.home;
226
+ const { editing, home, nameOf } = props;
227
+ const tiles = homeTiles(props.plugins, props.projects);
228
+ const [page, columns] = useHomeColumns();
229
+ const grid = useRef<HTMLUListElement>(null);
230
+ /** What the person has changed since they began: kept when they are done, let go if they cancel. */
231
+ const [draft, setDraft] = useState<RestHome | null>(null);
232
+ const working = draft ?? home;
233
+ const hidden = working?.hidden ?? NONE;
234
+ const placed = arranged(tiles, working, columns);
235
+ const [drag, setDrag] = useState<Drag | null>(null);
236
+ const { busy, error, act } = useAction();
237
+ /** The tile in hand, once it has come loose; nothing while one is merely pressed. */
238
+ const dragging = drag?.moving === true ? drag : null;
239
+ // Those in the way stand aside while the tile is held over their place, and go back when it
240
+ // moves on: each step starts from the page as it was taken up.
241
+ const shown =
242
+ dragging === null
243
+ ? placed
244
+ : moved(placed, dragging.id, dragging.cell.x, dragging.cell.y, columns);
245
+ const change = (next: readonly PlacedTile[], nextHidden: readonly string[] = hidden) =>
246
+ setDraft(homeWith(working, next, nextHidden, columns));
247
+
248
+ // What the pointer's handlers read, so that they run outside a state update.
249
+ const current = useRef({ drag, placed, columns, change });
250
+ current.current = { drag, placed, columns, change };
251
+ // Whether the held tile has come loose, kept outside the state so that the touch that follows
252
+ // the hold at once is answered before the state has rendered.
253
+ const loose = useRef(false);
254
+
255
+ const press = (tile: PlacedTile, event: ReactPointerEvent<HTMLElement>) => {
256
+ // The tile's own buttons are pressed for themselves; everything else takes hold of it.
257
+ if (
258
+ event.button !== 0 ||
259
+ (event.target instanceof Element && event.target.closest("button") !== null)
260
+ ) {
261
+ return;
262
+ }
263
+ const hold = event.pointerType !== "mouse";
264
+ if (!hold) {
265
+ // The press is the drag's beginning and nothing else: no text is selected on the way.
266
+ event.preventDefault();
267
+ }
268
+ const rect = event.currentTarget.getBoundingClientRect();
269
+ const from = { x: event.clientX, y: event.clientY };
270
+ setDrag({
271
+ id: tile.tile.id,
272
+ from,
273
+ x: from.x,
274
+ y: from.y,
275
+ grip: { x: from.x - rect.left, y: from.y - rect.top },
276
+ moving: false,
277
+ hold,
278
+ left: 0,
279
+ top: 0,
280
+ cell: { x: tile.x, y: tile.y },
281
+ });
282
+ };
283
+
284
+ // While a tile is held, the pointer is followed over the whole document: the cell under the
285
+ // tile is worked out, the page scrolls while the pointer is near the window's edge, moving or
286
+ // still, and the release puts the tile down. A finger's hold is timed here, and once the tile
287
+ // has come loose the page no longer scrolls under the finger, as on the board.
288
+ const held = drag !== null;
289
+ useEffect(() => {
290
+ if (!held) {
291
+ return;
292
+ }
293
+ const pointer = { x: current.current.drag?.x ?? 0, y: current.current.drag?.y ?? 0 };
294
+ /** Where the tile is, for the pointer as it is and the grid as it lies on the screen now. */
295
+ const whereFor = (grip: Point): Pick<Drag, "left" | "top" | "cell"> | undefined => {
296
+ const element = grid.current;
297
+ if (element === null) {
298
+ return undefined;
299
+ }
300
+ const rect = element.getBoundingClientRect();
301
+ const gap = Number.parseFloat(window.getComputedStyle(element).rowGap) || 0;
302
+ const width = current.current.columns;
303
+ const step = (rect.width + gap) / width;
304
+ const left = pointer.x - grip.x - rect.left;
305
+ const top = pointer.y - grip.y - rect.top;
306
+ const lowest = rowsOf(current.current.placed, width) + SPARE_ROWS - 1;
307
+ return {
308
+ left,
309
+ top,
310
+ cell: { x: Math.round(left / step), y: Math.min(Math.round(top / step), lowest) },
311
+ };
312
+ };
313
+ let frame = 0;
314
+ let at: number | undefined;
315
+ let rest = 0;
316
+ const carry = (now: number) => {
317
+ frame = window.requestAnimationFrame(carry);
318
+ const elapsed = at === undefined ? 0 : now - at;
319
+ at = now;
320
+ if (!loose.current) {
321
+ return;
322
+ }
323
+ const share = edgeScrollOf(pointer.y, 0, window.innerHeight, elapsed) + rest;
324
+ const whole = Math.trunc(share);
325
+ rest = share - whole;
326
+ if (whole === 0) {
327
+ return;
328
+ }
329
+ const was = window.scrollY;
330
+ window.scrollBy(0, whole);
331
+ if (window.scrollY !== was) {
332
+ setDrag((before) => (before === null ? before : { ...before, ...whereFor(before.grip) }));
333
+ }
334
+ };
335
+ const follow = (event: PointerEvent) => {
336
+ const { clientX: x, clientY: y } = event;
337
+ pointer.x = x;
338
+ pointer.y = y;
339
+ setDrag((before) => {
340
+ if (before === null) {
341
+ return before;
342
+ }
343
+ const travelled = Math.hypot(x - before.from.x, y - before.from.y);
344
+ if (!before.moving) {
345
+ // A finger that wanders before it has rested is scrolling, and lets the tile go; a
346
+ // mouse that has barely moved is still only pressing.
347
+ if (before.hold) {
348
+ return travelled > HOLD_SLACK ? null : { ...before, x, y };
349
+ }
350
+ if (travelled <= DRAG_SLACK) {
351
+ return { ...before, x, y };
352
+ }
353
+ loose.current = true;
354
+ }
355
+ return { ...before, x, y, moving: true, ...whereFor(before.grip) };
356
+ });
357
+ };
358
+ const loosen = () => {
359
+ setDrag((before) => {
360
+ if (before === null || before.moving) {
361
+ return before;
362
+ }
363
+ loose.current = true;
364
+ return { ...before, moving: true, ...whereFor(before.grip) };
365
+ });
366
+ };
367
+ const release = (event: PointerEvent) => {
368
+ const { drag: last, placed: now, columns: width, change: keep } = current.current;
369
+ if (last?.moving === true && event.type === "pointerup") {
370
+ keep(moved(now, last.id, last.cell.x, last.cell.y, width));
371
+ }
372
+ loose.current = false;
373
+ setDrag(null);
374
+ };
375
+ const letGo = (event: KeyboardEvent) => {
376
+ if (event.key === "Escape") {
377
+ loose.current = false;
378
+ setDrag(null);
379
+ }
380
+ };
381
+ const block = (event: TouchEvent) => {
382
+ if (loose.current) {
383
+ event.preventDefault();
384
+ }
385
+ };
386
+ const quiet = (event: Event) => event.preventDefault();
387
+ const timer =
388
+ current.current.drag?.hold === true ? window.setTimeout(loosen, HOLD_MS) : undefined;
389
+ frame = window.requestAnimationFrame(carry);
390
+ window.addEventListener("pointermove", follow);
391
+ window.addEventListener("pointerup", release);
392
+ window.addEventListener("pointercancel", release);
393
+ window.addEventListener("keydown", letGo);
394
+ document.addEventListener("touchmove", block, { passive: false });
395
+ document.addEventListener("contextmenu", quiet);
396
+ return () => {
397
+ if (timer !== undefined) {
398
+ window.clearTimeout(timer);
399
+ }
400
+ window.cancelAnimationFrame(frame);
401
+ window.removeEventListener("pointermove", follow);
402
+ window.removeEventListener("pointerup", release);
403
+ window.removeEventListener("pointercancel", release);
404
+ window.removeEventListener("keydown", letGo);
405
+ document.removeEventListener("touchmove", block);
406
+ document.removeEventListener("contextmenu", quiet);
407
+ loose.current = false;
408
+ };
409
+ }, [held]);
410
+
411
+ const remove = (id: string) =>
412
+ change(
413
+ placed.filter((tile) => tile.tile.id !== id),
414
+ [...hidden, id],
415
+ );
416
+ /** The arrow keys move the tile with the focus a cell, as the pointer does; Delete takes it off. */
417
+ const keyed = (tile: PlacedTile, event: ReactKeyboardEvent<HTMLElement>) => {
418
+ if (event.target !== event.currentTarget) {
419
+ return;
420
+ }
421
+ const step: Readonly<Record<string, Point>> = {
422
+ ArrowLeft: { x: -1, y: 0 },
423
+ ArrowRight: { x: 1, y: 0 },
424
+ ArrowUp: { x: 0, y: -1 },
425
+ ArrowDown: { x: 0, y: 1 },
426
+ };
427
+ const by = step[event.key];
428
+ if (by !== undefined) {
429
+ event.preventDefault();
430
+ change(moved(placed, tile.tile.id, tile.x + by.x, tile.y + by.y, columns));
431
+ } else if (event.key === "Delete" || event.key === "Backspace") {
432
+ event.preventDefault();
433
+ remove(tile.tile.id);
434
+ }
435
+ };
436
+ const done = () => {
437
+ if (draft === null) {
438
+ props.onDone();
439
+ return;
440
+ }
441
+ void act(async () => {
442
+ await props.onSave(draft);
443
+ setDraft(null);
444
+ props.onDone();
445
+ });
446
+ };
447
+ const cancel = () => {
448
+ setDraft(null);
449
+ props.onDone();
450
+ };
451
+
452
+ const nameOfTile = (tile: HomeTile): string =>
453
+ tile.kind === "project" ? tile.project.name : nameOf(tile.widget.name);
454
+ // Moved in the page, a frame would load again: the tiles keep the order they came in for as
455
+ // long as the page is open, and each stands where its style says.
456
+ const order = useRef<readonly string[]>(NONE);
457
+ const inOrder = [...shown].sort((a, b) => a.y - b.y || a.x - b.x).map((tile) => tile.tile.id);
458
+ order.current = [
459
+ ...order.current.filter((id) => inOrder.includes(id)),
460
+ ...inOrder.filter((id) => !order.current.includes(id)),
461
+ ];
462
+ const standing = [...shown].sort(
463
+ (a, b) => order.current.indexOf(a.tile.id) - order.current.indexOf(b.tile.id),
464
+ );
465
+ const out = editing
466
+ ? tiles.filter((tile) => !shown.some((candidate) => candidate.tile.id === tile.id))
467
+ : [];
468
+ const slot = dragging === null ? undefined : shown.find((tile) => tile.tile.id === dragging.id);
469
+ const rows = rowsOf(shown, columns) + (editing ? SPARE_ROWS : 0);
470
+ return (
471
+ <div
472
+ ref={page}
473
+ className={cx("sc-home", editing && "sc-home-editing", dragging !== null && "sc-home-held")}
474
+ >
475
+ {editing && (
476
+ <div className="sc-home-bar">
477
+ <p className="sc-home-hint">{words.hint}</p>
478
+ <div className="sc-home-bar-actions">
479
+ <Button size="sm" variant="ghost" disabled={busy} onClick={cancel}>
480
+ {allWords.common.cancel}
481
+ </Button>
482
+ <Button size="sm" variant="primary" disabled={busy} onClick={done}>
483
+ {words.done}
484
+ </Button>
485
+ </div>
486
+ </div>
487
+ )}
488
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
489
+ {out.length > 0 && (
490
+ <div className="sc-home-tray">
491
+ <span className="sc-field-label">{words.add}</span>
492
+ {out.map((tile) => (
493
+ <button
494
+ key={tile.id}
495
+ type="button"
496
+ className="sc-home-add"
497
+ onClick={() =>
498
+ change(
499
+ added(placed, tile, columns),
500
+ hidden.filter((id) => id !== tile.id),
501
+ )
502
+ }
503
+ >
504
+ + {nameOfTile(tile)}
505
+ </button>
506
+ ))}
507
+ </div>
508
+ )}
509
+ {shown.length === 0 && !editing && (props.empty ?? null)}
510
+ <ul
511
+ ref={grid}
512
+ className="sc-home-grid"
513
+ aria-label={allWords.projects.list}
514
+ data-columns={columns}
515
+ hidden={shown.length === 0 && !editing}
516
+ style={
517
+ {
518
+ "--sc-home-columns": columns,
519
+ "--sc-home-rows": Math.max(rows, 1),
520
+ } as CSSProperties
521
+ }
522
+ >
523
+ {standing.map((tile) => {
524
+ const { w, h } = footprintOf(tile.size, columns);
525
+ const inHand = dragging?.id === tile.tile.id ? dragging : undefined;
526
+ const name = nameOfTile(tile.tile);
527
+ return (
528
+ <li
529
+ key={tile.tile.id}
530
+ className={cx("sc-tile", inHand !== undefined && "sc-tile-held")}
531
+ data-tile={tile.tile.id}
532
+ style={
533
+ {
534
+ "--sc-tile-x": tile.x,
535
+ "--sc-tile-y": tile.y,
536
+ "--sc-tile-w": w,
537
+ "--sc-tile-h": h,
538
+ ...(inHand === undefined
539
+ ? {}
540
+ : {
541
+ "--sc-tile-left": `${inHand.left}px`,
542
+ "--sc-tile-top": `${inHand.top}px`,
543
+ }),
544
+ ...(slot === undefined || inHand !== undefined
545
+ ? {}
546
+ : { "--sc-tile-wait": `${waitToGiveWay(tile, slot)}ms` }),
547
+ } as CSSProperties
548
+ }
549
+ tabIndex={editing ? 0 : undefined}
550
+ aria-label={editing ? name : undefined}
551
+ onPointerDown={editing ? (event) => press(tile, event) : undefined}
552
+ onKeyDown={editing ? (event) => keyed(tile, event) : undefined}
553
+ >
554
+ {tile.tile.kind === "project" ? (
555
+ <ProjectTile
556
+ project={tile.tile.project}
557
+ size={tile.size}
558
+ href={editing ? undefined : props.projectHref(tile.tile.project)}
559
+ onOpen={props.onOpen}
560
+ />
561
+ ) : (
562
+ <article className="sc-tile-pane sc-tile-widget">
563
+ <h2 className="sc-tile-title">{name}</h2>
564
+ {props.renderWidget(tile.tile, tile.size as PluginWidgetSizeName)}
565
+ </article>
566
+ )}
567
+ {editing && (
568
+ <>
569
+ <button
570
+ type="button"
571
+ className="sc-tile-remove"
572
+ aria-label={`${words.remove}: ${name}`}
573
+ title={words.remove}
574
+ onClick={() => remove(tile.tile.id)}
575
+ >
576
+ ×
577
+ </button>
578
+ {sizesAt(tile.tile, columns).length > 1 && (
579
+ <SizeChoice
580
+ tile={tile}
581
+ sizes={sizesAt(tile.tile, columns)}
582
+ onChoose={(size) => change(resized(placed, tile.tile.id, size, columns))}
583
+ />
584
+ )}
585
+ </>
586
+ )}
587
+ </li>
588
+ );
589
+ })}
590
+ {slot !== undefined && (
591
+ <li
592
+ className="sc-tile sc-tile-slot"
593
+ aria-hidden="true"
594
+ style={
595
+ {
596
+ "--sc-tile-x": slot.x,
597
+ "--sc-tile-y": slot.y,
598
+ "--sc-tile-w": footprintOf(slot.size, columns).w,
599
+ "--sc-tile-h": footprintOf(slot.size, columns).h,
600
+ } as CSSProperties
601
+ }
602
+ >
603
+ {slot.tile.kind === "project" ? (
604
+ <ProjectTile project={slot.tile.project} size={slot.size} />
605
+ ) : (
606
+ <div className="sc-tile-pane sc-tile-widget">
607
+ <p className="sc-tile-title">{nameOfTile(slot.tile)}</p>
608
+ </div>
609
+ )}
610
+ </li>
611
+ )}
612
+ </ul>
613
+ </div>
614
+ );
615
+ }
@@ -27,6 +27,8 @@ export interface MyDecisionsProps {
27
27
  /** The decisions the person asked of someone else: waiting and answered. */
28
28
  readonly raised: DecisionsPage;
29
29
  readonly decisionHref: (projectKey: string, decisionId: string) => string;
30
+ /** Where a file put on a decision is read, by its project's key and its id (ADR-0055). */
31
+ readonly fileHref?: ((projectKey: string, fileId: string) => string) | undefined;
30
32
  /** 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
33
  readonly onAnswer?:
32
34
  | ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
@@ -67,7 +69,7 @@ function More(props: {
67
69
  export function MyDecisions(props: MyDecisionsProps) {
68
70
  const words = useWords();
69
71
  const own = words.myDecisions;
70
- const { onAnswer } = props;
72
+ const { onAnswer, fileHref } = props;
71
73
  const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
72
74
  const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
73
75
  const record = [...props.addressed.items, ...props.raised.items]
@@ -80,6 +82,7 @@ export function MyDecisions(props: MyDecisionsProps) {
80
82
  decision={item.decision}
81
83
  href={props.decisionHref(item.project.key, item.decision.id)}
82
84
  project={<InProject project={item.project} />}
85
+ fileHref={fileHref === undefined ? undefined : (fileId) => fileHref(item.project.key, fileId)}
83
86
  onAnswer={
84
87
  answerable && onAnswer !== undefined
85
88
  ? (decision, input) => onAnswer(item.project.key, decision, input)
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { PackConnectEnding, RestPackConnect, RestPackConnectStarted } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
+ import { errorWords } from "../use-action.js";
4
5
  import { Button, Time } from "./ui.js";
5
6
 
6
7
  // A private pack connected with a code a person approves (ADR-0036): an owner, or for the
@@ -97,7 +98,7 @@ export function PackConnect(props: PackConnectProps) {
97
98
  return;
98
99
  }
99
100
  // The console could not be asked this time; it is asked again.
100
- setProblem(failure instanceof Error ? failure.message : String(failure));
101
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
101
102
  setShown((current) => (current.kind === "waiting" ? { ...current } : current));
102
103
  });
103
104
  }, Math.max(shown.intervalSeconds, MIN_INTERVAL_SECONDS) * 1000);
@@ -105,7 +106,7 @@ export function PackConnect(props: PackConnectProps) {
105
106
  controller.abort();
106
107
  clearTimeout(timer);
107
108
  };
108
- }, [shown, claim, onConnected]);
109
+ }, [shown, claim, onConnected, words]);
109
110
 
110
111
  const start = () => {
111
112
  setProblem(undefined);
@@ -113,7 +114,7 @@ export function PackConnect(props: PackConnectProps) {
113
114
  .onStart()
114
115
  .then((started) => setShown({ kind: "waiting", ...started }))
115
116
  .catch((failure: unknown) => {
116
- setProblem(failure instanceof Error ? failure.message : String(failure));
117
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
117
118
  });
118
119
  };
119
120
  const cancel = () => {
@@ -121,7 +122,7 @@ export function PackConnect(props: PackConnectProps) {
121
122
  .onCancel()
122
123
  .then(() => setShown({ kind: "idle" }))
123
124
  .catch((failure: unknown) => {
124
- setProblem(failure instanceof Error ? failure.message : String(failure));
125
+ setProblem(errorWords(failure, words.common.somethingWentWrong, words));
125
126
  });
126
127
  };
127
128