@skillcdn/console 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +132 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +6 -4
  11. package/dist/cli/format.js.map +1 -1
  12. package/dist/cli/help.d.ts.map +1 -1
  13. package/dist/cli/help.js +72 -21
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/pack.d.ts +7 -2
  16. package/dist/cli/pack.d.ts.map +1 -1
  17. package/dist/cli/pack.js +16 -2
  18. package/dist/cli/pack.js.map +1 -1
  19. package/dist/client.d.ts +19 -3
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +11 -1
  22. package/dist/client.js.map +1 -1
  23. package/dist/compat.d.ts +1 -1
  24. package/dist/compat.js +2 -2
  25. package/dist/components/board.d.ts +12 -1
  26. package/dist/components/board.d.ts.map +1 -1
  27. package/dist/components/board.js +52 -4
  28. package/dist/components/board.js.map +1 -1
  29. package/dist/components/calendar.d.ts +4 -0
  30. package/dist/components/calendar.d.ts.map +1 -1
  31. package/dist/components/calendar.js +61 -22
  32. package/dist/components/calendar.js.map +1 -1
  33. package/dist/components/decision-form.d.ts +12 -4
  34. package/dist/components/decision-form.d.ts.map +1 -1
  35. package/dist/components/decision-form.js +24 -6
  36. package/dist/components/decision-form.js.map +1 -1
  37. package/dist/components/decision-list.d.ts +4 -0
  38. package/dist/components/decision-list.d.ts.map +1 -1
  39. package/dist/components/decision-list.js +9 -6
  40. package/dist/components/decision-list.js.map +1 -1
  41. package/dist/components/documents.d.ts.map +1 -1
  42. package/dist/components/documents.js +3 -3
  43. package/dist/components/documents.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +63 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/markdown.d.ts.map +1 -1
  48. package/dist/components/markdown.js +6 -3
  49. package/dist/components/markdown.js.map +1 -1
  50. package/dist/components/my-decisions.d.ts +20 -0
  51. package/dist/components/my-decisions.d.ts.map +1 -0
  52. package/dist/components/my-decisions.js +36 -0
  53. package/dist/components/my-decisions.js.map +1 -0
  54. package/dist/components/pack-connect.d.ts +3 -1
  55. package/dist/components/pack-connect.d.ts.map +1 -1
  56. package/dist/components/pack-connect.js +4 -4
  57. package/dist/components/pack-connect.js.map +1 -1
  58. package/dist/components/pack-token.d.ts +3 -1
  59. package/dist/components/pack-token.d.ts.map +1 -1
  60. package/dist/components/pack-token.js +1 -1
  61. package/dist/components/pack-token.js.map +1 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +14 -3
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/projects.d.ts.map +1 -1
  66. package/dist/components/projects.js +3 -3
  67. package/dist/components/projects.js.map +1 -1
  68. package/dist/components/remote-pictures.d.ts +6 -0
  69. package/dist/components/remote-pictures.d.ts.map +1 -0
  70. package/dist/components/remote-pictures.js +8 -0
  71. package/dist/components/remote-pictures.js.map +1 -0
  72. package/dist/components/roadmap.js +1 -1
  73. package/dist/components/roadmap.js.map +1 -1
  74. package/dist/components/runs.d.ts.map +1 -1
  75. package/dist/components/runs.js +1 -1
  76. package/dist/components/runs.js.map +1 -1
  77. package/dist/components/shell.d.ts.map +1 -1
  78. package/dist/components/shell.js +36 -1
  79. package/dist/components/shell.js.map +1 -1
  80. package/dist/components/sign-in.d.ts.map +1 -1
  81. package/dist/components/sign-in.js +49 -2
  82. package/dist/components/sign-in.js.map +1 -1
  83. package/dist/components/task-list.js +1 -1
  84. package/dist/components/task-list.js.map +1 -1
  85. package/dist/components/task-view.d.ts.map +1 -1
  86. package/dist/components/task-view.js +2 -2
  87. package/dist/components/task-view.js.map +1 -1
  88. package/dist/components/ui.d.ts +13 -2
  89. package/dist/components/ui.d.ts.map +1 -1
  90. package/dist/components/ui.js +21 -14
  91. package/dist/components/ui.js.map +1 -1
  92. package/dist/console.css +1686 -425
  93. package/dist/console.d.ts.map +1 -1
  94. package/dist/console.js +83 -13
  95. package/dist/console.js.map +1 -1
  96. package/dist/data.d.ts +15 -2
  97. package/dist/data.d.ts.map +1 -1
  98. package/dist/data.js +18 -19
  99. package/dist/data.js.map +1 -1
  100. package/dist/i18n/en.d.ts +48 -1
  101. package/dist/i18n/en.d.ts.map +1 -1
  102. package/dist/i18n/en.js +50 -3
  103. package/dist/i18n/en.js.map +1 -1
  104. package/dist/i18n/ko.d.ts.map +1 -1
  105. package/dist/i18n/ko.js +47 -4
  106. package/dist/i18n/ko.js.map +1 -1
  107. package/dist/index.d.ts +3 -2
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +3 -2
  110. package/dist/index.js.map +1 -1
  111. package/dist/limits.d.ts +14 -0
  112. package/dist/limits.d.ts.map +1 -1
  113. package/dist/limits.js +14 -0
  114. package/dist/limits.js.map +1 -1
  115. package/dist/router.d.ts +9 -1
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +12 -0
  118. package/dist/router.js.map +1 -1
  119. package/dist/routes.d.ts +5 -1
  120. package/dist/routes.d.ts.map +1 -1
  121. package/dist/routes.js +5 -1
  122. package/dist/routes.js.map +1 -1
  123. package/dist/rules.js +1 -1
  124. package/dist/rules.js.map +1 -1
  125. package/dist/schemas.d.ts +272 -4
  126. package/dist/schemas.d.ts.map +1 -1
  127. package/dist/schemas.js +33 -8
  128. package/dist/schemas.js.map +1 -1
  129. package/dist/web.d.ts.map +1 -1
  130. package/dist/web.js +3 -2
  131. package/dist/web.js.map +1 -1
  132. package/docs/specs/cli.md +11 -10
  133. package/docs/specs/rest.md +33 -17
  134. package/package.json +2 -2
  135. package/skills/building-a-console/SKILL.md +5 -5
  136. package/skills/building-a-console/references/the-default-console.md +4 -4
  137. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  138. package/skills/working-the-board/SKILL.md +6 -4
  139. package/skills/working-the-board/references/setting-up.md +1 -1
  140. package/skills/working-the-board/references/terms.md +1 -1
  141. package/src/api.ts +10 -0
  142. package/src/cli/cli.ts +162 -19
  143. package/src/cli/format.ts +11 -4
  144. package/src/cli/help.ts +79 -20
  145. package/src/cli/pack.ts +17 -1
  146. package/src/client.ts +51 -2
  147. package/src/compat.ts +2 -2
  148. package/src/components/board.tsx +114 -21
  149. package/src/components/calendar.tsx +207 -102
  150. package/src/components/decision-form.tsx +71 -23
  151. package/src/components/decision-list.tsx +30 -3
  152. package/src/components/documents.tsx +27 -11
  153. package/src/components/event-feed.tsx +116 -33
  154. package/src/components/markdown.tsx +6 -3
  155. package/src/components/my-decisions.tsx +138 -0
  156. package/src/components/pack-connect.tsx +10 -7
  157. package/src/components/pack-token.tsx +4 -1
  158. package/src/components/people.tsx +20 -8
  159. package/src/components/projects.tsx +7 -5
  160. package/src/components/remote-pictures.ts +19 -0
  161. package/src/components/roadmap.tsx +1 -1
  162. package/src/components/runs.tsx +5 -1
  163. package/src/components/shell.tsx +36 -1
  164. package/src/components/sign-in.tsx +114 -13
  165. package/src/components/task-list.tsx +1 -1
  166. package/src/components/task-view.tsx +48 -47
  167. package/src/components/ui.tsx +70 -14
  168. package/src/console.tsx +153 -32
  169. package/src/data.ts +37 -20
  170. package/src/i18n/en.ts +53 -3
  171. package/src/i18n/ko.ts +50 -4
  172. package/src/index.ts +3 -0
  173. package/src/limits.ts +14 -0
  174. package/src/router.ts +18 -1
  175. package/src/routes.ts +5 -1
  176. package/src/rules.ts +1 -1
  177. package/src/schemas.ts +37 -8
  178. package/src/styles/console.css +1686 -425
  179. package/src/web.ts +3 -2
package/src/cli/pack.ts CHANGED
@@ -264,11 +264,20 @@ export function joinFolder(
264
264
  if (first === undefined || last === undefined) {
265
265
  return undefined;
266
266
  }
267
+ // A file's path answers its outline (SkillCDN 0.4), its headings paged as a folder's entries are.
268
+ const outline =
269
+ first.outline === undefined
270
+ ? undefined
271
+ : {
272
+ ...first.outline,
273
+ headings: results.flatMap((result) => result.outline?.headings ?? []),
274
+ };
267
275
  return {
268
276
  ...first,
269
277
  mount: last.mount,
270
278
  entries: results.flatMap((result) => result.entries),
271
279
  nextCursor: last.nextCursor,
280
+ ...(outline === undefined ? {} : { outline }),
272
281
  };
273
282
  }
274
283
 
@@ -304,10 +313,16 @@ export function formatSkillFind(found: RestSkillFind, summary: PackSummary | und
304
313
  return renderFindResult(findResultOf(found.read, summary), CONSOLE_WORDS);
305
314
  }
306
315
 
307
- /** The pages of a file, as the console answered them, as the file whole; or the folder the path named. */
316
+ /**
317
+ * The pages of a file, as the console answered them, as the file whole; or the folder the path
318
+ * named. With `requested`, the one page is a section read as asked (`--offset`, `--limit`): the
319
+ * result carries the length asked for, so that the renderer says where the file continues rather
320
+ * than asking for the rest.
321
+ */
308
322
  export function joinFile(
309
323
  pages: readonly RestSkillFile[],
310
324
  summary: PackSummary,
325
+ requested?: number,
311
326
  ): FileResult | DirectoryResult | undefined {
312
327
  const reads = pages.flatMap((page) => (page.read === null ? [] : [page.read]));
313
328
  const first = reads[0];
@@ -331,6 +346,7 @@ export function joinFile(
331
346
  content: files.map((file) => file.content).join(""),
332
347
  nextOffset: closing.nextOffset,
333
348
  totalLength: closing.totalLength,
349
+ ...(requested === undefined ? {} : { requestedLength: requested }),
334
350
  };
335
351
  }
336
352
 
package/src/client.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  type RestMemberPatch,
45
45
  type RestMembers,
46
46
  type RestMePatch,
47
+ type RestMyDecisions,
47
48
  type RestPackConnect,
48
49
  type RestPackConnectStarted,
49
50
  type RestPackTokenInput,
@@ -101,6 +102,7 @@ import {
101
102
  restMemberSchema,
102
103
  restMembersSchema,
103
104
  restMeSchema,
105
+ restMyDecisionsSchema,
104
106
  restPackConnectSchema,
105
107
  restPackConnectStartedSchema,
106
108
  restPeopleSchema,
@@ -206,6 +208,8 @@ export interface EventFilter {
206
208
  readonly run?: string | undefined;
207
209
  readonly decision?: string | undefined;
208
210
  readonly document?: string | undefined;
211
+ /** True for the newest events, oldest first, and whether there are earlier ones; `after` is not looked at then. */
212
+ readonly latest?: boolean | undefined;
209
213
  }
210
214
 
211
215
  /** One page of a listing: how many items, from which one on; the server's bounds apply. */
@@ -362,8 +366,14 @@ export interface ProjectClient {
362
366
  removePackToken(): Promise<RestProject>;
363
367
  /** Asks the deployment to connect the project's pack with a code a person approves (ADR-0036); for an owner. */
364
368
  connectPackToken(): Promise<RestPackConnectStarted>;
365
- /** How that connection stands; the token is kept when the person approved. For an owner. */
369
+ /** How that connection stands, read and changed nothing: pending, ended, or none; for an owner. */
366
370
  packTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
371
+ /**
372
+ * Asks the console to claim the token at the deployment (ADR-0046): pending while the person
373
+ * has not approved, connected once, the token kept and the project as it stands, or ended;
374
+ * for an owner.
375
+ */
376
+ claimPackTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
367
377
  /** Gives the connection up; for an owner. */
368
378
  cancelPackTokenConnection(): Promise<void>;
369
379
  /** The rules that hold in the project (ADR-0023): the workspace's, and what the project adds. */
@@ -412,6 +422,7 @@ export interface ConsoleClient {
412
422
  removeWorkspacePackToken(): Promise<RestWorkspace>;
413
423
  connectWorkspacePackToken(): Promise<RestPackConnectStarted>;
414
424
  workspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
425
+ claimWorkspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
415
426
  cancelWorkspacePackTokenConnection(): Promise<void>;
416
427
  /** The people the asker may see: everyone, or those they share a project with (ADR-0035). */
417
428
  people(signal?: AbortSignal): Promise<RestPeople>;
@@ -434,6 +445,8 @@ export interface ConsoleClient {
434
445
  project(key: string): ProjectClient;
435
446
  /** The workspace's own events, the ones about no project, after event number `after`. */
436
447
  events(after: number, signal?: AbortSignal): Promise<RestEvents>;
448
+ /** Where a picture from elsewhere is read through the console (ADR-0048), given the https URL that names it. */
449
+ pictureUrl(url: string): string;
437
450
  /** The categories tasks are sorted into (ADR-0019), by name. */
438
451
  categories(signal?: AbortSignal): Promise<RestCategories>;
439
452
  /** The workspace's rules (ADR-0023): the console's own until an administrator writes the organization's. */
@@ -449,8 +462,13 @@ export interface ConsoleClient {
449
462
  updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
450
463
  /** Removes a category no task uses; for an administrator signed in. */
451
464
  removeCategory(key: string): Promise<void>;
452
- /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
465
+ /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting for them. */
453
466
  work(signal?: AbortSignal): Promise<RestWork>;
467
+ /** The decisions addressed to the asker (ADR-0042) across the projects they may see, or with `raised` those they raised for someone else: a page, the waiting first; or only those. */
468
+ myDecisions(
469
+ filter?: { readonly open?: boolean; readonly raised?: boolean } & Page,
470
+ signal?: AbortSignal,
471
+ ): Promise<RestMyDecisions>;
454
472
  /** Another person's plate, within what the asker may see: for an administrator, or an owner of a project they are in. */
455
473
  personWork(personId: string, signal?: AbortSignal): Promise<RestPersonWork>;
456
474
  /** The tokens of whoever asks. */
@@ -745,6 +763,7 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
745
763
  "GET",
746
764
  withQuery(at("events"), {
747
765
  after: String(after),
766
+ latest: filter.latest === true ? "true" : undefined,
748
767
  task: filter.task,
749
768
  run: filter.run,
750
769
  decision: filter.decision,
@@ -821,6 +840,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
821
840
  restPackConnectSchema,
822
841
  signal,
823
842
  ),
843
+ claimPackTokenConnection: (signal) =>
844
+ request(
845
+ "POST",
846
+ `${base}${at()}/pack-token/connect/claim`,
847
+ undefined,
848
+ restPackConnectSchema,
849
+ signal,
850
+ ),
824
851
  cancelPackTokenConnection: () =>
825
852
  request("DELETE", `${base}${at()}/pack-token/connect`, undefined, undefined),
826
853
  rules: (signal) =>
@@ -924,6 +951,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
924
951
  restWorkspacePackConnectSchema,
925
952
  signal,
926
953
  ),
954
+ claimWorkspacePackTokenConnection: (signal) =>
955
+ request(
956
+ "POST",
957
+ `${base}${REST_ROUTES.workspacePackToken}/connect/claim`,
958
+ undefined,
959
+ restWorkspacePackConnectSchema,
960
+ signal,
961
+ ),
927
962
  cancelWorkspacePackTokenConnection: () =>
928
963
  request("DELETE", `${base}${REST_ROUTES.workspacePackToken}/connect`, undefined, undefined),
929
964
  updatePerson: (id, patch) =>
@@ -945,6 +980,7 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
945
980
  restEventsSchema,
946
981
  signal,
947
982
  ),
983
+ pictureUrl: (url) => withQuery(REST_ROUTES.pictures, { src: url }),
948
984
  categories: (signal) =>
949
985
  request("GET", `${base}${REST_ROUTES.categories}`, undefined, restCategoriesSchema, signal),
950
986
  rules: (signal) =>
@@ -961,6 +997,19 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
961
997
  removeCategory: (key) => request("DELETE", `${base}${categoryPath(key)}`, undefined, undefined),
962
998
  work: (signal) =>
963
999
  request("GET", `${base}${REST_ROUTES.work}`, undefined, restWorkSchema, signal),
1000
+ myDecisions: (filter = {}, signal) =>
1001
+ request(
1002
+ "GET",
1003
+ withQuery(REST_ROUTES.decisions, {
1004
+ open: flag(filter.open),
1005
+ raised: filter.raised === true ? "true" : undefined,
1006
+ limit: number(filter.limit),
1007
+ offset: number(filter.offset),
1008
+ }),
1009
+ undefined,
1010
+ restMyDecisionsSchema,
1011
+ signal,
1012
+ ),
964
1013
  personWork: (personId, signal) =>
965
1014
  request("GET", `${base}${personWorkPath(personId)}`, undefined, restPersonWorkSchema, signal),
966
1015
  tokens: (signal) =>
package/src/compat.ts CHANGED
@@ -113,7 +113,7 @@ export interface ClientPolicy {
113
113
  * last changed what the command reads or sends (ADR-0038), whose changelog entry begins with
114
114
  * `Breaking:`; `pnpm check` holds it to a version the package has, or is about to have.
115
115
  */
116
- export const CLIENT_POLICY: ClientPolicy = { atLeast: "0.3.2" };
116
+ export const CLIENT_POLICY: ClientPolicy = { atLeast: "0.4.1" };
117
117
 
118
118
  /**
119
119
  * The oldest console this command works with (ADR-0038): raised by the release that last
@@ -122,7 +122,7 @@ export const CLIENT_POLICY: ClientPolicy = { atLeast: "0.3.2" };
122
122
  * itself, naming the command to install instead; `pnpm check` holds it as it holds the
123
123
  * console's floor.
124
124
  */
125
- export const CONSOLE_AT_LEAST = "0.3.2";
125
+ export const CONSOLE_AT_LEAST = "0.4.1";
126
126
 
127
127
  /** `common` from `atLeast` on, where that floor is within it; `common` as it is otherwise. */
128
128
  function narrowed(common: VersionRange, atLeast: string | undefined): VersionRange {
@@ -59,8 +59,17 @@ export interface BoardProps {
59
59
  readonly onEdit?: ((task: RestTask) => void) | undefined;
60
60
  /** Called when a task is put away from its card's menu (ADR-0029, ADR-0030). Left out, the menu offers nothing of it. */
61
61
  readonly onArchive?: ((task: RestTask) => void) | undefined;
62
+ /**
63
+ * The columns folded to a strip until a person opens them, with their count and the way to open
64
+ * them; a folded column still takes a card dragged onto it. What is finished, by default, so that
65
+ * the work at hand has the room; `[]` folds none.
66
+ */
67
+ readonly folded?: readonly TaskState[] | undefined;
62
68
  }
63
69
 
70
+ /** The columns a board folds unless told otherwise: the work that is over. */
71
+ export const FOLDED_STATES: readonly TaskState[] = ["done", "dropped"];
72
+
64
73
  /** How far a mouse travels on a card before the press is a drag and not a click. */
65
74
  const DRAG_SLACK = 4;
66
75
  /**
@@ -160,7 +169,7 @@ export function TaskCard(props: TaskCardProps) {
160
169
  <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
161
170
  )}
162
171
  {task.openDecisions > 0 && (
163
- <Badge tone="warning" title={words.board.decisionsTitle}>
172
+ <Badge tone="warning" kind="count" title={words.board.decisionsTitle}>
164
173
  {words.board.decisions(task.openDecisions)}
165
174
  </Badge>
166
175
  )}
@@ -364,6 +373,19 @@ export function Board(props: BoardProps) {
364
373
  const { onMove } = props;
365
374
  const board = useRef<HTMLDivElement>(null);
366
375
  const [drag, setDrag] = useState<Drag | null>(null);
376
+ // The foldable columns a person has opened on this board; the others of them stay folded.
377
+ const [opened, setOpened] = useState<ReadonlySet<TaskState>>(() => new Set());
378
+ const foldable = props.folded ?? FOLDED_STATES;
379
+ const toggle = (state: TaskState) =>
380
+ setOpened((before) => {
381
+ const after = new Set(before);
382
+ if (after.has(state)) {
383
+ after.delete(state);
384
+ } else {
385
+ after.add(state);
386
+ }
387
+ return after;
388
+ });
367
389
  // What the pointer's release reads, so that the move is made outside a state update.
368
390
  const current = useRef<Drag | null>(null);
369
391
  current.current = drag;
@@ -511,38 +533,65 @@ export function Board(props: BoardProps) {
511
533
  >
512
534
  {TASK_STATES.map((state) => {
513
535
  const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
536
+ const name = words.vocabulary.state[state];
537
+ const canFold = foldable.includes(state);
538
+ const folded = canFold && !opened.has(state);
514
539
  return (
515
540
  <section
516
541
  key={state}
517
542
  className={cx(
518
543
  "sc-column",
519
544
  `sc-column-${state}`,
545
+ folded && "sc-column-folded",
520
546
  dragging?.over === state && "sc-column-over",
521
547
  )}
522
- aria-label={words.vocabulary.state[state]}
548
+ aria-label={name}
523
549
  data-column={state}
524
550
  >
525
551
  <header className="sc-column-header">
526
- <h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
552
+ <h2 className="sc-column-title">{name}</h2>
527
553
  <span className="sc-column-count">{tasks.length}</span>
554
+ {canFold && (
555
+ <button
556
+ type="button"
557
+ className="sc-column-toggle"
558
+ aria-expanded={!folded}
559
+ aria-label={folded ? words.board.unfold(name) : words.board.fold(name)}
560
+ title={folded ? words.board.unfold(name) : words.board.fold(name)}
561
+ onClick={() => toggle(state)}
562
+ >
563
+ <svg viewBox="0 0 16 16" aria-hidden="true">
564
+ <path
565
+ d={folded ? "M6 4l4 4-4 4" : "M10 4l-4 4 4 4"}
566
+ fill="none"
567
+ stroke="currentColor"
568
+ strokeWidth="1.5"
569
+ strokeLinecap="round"
570
+ strokeLinejoin="round"
571
+ />
572
+ </svg>
573
+ </button>
574
+ )}
528
575
  </header>
529
- <div className="sc-column-body">
530
- {tasks.map((task) => (
531
- <TaskCard
532
- key={task.id}
533
- task={task}
534
- href={props.taskHref(task)}
535
- onOpen={props.onOpen}
536
- today={props.today}
537
- onPress={onMove === undefined ? undefined : press}
538
- dragging={dragging?.task.id === task.id}
539
- editHref={props.editHref?.(task)}
540
- onEdit={props.onEdit}
541
- onArchive={props.onArchive}
542
- />
543
- ))}
544
- {tasks.length === 0 && state === "idea" && props.empty}
545
- </div>
576
+ {!folded && (
577
+ <div className="sc-column-body">
578
+ {tasks.map((task) => (
579
+ <TaskCard
580
+ key={task.id}
581
+ task={task}
582
+ href={props.taskHref(task)}
583
+ onOpen={props.onOpen}
584
+ today={props.today}
585
+ onPress={onMove === undefined ? undefined : press}
586
+ dragging={dragging?.task.id === task.id}
587
+ editHref={props.editHref?.(task)}
588
+ onEdit={props.onEdit}
589
+ onArchive={props.onArchive}
590
+ />
591
+ ))}
592
+ {tasks.length === 0 && state === "idea" && props.empty}
593
+ </div>
594
+ )}
546
595
  </section>
547
596
  );
548
597
  })}
@@ -571,13 +620,43 @@ export function Board(props: BoardProps) {
571
620
  );
572
621
  }
573
622
 
574
- /** What the filter keeps of the board: a category or none, a goal or none, a person, one's own. */
623
+ /** What a task is found by: its title, its number, its owner and its workers, its category. */
624
+ function wordsOf(task: RestTask): string {
625
+ return [
626
+ task.title,
627
+ `#${task.number}`,
628
+ personName(task.owner),
629
+ task.owner.login,
630
+ ...task.workers.flatMap((worker) => [personName(worker), worker.login]),
631
+ task.category?.name ?? "",
632
+ ]
633
+ .join("\n")
634
+ .toLowerCase();
635
+ }
636
+
637
+ /**
638
+ * What the filter keeps of the board: a category or none, a goal or none, a person, one's own,
639
+ * and the tasks that contain every word typed, whatever the case.
640
+ */
575
641
  export function filterTasks(
576
642
  tasks: readonly RestTask[],
577
643
  filter: BoardFilter,
578
644
  me?: RestPerson | undefined,
579
645
  ): RestTask[] {
646
+ const wanted =
647
+ filter.words === undefined
648
+ ? []
649
+ : filter.words
650
+ .toLowerCase()
651
+ .split(/\s+/)
652
+ .filter((word) => word.length > 0);
580
653
  return tasks.filter((task) => {
654
+ if (wanted.length > 0) {
655
+ const found = wordsOf(task);
656
+ if (!wanted.every((word) => found.includes(word))) {
657
+ return false;
658
+ }
659
+ }
581
660
  if (filter.category !== undefined && (task.category?.key ?? "") !== filter.category) {
582
661
  return false;
583
662
  }
@@ -633,6 +712,20 @@ export function BoardFilters(props: BoardFiltersProps) {
633
712
  aria-label={words.aria}
634
713
  onSubmit={(event) => event.preventDefault()}
635
714
  >
715
+ <label className="sc-field sc-field-inline sc-filters-words">
716
+ <span className="sc-visually-hidden">{words.words}</span>
717
+ <input
718
+ className="sc-input"
719
+ type="search"
720
+ placeholder={words.words}
721
+ value={filter.words ?? ""}
722
+ onChange={(event) =>
723
+ event.target.value.trim() === ""
724
+ ? change({}, "words")
725
+ : change({ words: event.target.value })
726
+ }
727
+ />
728
+ </label>
636
729
  <label className="sc-field sc-field-inline">
637
730
  <span className="sc-visually-hidden">{words.anyCategory}</span>
638
731
  <select