@skillcdn/console 0.4.3 → 0.4.5

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 (268) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +255 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +20 -5
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +98 -26
  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 +39 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +32 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +14 -2
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +49 -18
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -3
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +24 -18
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +34 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/my-decisions.d.ts +20 -3
  55. package/dist/components/my-decisions.d.ts.map +1 -1
  56. package/dist/components/my-decisions.js +14 -1
  57. package/dist/components/my-decisions.js.map +1 -1
  58. package/dist/components/pack-connect.d.ts.map +1 -1
  59. package/dist/components/pack-connect.js +5 -4
  60. package/dist/components/pack-connect.js.map +1 -1
  61. package/dist/components/people.d.ts +45 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +125 -4
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/picker.d.ts +8 -0
  66. package/dist/components/picker.d.ts.map +1 -1
  67. package/dist/components/picker.js +2 -2
  68. package/dist/components/picker.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -13
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +75 -21
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts +5 -1
  74. package/dist/components/plugin-settings.d.ts.map +1 -1
  75. package/dist/components/plugin-settings.js +283 -45
  76. package/dist/components/plugin-settings.js.map +1 -1
  77. package/dist/components/projects.d.ts +0 -8
  78. package/dist/components/projects.d.ts.map +1 -1
  79. package/dist/components/projects.js +4 -14
  80. package/dist/components/projects.js.map +1 -1
  81. package/dist/components/searches.d.ts +11 -0
  82. package/dist/components/searches.d.ts.map +1 -0
  83. package/dist/components/searches.js +30 -0
  84. package/dist/components/searches.js.map +1 -0
  85. package/dist/components/task-form.d.ts.map +1 -1
  86. package/dist/components/task-form.js +6 -5
  87. package/dist/components/task-form.js.map +1 -1
  88. package/dist/components/task-view.d.ts.map +1 -1
  89. package/dist/components/task-view.js +2 -1
  90. package/dist/components/task-view.js.map +1 -1
  91. package/dist/components/ui.d.ts +16 -0
  92. package/dist/components/ui.d.ts.map +1 -1
  93. package/dist/components/ui.js +15 -2
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/connect-page.d.ts.map +1 -1
  96. package/dist/connect-page.js +5 -6
  97. package/dist/connect-page.js.map +1 -1
  98. package/dist/console.css +1123 -258
  99. package/dist/console.d.ts +12 -4
  100. package/dist/console.d.ts.map +1 -1
  101. package/dist/console.js +123 -27
  102. package/dist/console.js.map +1 -1
  103. package/dist/data.d.ts +18 -3
  104. package/dist/data.d.ts.map +1 -1
  105. package/dist/data.js +41 -1
  106. package/dist/data.js.map +1 -1
  107. package/dist/docs-page.d.ts.map +1 -1
  108. package/dist/docs-page.js +6 -5
  109. package/dist/docs-page.js.map +1 -1
  110. package/dist/home-tiles.d.ts +92 -0
  111. package/dist/home-tiles.d.ts.map +1 -0
  112. package/dist/home-tiles.js +294 -0
  113. package/dist/home-tiles.js.map +1 -0
  114. package/dist/i18n/en.d.ts +264 -1
  115. package/dist/i18n/en.d.ts.map +1 -1
  116. package/dist/i18n/en.js +260 -1
  117. package/dist/i18n/en.js.map +1 -1
  118. package/dist/i18n/index.d.ts +4 -2
  119. package/dist/i18n/index.d.ts.map +1 -1
  120. package/dist/i18n/index.js +10 -11
  121. package/dist/i18n/index.js.map +1 -1
  122. package/dist/i18n/ko.d.ts.map +1 -1
  123. package/dist/i18n/ko.js +255 -4
  124. package/dist/i18n/ko.js.map +1 -1
  125. package/dist/index.d.ts +7 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +7 -5
  128. package/dist/index.js.map +1 -1
  129. package/dist/limits.d.ts +12 -0
  130. package/dist/limits.d.ts.map +1 -1
  131. package/dist/limits.js +12 -0
  132. package/dist/limits.js.map +1 -1
  133. package/dist/plugin/args.d.ts +6 -1
  134. package/dist/plugin/args.d.ts.map +1 -1
  135. package/dist/plugin/args.js +16 -11
  136. package/dist/plugin/args.js.map +1 -1
  137. package/dist/plugin/bridge.d.ts +63 -3
  138. package/dist/plugin/bridge.d.ts.map +1 -1
  139. package/dist/plugin/bridge.js +54 -0
  140. package/dist/plugin/bridge.js.map +1 -1
  141. package/dist/plugin/capabilities.d.ts +7 -1
  142. package/dist/plugin/capabilities.d.ts.map +1 -1
  143. package/dist/plugin/capabilities.js +24 -1
  144. package/dist/plugin/capabilities.js.map +1 -1
  145. package/dist/plugin/context.d.ts +174 -12
  146. package/dist/plugin/context.d.ts.map +1 -1
  147. package/dist/plugin/define.d.ts +14 -1
  148. package/dist/plugin/define.d.ts.map +1 -1
  149. package/dist/plugin/define.js +18 -2
  150. package/dist/plugin/define.js.map +1 -1
  151. package/dist/plugin/limits.d.ts +9 -2
  152. package/dist/plugin/limits.d.ts.map +1 -1
  153. package/dist/plugin/limits.js +9 -2
  154. package/dist/plugin/limits.js.map +1 -1
  155. package/dist/plugin/manifest.d.ts +98 -6
  156. package/dist/plugin/manifest.d.ts.map +1 -1
  157. package/dist/plugin/manifest.js +171 -10
  158. package/dist/plugin/manifest.js.map +1 -1
  159. package/dist/plugin/rest-schemas.d.ts +120 -0
  160. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  161. package/dist/plugin/rest-schemas.js +78 -5
  162. package/dist/plugin/rest-schemas.js.map +1 -1
  163. package/dist/plugin/sandbox.d.ts +4 -3
  164. package/dist/plugin/sandbox.d.ts.map +1 -1
  165. package/dist/plugin/sandbox.js +33 -6
  166. package/dist/plugin/sandbox.js.map +1 -1
  167. package/dist/plugin/semver.d.ts +26 -0
  168. package/dist/plugin/semver.d.ts.map +1 -0
  169. package/dist/plugin/semver.js +76 -0
  170. package/dist/plugin/semver.js.map +1 -0
  171. package/dist/plugin/testing.d.ts +15 -1
  172. package/dist/plugin/testing.d.ts.map +1 -1
  173. package/dist/plugin/testing.js +42 -5
  174. package/dist/plugin/testing.js.map +1 -1
  175. package/dist/plugin/web.d.ts +16 -1
  176. package/dist/plugin/web.d.ts.map +1 -1
  177. package/dist/plugin/web.js +44 -1
  178. package/dist/plugin/web.js.map +1 -1
  179. package/dist/plugin.d.ts +3 -2
  180. package/dist/plugin.d.ts.map +1 -1
  181. package/dist/plugin.js +3 -2
  182. package/dist/plugin.js.map +1 -1
  183. package/dist/router.d.ts +19 -0
  184. package/dist/router.d.ts.map +1 -1
  185. package/dist/router.js +18 -0
  186. package/dist/router.js.map +1 -1
  187. package/dist/routes.d.ts +11 -2
  188. package/dist/routes.d.ts.map +1 -1
  189. package/dist/routes.js +14 -1
  190. package/dist/routes.js.map +1 -1
  191. package/dist/rules.d.ts +2 -2
  192. package/dist/rules.js +3 -3
  193. package/dist/schemas.d.ts +864 -2
  194. package/dist/schemas.d.ts.map +1 -1
  195. package/dist/schemas.js +156 -9
  196. package/dist/schemas.js.map +1 -1
  197. package/dist/use-action.d.ts +8 -2
  198. package/dist/use-action.d.ts.map +1 -1
  199. package/dist/use-action.js +13 -6
  200. package/dist/use-action.js.map +1 -1
  201. package/dist/vocabulary.d.ts +3 -1
  202. package/dist/vocabulary.d.ts.map +1 -1
  203. package/dist/vocabulary.js +9 -0
  204. package/dist/vocabulary.js.map +1 -1
  205. package/docs/specs/cli.md +10 -6
  206. package/docs/specs/plugins.md +83 -62
  207. package/docs/specs/rest.md +34 -16
  208. package/package.json +1 -1
  209. package/skills/building-a-plugin/SKILL.md +15 -12
  210. package/skills/planning-the-board/references/setting-up.md +2 -1
  211. package/skills/working-the-board/SKILL.md +3 -3
  212. package/src/api.ts +23 -0
  213. package/src/cli/cli.ts +323 -30
  214. package/src/cli/format.ts +122 -26
  215. package/src/cli/help.ts +43 -14
  216. package/src/cli/plugin-check.ts +41 -2
  217. package/src/cli/plugin-cli.ts +10 -1
  218. package/src/cli/plugin-init.ts +8 -0
  219. package/src/client.ts +78 -0
  220. package/src/compat.ts +1 -1
  221. package/src/components/board.tsx +86 -45
  222. package/src/components/decision-form.tsx +73 -36
  223. package/src/components/decision-list.tsx +45 -13
  224. package/src/components/event-feed.tsx +40 -7
  225. package/src/components/goals.tsx +4 -1
  226. package/src/components/home.tsx +615 -0
  227. package/src/components/my-decisions.tsx +50 -5
  228. package/src/components/pack-connect.tsx +5 -4
  229. package/src/components/people.tsx +500 -61
  230. package/src/components/picker.tsx +11 -2
  231. package/src/components/plugin-frame.tsx +85 -63
  232. package/src/components/plugin-settings.tsx +735 -185
  233. package/src/components/projects.tsx +14 -69
  234. package/src/components/searches.ts +47 -0
  235. package/src/components/task-form.tsx +23 -28
  236. package/src/components/task-view.tsx +7 -0
  237. package/src/components/ui.tsx +42 -2
  238. package/src/connect-page.tsx +5 -6
  239. package/src/console.tsx +303 -64
  240. package/src/data.ts +62 -3
  241. package/src/docs-page.tsx +6 -5
  242. package/src/home-tiles.ts +438 -0
  243. package/src/i18n/en.ts +274 -0
  244. package/src/i18n/index.ts +17 -14
  245. package/src/i18n/ko.ts +267 -4
  246. package/src/index.ts +18 -5
  247. package/src/limits.ts +12 -0
  248. package/src/plugin/args.ts +16 -11
  249. package/src/plugin/bridge.ts +122 -1
  250. package/src/plugin/capabilities.ts +28 -1
  251. package/src/plugin/context.ts +186 -9
  252. package/src/plugin/define.ts +38 -1
  253. package/src/plugin/limits.ts +9 -2
  254. package/src/plugin/manifest.ts +228 -13
  255. package/src/plugin/rest-schemas.ts +89 -6
  256. package/src/plugin/sandbox.ts +36 -8
  257. package/src/plugin/semver.ts +89 -0
  258. package/src/plugin/testing.ts +63 -6
  259. package/src/plugin/web.ts +66 -0
  260. package/src/plugin.ts +19 -0
  261. package/src/router.ts +25 -0
  262. package/src/routes.ts +17 -2
  263. package/src/rules.ts +3 -3
  264. package/src/schemas.ts +197 -10
  265. package/src/styles/console.css +1123 -258
  266. package/src/use-action.ts +16 -7
  267. package/src/vocabulary.ts +10 -0
  268. package/template/plugin/console-plugin.json +3 -1
@@ -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
+ }
@@ -1,5 +1,11 @@
1
1
  import type { ReactNode } from "react";
2
- import type { RestAnswerInput, RestDecision, RestMyDecisions } from "../api.js";
2
+ import {
3
+ decisionFilePath,
4
+ type RestAnswerInput,
5
+ type RestDecision,
6
+ type RestMyDecisions,
7
+ type RestReachedDecision,
8
+ } from "../api.js";
3
9
  import { useWords } from "../i18n/index.js";
4
10
  import { DecisionCard, DecisionLog } from "./decision-list.js";
5
11
  import { ProjectMark } from "./projects.js";
@@ -11,7 +17,9 @@ import { Button } from "./ui.js";
11
17
  // whom they wait for; in the second the record, the answered ones of both kinds, newest first.
12
18
  // Every card and row names the project, and who asked and who answers, as the cards do
13
19
  // everywhere. Takes its data as props and nothing from the network; the page reads more when
14
- // asked.
20
+ // asked. A decision here may be in a project the person may not see (ADR-0068), so the files
21
+ // put on it are read at the decision's own address, which serves them to whoever the decision
22
+ // reaches, and not at the project's.
15
23
 
16
24
  /** One kind of a person's decisions, a page at a time. */
17
25
  export interface DecisionsPage {
@@ -27,8 +35,14 @@ export interface MyDecisionsProps {
27
35
  /** The decisions the person asked of someone else: waiting and answered. */
28
36
  readonly raised: DecisionsPage;
29
37
  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;
38
+ /**
39
+ * Where a file put on a decision is read (ADR-0055), by the project's key, the file's id and
40
+ * the decision's; left out, at the decision's own address, `decisionFilePath`, which serves it
41
+ * to whoever the decision reaches, in a project they may see or not (ADR-0068).
42
+ */
43
+ readonly fileHref?:
44
+ | ((projectKey: string, fileId: string, decisionId: string) => string)
45
+ | undefined;
32
46
  /** 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. */
33
47
  readonly onAnswer?:
34
48
  | ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
@@ -82,7 +96,11 @@ export function MyDecisions(props: MyDecisionsProps) {
82
96
  decision={item.decision}
83
97
  href={props.decisionHref(item.project.key, item.decision.id)}
84
98
  project={<InProject project={item.project} />}
85
- fileHref={fileHref === undefined ? undefined : (fileId) => fileHref(item.project.key, fileId)}
99
+ fileHref={(fileId) =>
100
+ fileHref === undefined
101
+ ? decisionFilePath(item.decision.id, fileId)
102
+ : fileHref(item.project.key, fileId, item.decision.id)
103
+ }
86
104
  onAnswer={
87
105
  answerable && onAnswer !== undefined
88
106
  ? (decision, input) => onAnswer(item.project.key, decision, input)
@@ -139,3 +157,30 @@ export function MyDecisions(props: MyDecisionsProps) {
139
157
  </div>
140
158
  );
141
159
  }
160
+
161
+ export interface ReachedDecisionProps {
162
+ /** The decision as the person reaches it (ADR-0068): its project, the decision, and whether they may answer it. */
163
+ readonly reached: RestReachedDecision;
164
+ /** Called with the person's answer; the decision is answered here only where `mayAnswer` says they may. */
165
+ readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
166
+ readonly busy?: boolean | undefined;
167
+ }
168
+
169
+ /**
170
+ * One decision wherever it is (ADR-0068), on its own page: the card with its project named, the
171
+ * files put on it read at the decision's own address, since the project may be one the person
172
+ * may not see, and the way to answer it where they may.
173
+ */
174
+ export function ReachedDecision(props: ReachedDecisionProps) {
175
+ const { reached } = props;
176
+ const { decision } = reached;
177
+ return (
178
+ <DecisionCard
179
+ decision={decision}
180
+ project={<InProject project={reached.project} />}
181
+ fileHref={(fileId) => decisionFilePath(decision.id, fileId)}
182
+ onAnswer={reached.mayAnswer === true ? props.onAnswer : undefined}
183
+ busy={props.busy}
184
+ />
185
+ );
186
+ }