@skillcdn/console 0.1.24 → 0.3.0

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 (85) hide show
  1. package/README.md +6 -5
  2. package/dist/api.d.ts +1 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +1 -1
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +64 -65
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -28
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +7 -261
  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 +6 -6
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/pack.d.ts +46 -0
  17. package/dist/cli/pack.d.ts.map +1 -0
  18. package/dist/cli/pack.js +250 -0
  19. package/dist/cli/pack.js.map +1 -0
  20. package/dist/compat.d.ts +1 -1
  21. package/dist/compat.js +1 -1
  22. package/dist/components/decision-list.d.ts +13 -1
  23. package/dist/components/decision-list.d.ts.map +1 -1
  24. package/dist/components/decision-list.js +74 -29
  25. package/dist/components/decision-list.js.map +1 -1
  26. package/dist/components/paged.d.ts +24 -0
  27. package/dist/components/paged.d.ts.map +1 -1
  28. package/dist/components/paged.js +40 -4
  29. package/dist/components/paged.js.map +1 -1
  30. package/dist/components/skills.d.ts.map +1 -1
  31. package/dist/components/skills.js +8 -4
  32. package/dist/components/skills.js.map +1 -1
  33. package/dist/components/ui.d.ts +2 -0
  34. package/dist/components/ui.d.ts.map +1 -1
  35. package/dist/components/ui.js +7 -2
  36. package/dist/components/ui.js.map +1 -1
  37. package/dist/console.css +112 -5
  38. package/dist/console.d.ts.map +1 -1
  39. package/dist/console.js +2 -2
  40. package/dist/console.js.map +1 -1
  41. package/dist/data.d.ts.map +1 -1
  42. package/dist/data.js +27 -2
  43. package/dist/data.js.map +1 -1
  44. package/dist/i18n/en.d.ts +13 -0
  45. package/dist/i18n/en.d.ts.map +1 -1
  46. package/dist/i18n/en.js +15 -0
  47. package/dist/i18n/en.js.map +1 -1
  48. package/dist/i18n/ko.d.ts.map +1 -1
  49. package/dist/i18n/ko.js +15 -0
  50. package/dist/i18n/ko.js.map +1 -1
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +2 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/router.d.ts +6 -1
  56. package/dist/router.d.ts.map +1 -1
  57. package/dist/router.js +13 -7
  58. package/dist/router.js.map +1 -1
  59. package/dist/schemas.d.ts +321 -159
  60. package/dist/schemas.d.ts.map +1 -1
  61. package/dist/schemas.js +61 -154
  62. package/dist/schemas.js.map +1 -1
  63. package/docs/specs/cli.md +3 -3
  64. package/docs/specs/rest.md +5 -13
  65. package/package.json +2 -1
  66. package/skills/deploying-a-console/SKILL.md +2 -2
  67. package/skills/working-the-board/SKILL.md +3 -3
  68. package/src/api.ts +2 -7
  69. package/src/cli/cli.ts +92 -76
  70. package/src/cli/format.ts +10 -328
  71. package/src/cli/help.ts +7 -6
  72. package/src/cli/pack.ts +356 -0
  73. package/src/compat.ts +1 -1
  74. package/src/components/decision-list.tsx +227 -94
  75. package/src/components/paged.tsx +72 -3
  76. package/src/components/skills.tsx +51 -33
  77. package/src/components/ui.tsx +16 -3
  78. package/src/console.tsx +3 -0
  79. package/src/data.ts +32 -2
  80. package/src/i18n/en.ts +18 -0
  81. package/src/i18n/ko.ts +17 -0
  82. package/src/index.ts +9 -1
  83. package/src/router.ts +20 -9
  84. package/src/schemas.ts +76 -180
  85. package/src/styles/console.css +112 -5
@@ -3,6 +3,7 @@ import {
3
3
  agentLabel,
4
4
  MAX_BODY_LENGTH,
5
5
  MAX_NOTE_LENGTH,
6
+ MAX_OPTION_LABEL_LENGTH,
6
7
  type RestAnswerInput,
7
8
  type RestDecision,
8
9
  type RestDecisionPatch,
@@ -10,8 +11,8 @@ import {
10
11
  } from "../api.js";
11
12
  import { useWords } from "../i18n/index.js";
12
13
  import { Markdown } from "./markdown.js";
13
- import { ShowMore, usePaged } from "./paged.js";
14
- import { Button, cx, PersonChip, Time } from "./ui.js";
14
+ import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
15
+ import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
15
16
 
16
17
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
17
18
  // a person came for; and the ones answered, the record, a row each, since they are read back
@@ -35,6 +36,131 @@ export interface DecisionCardProps {
35
36
  readonly busy?: boolean | undefined;
36
37
  }
37
38
 
39
+ /** Who asked, and when: the person, through their agent when a run asked. */
40
+ function Asked(props: { readonly decision: RestDecision; readonly day?: boolean }) {
41
+ const words = useWords();
42
+ const { decision } = props;
43
+ return (
44
+ <>
45
+ <PersonChip person={decision.raisedBy} />{" "}
46
+ {decision.run === null
47
+ ? words.decisions.asked
48
+ : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
49
+ <Time iso={decision.createdAt} precision={props.day === true ? "day" : "minute"} />
50
+ </>
51
+ );
52
+ }
53
+
54
+ /** What was chosen: the option's label, marked when it is the person's own words. */
55
+ function Chosen(props: { readonly decision: RestDecision }) {
56
+ const words = useWords();
57
+ const { decision } = props;
58
+ const chosen = decision.options.find((option) => option.id === decision.answer?.option);
59
+ return (
60
+ <>
61
+ <span>{chosen?.label ?? decision.answer?.option ?? ""}</span>
62
+ {chosen?.own === true && <Badge>{words.decisions.ownAnswer}</Badge>}
63
+ </>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * The way to answer: one of the options, or a line of one's own, which is chosen by writing
69
+ * on it; and a word about either.
70
+ */
71
+ function AnswerForm(props: {
72
+ readonly decision: RestDecision;
73
+ readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
74
+ readonly busy: boolean | undefined;
75
+ }) {
76
+ const words = useWords();
77
+ const { decision } = props;
78
+ const [chosen, setChosen] = useState<string | undefined>(undefined);
79
+ const [own, setOwn] = useState(false);
80
+ const [ownText, setOwnText] = useState("");
81
+ const [note, setNote] = useState("");
82
+ const chooseOwn = () => {
83
+ setOwn(true);
84
+ setChosen(undefined);
85
+ };
86
+ const answer: RestAnswerInput | undefined = own
87
+ ? ownText.trim().length > 0
88
+ ? { own: ownText.trim() }
89
+ : undefined
90
+ : chosen === undefined
91
+ ? undefined
92
+ : { option: chosen };
93
+ const name = `decision-${decision.id}`;
94
+ return (
95
+ <form
96
+ className="sc-answer-form"
97
+ onSubmit={(event) => {
98
+ event.preventDefault();
99
+ if (answer !== undefined) {
100
+ props.onAnswer(decision, {
101
+ ...answer,
102
+ ...(note.trim() === "" ? {} : { note: note.trim() }),
103
+ });
104
+ }
105
+ }}
106
+ >
107
+ <div className="sc-options-list" role="radiogroup" aria-label={words.decisions.options}>
108
+ {decision.options.map((option) => (
109
+ <label
110
+ key={option.id}
111
+ className={cx("sc-option", chosen === option.id && "sc-option-chosen")}
112
+ >
113
+ <input
114
+ type="radio"
115
+ name={name}
116
+ value={option.id}
117
+ checked={chosen === option.id}
118
+ onChange={() => {
119
+ setChosen(option.id);
120
+ setOwn(false);
121
+ }}
122
+ />
123
+ <span>{option.label}</span>
124
+ </label>
125
+ ))}
126
+ <label className={cx("sc-option", "sc-option-own", own && "sc-option-chosen")}>
127
+ <input type="radio" name={name} value="own" checked={own} onChange={chooseOwn} />
128
+ <span>{words.decisions.own}</span>
129
+ <input
130
+ className="sc-input"
131
+ aria-label={words.decisions.own}
132
+ placeholder={words.decisions.ownPlaceholder}
133
+ maxLength={MAX_OPTION_LABEL_LENGTH}
134
+ value={ownText}
135
+ onFocus={chooseOwn}
136
+ onChange={(event) => {
137
+ setOwnText(event.target.value);
138
+ chooseOwn();
139
+ }}
140
+ />
141
+ </label>
142
+ </div>
143
+ <input
144
+ className="sc-input"
145
+ placeholder={words.decisions.notePlaceholder}
146
+ maxLength={MAX_NOTE_LENGTH}
147
+ value={note}
148
+ onChange={(event) => setNote(event.target.value)}
149
+ />
150
+ <div className="sc-form-actions">
151
+ <Button
152
+ type="submit"
153
+ variant="primary"
154
+ size="sm"
155
+ disabled={answer === undefined || props.busy === true}
156
+ >
157
+ {words.decisions.answer}
158
+ </Button>
159
+ </div>
160
+ </form>
161
+ );
162
+ }
163
+
38
164
  /** What followed the decision: shown, and written or changed by whoever may. */
39
165
  function Outcome(props: {
40
166
  readonly decision: RestDecision;
@@ -109,11 +235,7 @@ function Outcome(props: {
109
235
  export function DecisionCard(props: DecisionCardProps) {
110
236
  const words = useWords();
111
237
  const { decision, task } = props;
112
- const [chosen, setChosen] = useState<string | undefined>(undefined);
113
- const [note, setNote] = useState("");
114
238
  const answered = decision.answer !== null;
115
- const chosenLabel = (id: string) =>
116
- decision.options.find((option) => option.id === id)?.label ?? id;
117
239
 
118
240
  return (
119
241
  <article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
@@ -126,11 +248,7 @@ export function DecisionCard(props: DecisionCardProps) {
126
248
  )}
127
249
  </h3>
128
250
  <p className="sc-decision-meta">
129
- <PersonChip person={decision.raisedBy} />{" "}
130
- {decision.run === null
131
- ? words.decisions.asked
132
- : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
133
- <Time iso={decision.createdAt} />
251
+ <Asked decision={decision} />
134
252
  {task !== undefined && (
135
253
  <>
136
254
  {" "}
@@ -156,7 +274,9 @@ export function DecisionCard(props: DecisionCardProps) {
156
274
  )}
157
275
  {decision.answer !== null ? (
158
276
  <div className="sc-answer">
159
- <p className="sc-answer-option">{chosenLabel(decision.answer.option)}</p>
277
+ <p className="sc-answer-option">
278
+ <Chosen decision={decision} />
279
+ </p>
160
280
  {decision.answer.note !== null && (
161
281
  <div className="sc-answer-note">
162
282
  <Markdown source={decision.answer.note} docHref={props.docHref} />
@@ -170,53 +290,7 @@ export function DecisionCard(props: DecisionCardProps) {
170
290
  ) : props.onAnswer === undefined ? (
171
291
  <p className="sc-decision-waiting">{words.decisions.waitingForPerson}</p>
172
292
  ) : (
173
- <form
174
- className="sc-answer-form"
175
- onSubmit={(event) => {
176
- event.preventDefault();
177
- if (chosen !== undefined) {
178
- props.onAnswer?.(decision, {
179
- option: chosen,
180
- ...(note.trim() === "" ? {} : { note: note.trim() }),
181
- });
182
- }
183
- }}
184
- >
185
- <div className="sc-options-list" role="radiogroup" aria-label={words.decisions.options}>
186
- {decision.options.map((option) => (
187
- <label
188
- key={option.id}
189
- className={cx("sc-option", chosen === option.id && "sc-option-chosen")}
190
- >
191
- <input
192
- type="radio"
193
- name={`decision-${decision.id}`}
194
- value={option.id}
195
- checked={chosen === option.id}
196
- onChange={() => setChosen(option.id)}
197
- />
198
- <span>{option.label}</span>
199
- </label>
200
- ))}
201
- </div>
202
- <input
203
- className="sc-input"
204
- placeholder={words.decisions.notePlaceholder}
205
- maxLength={MAX_NOTE_LENGTH}
206
- value={note}
207
- onChange={(event) => setNote(event.target.value)}
208
- />
209
- <div className="sc-form-actions">
210
- <Button
211
- type="submit"
212
- variant="primary"
213
- size="sm"
214
- disabled={chosen === undefined || props.busy === true}
215
- >
216
- {words.decisions.answer}
217
- </Button>
218
- </div>
219
- </form>
293
+ <AnswerForm decision={decision} onAnswer={props.onAnswer} busy={props.busy} />
220
294
  )}
221
295
  {answered && (
222
296
  <Outcome
@@ -238,6 +312,9 @@ export interface DecisionListProps {
238
312
  readonly docHref?: ((path: string) => string) | undefined;
239
313
  /** Where each decision's own page is. */
240
314
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
315
+ /** The page of the record shown, from 1, and where each page is (ADR-0010); left out, the record turns its pages in place. */
316
+ readonly page?: number | undefined;
317
+ readonly pageHref?: ((page: number) => string) | undefined;
241
318
  readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
242
319
  readonly onUpdate?: ((decision: RestDecision, patch: RestDecisionPatch) => void) | undefined;
243
320
  readonly busy?: boolean | undefined;
@@ -251,48 +328,101 @@ export interface DecisionLogProps {
251
328
  readonly taskHref?: ((task: RestTask) => string) | undefined;
252
329
  /** Where each decision's own page is, with its context, its rationale and what followed. */
253
330
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
331
+ /** Where a document the word given with an answer links to is read, by its path. */
332
+ readonly docHref?: ((path: string) => string) | undefined;
333
+ /** The page shown, from 1; the first when left out, the last for one past the end. Read with `pageHref`. */
334
+ readonly page?: number | undefined;
335
+ /** Where each page of the record is, so that a page has an address; left out, the pages turn in place. */
336
+ readonly pageHref?: ((page: number) => string) | undefined;
254
337
  }
255
338
 
256
- /** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
339
+ /** One row of the record: what was asked, by whom and when; what was answered, with the word given, by whom and when. */
340
+ function RecordRow(props: {
341
+ readonly decision: RestDecision;
342
+ readonly task: RestTask | undefined;
343
+ readonly taskHref: ((task: RestTask) => string) | undefined;
344
+ readonly href: string | undefined;
345
+ readonly docHref: ((path: string) => string) | undefined;
346
+ }) {
347
+ const words = useWords();
348
+ const { decision, task } = props;
349
+ const { answer } = decision;
350
+ return (
351
+ <li className="sc-decision-record-row">
352
+ <div className="sc-decision-record-asked">
353
+ <p className="sc-decision-record-question">
354
+ {props.href === undefined ? (
355
+ decision.question
356
+ ) : (
357
+ <a href={props.href}>{decision.question}</a>
358
+ )}
359
+ {task !== undefined &&
360
+ (props.taskHref === undefined ? (
361
+ <span className="sc-card-number">#{task.number}</span>
362
+ ) : (
363
+ <a className="sc-card-number" href={props.taskHref(task)}>
364
+ #{task.number}
365
+ </a>
366
+ ))}
367
+ </p>
368
+ <p className="sc-decision-record-meta">
369
+ <Asked decision={decision} day />
370
+ </p>
371
+ </div>
372
+ {answer !== null && (
373
+ <div className="sc-decision-record-answer">
374
+ <p className="sc-decision-record-chosen">
375
+ <Chosen decision={decision} />
376
+ </p>
377
+ {answer.note !== null && (
378
+ <div className="sc-decision-record-note">
379
+ <Markdown source={answer.note} docHref={props.docHref} />
380
+ </div>
381
+ )}
382
+ <p className="sc-decision-record-meta">
383
+ <PersonChip person={answer.by} /> {words.decisions.answeredBy}{" "}
384
+ <Time iso={answer.at} precision="day" />
385
+ </p>
386
+ </div>
387
+ )}
388
+ </li>
389
+ );
390
+ }
391
+
392
+ /**
393
+ * The record: one row per decision answered, what was asked and by whom, what was chosen with
394
+ * the word given and by whom; twenty to a page, the page named by the address or turned here.
395
+ */
257
396
  export function DecisionLog(props: DecisionLogProps) {
258
397
  const words = useWords();
259
- const { shown, rest, more } = usePaged(props.decisions);
398
+ const [turned, setTurned] = useState(1);
399
+ const { shown, page, pages } = pageOf(
400
+ props.decisions,
401
+ props.pageHref === undefined ? turned : props.page,
402
+ RECORD_PAGE_SIZE,
403
+ );
260
404
  return (
261
405
  <>
262
- <ol className="sc-decision-log" aria-label={words.decisions.record}>
263
- {shown.map((decision) => {
264
- const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
265
- const href = props.decisionHref?.(decision);
266
- const chosen =
267
- decision.options.find((option) => option.id === decision.answer?.option)?.label ??
268
- decision.answer?.option ??
269
- "";
270
- return (
271
- <li key={decision.id} className="sc-decision-row">
272
- <span className="sc-decision-row-question">
273
- {href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
274
- </span>
275
- <span className="sc-decision-row-answer">{chosen}</span>
276
- <span className="sc-decision-row-meta">
277
- {decision.answer !== null && (
278
- <>
279
- <PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
280
- </>
281
- )}
282
- {task !== undefined &&
283
- (props.taskHref === undefined ? (
284
- <span className="sc-card-number">#{task.number}</span>
285
- ) : (
286
- <a className="sc-card-number" href={props.taskHref(task)}>
287
- #{task.number}
288
- </a>
289
- ))}
290
- </span>
291
- </li>
292
- );
293
- })}
406
+ <ol className="sc-decision-record" aria-label={words.decisions.record}>
407
+ {shown.map((decision) => (
408
+ <RecordRow
409
+ key={decision.id}
410
+ decision={decision}
411
+ task={decision.taskId === null ? undefined : props.tasks?.get(decision.taskId)}
412
+ taskHref={props.taskHref}
413
+ href={props.decisionHref?.(decision)}
414
+ docHref={props.docHref}
415
+ />
416
+ ))}
294
417
  </ol>
295
- <ShowMore rest={rest} onMore={more} />
418
+ <Pager
419
+ page={page}
420
+ pages={pages}
421
+ total={props.decisions.length}
422
+ pageSize={RECORD_PAGE_SIZE}
423
+ pageHref={props.pageHref}
424
+ onPage={setTurned}
425
+ />
296
426
  </>
297
427
  );
298
428
  }
@@ -332,6 +462,9 @@ export function DecisionList(props: DecisionListProps) {
332
462
  tasks={props.tasks}
333
463
  taskHref={props.taskHref}
334
464
  decisionHref={props.decisionHref}
465
+ docHref={props.docHref}
466
+ page={props.page}
467
+ pageHref={props.pageHref}
335
468
  />
336
469
  </section>
337
470
  )}
@@ -2,13 +2,17 @@ import { useState } from "react";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Button } from "./ui.js";
4
4
 
5
- // A long list shown a page at a time, from what the page already holds: the record of the
6
- // decisions, the feed, the tasks with no day. The next page is a click away and nothing is
7
- // asked of the server; the API's own pages (`limit`, `offset`) are for the command and scripts.
5
+ // A long list shown a page at a time, from what the page already holds: the feed and the tasks
6
+ // with no day grow by a page on a click; the record of the decisions turns its pages, each with
7
+ // an address. Nothing is asked of the server either way; the API's own pages (`limit`, `offset`)
8
+ // are for the command and scripts.
8
9
 
9
10
  /** How many items a list shows before it asks to be told to show more. */
10
11
  export const PAGE_SIZE = 50;
11
12
 
13
+ /** How many rows the record of the decisions shows on a page: each says what was asked and what was answered. */
14
+ export const RECORD_PAGE_SIZE = 20;
15
+
12
16
  /** The first `shown` items of a list, and the way to show the next page of it. */
13
17
  export function usePaged<T>(
14
18
  items: readonly T[],
@@ -37,3 +41,68 @@ export function ShowMore(props: { readonly rest: number; readonly onMore: () =>
37
41
  </div>
38
42
  );
39
43
  }
44
+
45
+ /**
46
+ * The page `page` of a list, counted from one and `pageSize` long, and which it is of how many:
47
+ * a page past the end, or no page at all, is the last or the first, and an empty list has one.
48
+ */
49
+ export function pageOf<T>(
50
+ items: readonly T[],
51
+ page: number | undefined,
52
+ pageSize = RECORD_PAGE_SIZE,
53
+ ): { readonly shown: readonly T[]; readonly page: number; readonly pages: number } {
54
+ const pages = Math.max(1, Math.ceil(items.length / pageSize));
55
+ const current =
56
+ page === undefined || !Number.isInteger(page) ? 1 : Math.min(Math.max(page, 1), pages);
57
+ return {
58
+ shown: items.slice((current - 1) * pageSize, current * pageSize),
59
+ page: current,
60
+ pages,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The way between the pages of a list: the page before, which items of how many are shown, the
66
+ * page after; nothing for a list of one page. Given `pageHref`, each page has an address and the
67
+ * steps lead there; else `onPage` turns the page in place.
68
+ */
69
+ export function Pager(props: {
70
+ readonly page: number;
71
+ readonly pages: number;
72
+ readonly total: number;
73
+ readonly pageSize: number;
74
+ readonly pageHref?: ((page: number) => string) | undefined;
75
+ readonly onPage?: ((page: number) => void) | undefined;
76
+ }) {
77
+ const words = useWords().common.pager;
78
+ if (props.pages <= 1) {
79
+ return null;
80
+ }
81
+ const from = (props.page - 1) * props.pageSize + 1;
82
+ const to = Math.min(props.page * props.pageSize, props.total);
83
+ const step = (label: string, target: number, there: boolean) => {
84
+ if (!there) {
85
+ return (
86
+ <span className="sc-button sc-button-secondary sc-button-sm" aria-disabled="true">
87
+ {label}
88
+ </span>
89
+ );
90
+ }
91
+ return props.pageHref === undefined ? (
92
+ <Button variant="secondary" size="sm" onClick={() => props.onPage?.(target)}>
93
+ {label}
94
+ </Button>
95
+ ) : (
96
+ <a className="sc-button sc-button-secondary sc-button-sm" href={props.pageHref(target)}>
97
+ {label}
98
+ </a>
99
+ );
100
+ };
101
+ return (
102
+ <nav className="sc-pager" aria-label={words.label}>
103
+ {step(words.previous, props.page - 1, props.page > 1)}
104
+ <span className="sc-pager-range">{words.range(from, to, props.total)}</span>
105
+ {step(words.next, props.page + 1, props.page < props.pages)}
106
+ </nav>
107
+ );
108
+ }
@@ -2,9 +2,10 @@ import type { RestSkills } from "../api.js";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Callout, EmptyState, Spinner } from "./ui.js";
4
4
 
5
- // The organization's skills, as SkillCDN serves them at the console's address: where a person
6
- // reads each, and what an agent loads it by. Takes its data as props and nothing from the
7
- // network; what the console answered is shown as text, never as HTML.
5
+ // A project's skills, as the pack lists them: at a SkillCDN deployment, where a person reads
6
+ // each and what an agent loads it by; or from a directory on the console's machine (ADR-0031),
7
+ // with nothing to link to. Takes its data as props and nothing from the network; what the
8
+ // console answered is shown as text, never as HTML.
8
9
 
9
10
  export interface SkillListProps {
10
11
  /** What the console answered, or nothing while it has not yet. */
@@ -21,35 +22,48 @@ export function SkillList(props: SkillListProps) {
21
22
  if (skills.status === "unavailable" && skills.address === null) {
22
23
  return <Callout tone="warning">{words.status.unavailable}</Callout>;
23
24
  }
24
- if (skills.status === "none" || skills.address === null || skills.page === null) {
25
+ if (skills.status === "none" || skills.address === null) {
25
26
  return <EmptyState title={words.noAddress.title} body={words.noAddress.body} />;
26
27
  }
27
- const where = (
28
- <>
29
- <p className="sc-lead">
30
- {words.servedBy}{" "}
31
- <a href={skills.source} target="_blank" rel="noopener noreferrer">
32
- SkillCDN
33
- </a>{" "}
34
- {words.at}{" "}
35
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
36
- <code>{skills.address}</code>
37
- </a>
38
- </p>
39
- <p className="sc-muted">
40
- {words.connect}{" "}
41
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
42
- {words.connectLink}
43
- </a>
44
- </p>
45
- </>
46
- );
28
+ const deployment =
29
+ skills.source !== null && skills.page !== null
30
+ ? { source: skills.source, page: skills.page }
31
+ : undefined;
32
+ const local = deployment === undefined;
33
+ const where =
34
+ deployment === undefined ? (
35
+ <>
36
+ <p className="sc-lead">
37
+ {words.fromMachine} <code>{skills.address}</code>
38
+ </p>
39
+ <p className="sc-muted">{words.connectLocal}</p>
40
+ </>
41
+ ) : (
42
+ <>
43
+ <p className="sc-lead">
44
+ {words.servedBy}{" "}
45
+ <a href={deployment.source} target="_blank" rel="noopener noreferrer">
46
+ SkillCDN
47
+ </a>{" "}
48
+ {words.at}{" "}
49
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
50
+ <code>{skills.address}</code>
51
+ </a>
52
+ </p>
53
+ <p className="sc-muted">
54
+ {words.connect}{" "}
55
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
56
+ {words.connectLink}
57
+ </a>
58
+ </p>
59
+ </>
60
+ );
47
61
  if (skills.status !== "ready") {
48
62
  return (
49
63
  <>
50
64
  {where}
51
65
  <Callout tone={skills.status === "indexing" ? "info" : "warning"}>
52
- {words.status[skills.status]}
66
+ {(local ? words.localStatus[skills.status] : undefined) ?? words.status[skills.status]}
53
67
  </Callout>
54
68
  </>
55
69
  );
@@ -69,14 +83,18 @@ export function SkillList(props: SkillListProps) {
69
83
  {skills.items.map((skill) => (
70
84
  <li key={skill.path} className="sc-skill">
71
85
  <p className="sc-skill-head">
72
- <a
73
- className="sc-skill-name"
74
- href={skill.page}
75
- target="_blank"
76
- rel="noopener noreferrer nofollow"
77
- >
78
- {skill.name}
79
- </a>
86
+ {skill.page === null ? (
87
+ <span className="sc-skill-name">{skill.name}</span>
88
+ ) : (
89
+ <a
90
+ className="sc-skill-name"
91
+ href={skill.page}
92
+ target="_blank"
93
+ rel="noopener noreferrer nofollow"
94
+ >
95
+ {skill.name}
96
+ </a>
97
+ )}
80
98
  <span className="sc-muted">{skill.directory === "" ? "/" : skill.directory}</span>
81
99
  </p>
82
100
  <p className="sc-skill-description">{skill.description}</p>
@@ -9,7 +9,7 @@ import {
9
9
  type TaskPriority,
10
10
  type TaskState,
11
11
  } from "../api.js";
12
- import { daysBetween, formatDay, today } from "../dates.js";
12
+ import { dayOf, daysBetween, formatDay, today } from "../dates.js";
13
13
  import { useLanguage, useWords } from "../i18n/index.js";
14
14
 
15
15
  // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
@@ -356,11 +356,24 @@ export function formatBytes(size: number): string {
356
356
  return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
357
357
  }
358
358
 
359
- export function Time(props: { readonly iso: string }) {
359
+ export function Time(props: {
360
+ readonly iso: string;
361
+ /** `day` shows the day alone, with the moment as the title, where a row has no room for the hour. */
362
+ readonly precision?: "minute" | "day";
363
+ }) {
360
364
  const { tag } = useLanguage();
365
+ const full = formatInstant(props.iso, tag);
366
+ if (props.precision === "day") {
367
+ const date = new Date(props.iso);
368
+ return (
369
+ <time className="sc-time" dateTime={props.iso} title={full}>
370
+ {Number.isNaN(date.getTime()) ? props.iso : formatDay(dayOf(date), tag)}
371
+ </time>
372
+ );
373
+ }
361
374
  return (
362
375
  <time className="sc-time" dateTime={props.iso}>
363
- {formatInstant(props.iso, tag)}
376
+ {full}
364
377
  </time>
365
378
  );
366
379
  }
package/src/console.tsx CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  calendarHref,
99
99
  customPageHref,
100
100
  decisionHref,
101
+ decisionsHref,
101
102
  docHref,
102
103
  type GoalTaskView,
103
104
  goalHref,
@@ -1112,6 +1113,8 @@ function ProjectPage(props: {
1112
1113
  taskHref={href}
1113
1114
  docHref={toDoc}
1114
1115
  decisionHref={(decision) => decisionHref(key, decision.id)}
1116
+ page={route.page}
1117
+ pageHref={(page) => decisionsHref(key, page)}
1115
1118
  onAnswer={onAnswer}
1116
1119
  onUpdate={onUpdate}
1117
1120
  busy={busy}