@skillcdn/console 0.1.20 → 0.1.21

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.
package/docs/specs/cli.md CHANGED
@@ -24,7 +24,7 @@ The board is a project's ([ADR-0008](../adr/0008-a-workspace-holds-projects-and-
24
24
  | `login --url <origin> [--agent <name>] [--token-stdin] [--default]` | `POST /api/v1/connect`, then `POST /api/v1/connect/claim` until a person approved, then `GET /api/v1/me` with the token; `DELETE /api/v1/me/token` with the token a repeated login replaces | the address and the code for a person; then who the token is, and whether this is the default console; keeps the login |
25
25
  | `logins` | nothing | one line per login: the console, the account, what the token is called, since when, and which is the default; never a token |
26
26
  | `logout [--url <origin>] [--account <login>]` | `DELETE /api/v1/me/token` | that the login's token is given up and forgotten, or that the console was out of reach and the token is left to the Agents page |
27
- | `whoami` | `GET /api/v1/me` | the login, the role, the workspace, the address, and the project this directory works in; then the command's version and where it is installed, the console's version and the range it takes, and the update to make when they differ |
27
+ | `whoami` | `GET /api/v1/me` | the login, the role, the workspace, the address, and the project this directory works in; with a project, where the organization's rules and the skill working-the-board are read; then the command's version and where it is installed, the console's version and the range it takes, and the update to make when they differ |
28
28
  | `projects` | `GET /api/v1/projects` | one line per project: key, name, what the person is in it, what waits in it |
29
29
  | `use <key> [--url <origin>] [--account <login>]` | `GET /api/v1/projects/<key>` | that the directory works in the project from now on, at which console and as which account where it binds them, and that `console rules` says what its people ask; writes `.skillcdn-console.json` |
30
30
  | `work [--person <login>]` | `GET /api/v1/work`, `GET /api/v1/people/<id>/work` | what is on the person's plate across their projects: the open tasks assigned to them, the sooner due first, each with its project; and the decisions waiting. `--person` shows another person's plate, for an administrator or an owner of a project they are in: their open tasks within the projects the asker may see, without the decisions. Needs no project |
@@ -32,7 +32,7 @@ The board is a project's ([ADR-0008](../adr/0008-a-workspace-holds-projects-and-
32
32
  | `task <number\|id>` | `GET .../tasks/<ref>`, the decisions about it, the runs on it, `GET .../tasks/<id>/comments` | the task in full, with its plan and its moments, and the discussion on it: each comment whole, with its files and the command that reads each |
33
33
  | `task new <title> [--body <markdown>\|--file <path>] [--state <state>] [--priority <priority>] [--category <key>] [--goal <id>] [--starts <day>] [--due <day>] [--assignee <login\|me>] [--parent <number\|id>]` | `POST .../tasks` | the number and the id |
34
34
  | `task edit <number\|id> [--title <title>] [--body ...] [the same flags; `none` clears a field]` | `GET .../tasks/<ref>`, then `PATCH .../tasks/<id>` | the task in full, as it is now |
35
- | `task archive <number\|id>` | `GET .../tasks/<ref>`, then `POST .../tasks/<id>/archive` | the task's line, put away ([ADR-0029](../adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md)): out of the listings, the board and the calendar, open by its number still; an open task is refused (`task.open`) |
35
+ | `task archive <number\|id>` | `GET .../tasks/<ref>`, then `POST .../tasks/<id>/archive` | the task's line, put away ([ADR-0029](../adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md)), whatever its state ([ADR-0030](../adr/0030-a-task-is-put-away-from-any-state.md)): out of the listings, the board and the calendar, open by its number still |
36
36
  | `task archive --goal <id>` | `POST .../tasks/archive` | how many done or dropped tasks of the goal were put away; for an owner |
37
37
  | `task restore <number\|id>` | `GET .../tasks/<ref>`, then `POST .../tasks/<id>/restore` | the task's line, back on the board |
38
38
  | `goals [--category <key>] [--state <state>]` | `GET .../goals?category=&state=` | one line per goal of the roadmap: id, state, title, category, the days it starts and is meant to be reached by, how far it is ([ADR-0026](../adr/0026-a-goal-is-planned-in-days-and-sorted-into-a-category-and-the-roadmap-is-read-as-months.md)) |
@@ -59,7 +59,6 @@ The API is the board as its members see it: the people, the projects, and in eac
59
59
  | 409 | `decision.answered` | The decision has an answer already. |
60
60
  | 409 | `document.conflict` | The page has moved on since the version the write started from (`baseVersion`), or was written at that path by someone else first. |
61
61
  | 409 | `document.archived` | The page is archived: restore it before writing to it or attaching to it. |
62
- | 409 | `task.open` | The task is open: only one done or dropped is put away ([ADR-0029](../adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md)). |
63
62
  | 409 | `document.too_many_versions`, `document.too_many_files` | The page carries as many versions, or files, as one may (`MAX_VERSIONS_PER_DOCUMENT`, `MAX_FILES_PER_DOCUMENT`). |
64
63
  | 409 | `token.too_many` | The person holds as many live tokens as one may (`MAX_TOKENS_PER_PERSON`). |
65
64
  | 409 | `connect.approved` | The connection was approved already; the command claims its token. |
@@ -188,7 +187,7 @@ One task, by its id or by its number in the project (`/tasks/7`): the pages hold
188
187
 
189
188
  ### `POST .../tasks/<id>/archive`, `POST .../tasks/<id>/restore` and `POST /api/v1/projects/<key>/tasks/archive`
190
189
 
191
- Putting a task away, and bringing it back ([ADR-0029](../adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md)). A task done or dropped is put away by whoever may work in the project, with a session or a token, and brought back the same way: putting away is a moment the board keeps (`archivedAt`), not a state, so the task stays done or dropped, counts toward its goal as before, and keeps its runs, decisions, comments and events; it is out of the listings, the board and the calendar, and open at its own address by id or by number still. An open task is not put away (`task.open`, 409). Each answers the task and writes `task.archived` or `task.restored` with the task's `number` and `title`; what is so already answers the task and writes nothing. `POST .../tasks/archive`, for an owner, takes `{ "goalId" }` and puts every done or dropped task of that goal away at once, one event each, answering `{ "archived": n }`, how many went; a goal with nothing to put away, or that is not the project's, is `0`. The console puts away by itself what was done or dropped and not touched for `TASK_ARCHIVE_AFTER_DAYS` ([deploy/README.md](../../deploy/README.md#environment-contract)), each with a `task.archived` event that names no actor.
190
+ Putting a task away, and bringing it back ([ADR-0029](../adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md), [ADR-0030](../adr/0030-a-task-is-put-away-from-any-state.md)). A task is put away, whatever its state, by whoever may work in the project, with a session or a token, and brought back the same way: putting away is a moment the board keeps (`archivedAt`), not a state, so the task keeps its state, counts toward its goal as before, and keeps its runs, decisions, comments and events; it is out of the listings, the board and the calendar, and open at its own address by id or by number still. Each answers the task and writes `task.archived` or `task.restored` with the task's `number` and `title`; what is so already answers the task and writes nothing. `POST .../tasks/archive`, for an owner, takes `{ "goalId" }` and puts every done or dropped task of that goal away at once, one event each, answering `{ "archived": n }`, how many went; a goal with nothing to put away, or that is not the project's, is `0`. The console puts away by itself what was done or dropped and not touched for `TASK_ARCHIVE_AFTER_DAYS` ([deploy/README.md](../../deploy/README.md#environment-contract)), each with a `task.archived` event that names no actor.
192
191
 
193
192
  ### `GET .../tasks/<id or number>/comments` and `POST .../tasks/<id or number>/comments`
194
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillcdn/console",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "What a custom SkillCDN console is built from: the API client and schemas, the components, the composition of the default console, and the console command agents work the board with.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/skillcdn/console#readme",
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Needs a shell with the console command on the PATH (npm install -g @skillcdn/console, Node.js 24 or newer), signed in by a person. Nothing else; the command needs no key of its own and the agent never sees a token.
6
6
  metadata:
7
7
  author: skillcdn
8
- version: "1.13"
8
+ version: "1.14"
9
9
  skillcdn:
10
10
  translations:
11
11
  ko:
@@ -50,7 +50,7 @@ Each phase ends with something on the board. Only phase 4 waits for a person.
50
50
 
51
51
  ### Phase 1: Read
52
52
 
53
- Told which task, go to phase 2: `console take` prints the rules of the board with the task, and they are read there, once. Choosing one, or checking one before taking it, `console rules` says which tasks to take and what to load, and `console task <number>` shows a task in full, with its plan, the decisions about it, the runs on it and the discussion on it, without taking it. A run open on it means another agent was at it, and may still be: the console cannot tell ([ADR-0020](/docs/adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), so read its reports and when it was last heard from, and take the task only when the person said to, since taking it ends that run as taken over; otherwise say so and stop. A task that is past its due day is still the task; the first report says so, and what the day was for is kept in mind. A task put away once done or dropped ([ADR-0029](/docs/adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md)) is out of `console tasks` unless `--archived` asks for the archive, and `console task <number>` opens it still. Read what the body links to, with `console doc <path>` for the project's pages and `console skills` for the organization's skills, before anything is made.
53
+ Told which task, go to phase 2: `console take` prints the rules of the board with the task, and they are read there, once. Choosing one, or checking one before taking it, `console rules` says which tasks to take and what to load, and `console task <number>` shows a task in full, with its plan, the decisions about it, the runs on it and the discussion on it, without taking it. A run open on it means another agent was at it, and may still be: the console cannot tell ([ADR-0020](/docs/adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), so read its reports and when it was last heard from, and take the task only when the person said to, since taking it ends that run as taken over; otherwise say so and stop. A task that is past its due day is still the task; the first report says so, and what the day was for is kept in mind. A task put away ([ADR-0029](/docs/adr/0029-a-task-done-or-dropped-is-put-away-out-of-the-board-and-open-by-its-number.md), from any state since [ADR-0030](/docs/adr/0030-a-task-is-put-away-from-any-state.md)) is out of `console tasks` unless `--archived` asks for the archive, and `console task <number>` opens it still. Read what the body links to, with `console doc <path>` for the project's pages and `console skills` for the organization's skills, before anything is made.
54
54
 
55
55
  ### Phase 2: Take
56
56
 
package/src/cli/cli.ts CHANGED
@@ -844,6 +844,12 @@ const whoami: Command = async (args, io) => {
844
844
  () =>
845
845
  [
846
846
  `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
847
+ // The pack's rules send an agent here first; from here the rules and the skill are the next steps.
848
+ ...(key === undefined
849
+ ? []
850
+ : [
851
+ "What this organization asks of agents: console rules. How the board is worked: the skill working-the-board, shipped with this command (console skills says where it is).",
852
+ ]),
847
853
  `This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
848
854
  ...(verdict === "behind"
849
855
  ? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
package/src/cli/help.ts CHANGED
@@ -131,17 +131,13 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
131
131
  task: {
132
132
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task archive <number|id> | task archive --goal <id> | task restore <number|id>`,
133
133
  about:
134
- "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself, and when it was put away), the decisions about it, the runs on it and the discussion on it, each comment whole with its files; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent). archive puts a done or dropped task away (ADR-0029): out of the listings, the board and the calendar, open by its number still; with --goal, every done or dropped task of the goal at once, for an owner. restore brings one back.",
134
+ "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself, and when it was put away), the decisions about it, the runs on it and the discussion on it, each comment whole with its files; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent). archive puts a task away, whatever its state (ADR-0029, ADR-0030): out of the listings, the board and the calendar, open by its number still; with --goal, every done or dropped task of the goal at once, for an owner. restore brings one back.",
135
135
  limits: [
136
136
  `title: one line, up to ${MAX_TITLE_LENGTH} characters, saying what is to be done on its own; what it refers to goes in the body`,
137
137
  `body: ${MARKDOWN}`,
138
138
  ...TASK_FIELD_LIMITS,
139
139
  ],
140
- refusals: [
141
- "task.not_found: no task has that number or id",
142
- "task.open: an open task is not put away; one done or dropped is",
143
- ...TASK_FIELD_REFUSALS,
144
- ],
140
+ refusals: ["task.not_found: no task has that number or id", ...TASK_FIELD_REFUSALS],
145
141
  },
146
142
  goals: {
147
143
  usage: "goals [--category <key>] [--state <state>]",
@@ -450,6 +446,8 @@ const preamble = (version: string | undefined): string =>
450
446
  `console${version === undefined ? "" : ` ${version}`}: the board of your organization's work with AI agents, from the command line.
451
447
  You act as the person whose token you hold; everything you send is shown to people as text.
452
448
  The rules of this organization come with the task: console take prints them; console rules reads them any time.
449
+ How the board is worked is the skill working-the-board, installed with this command: console skills prints
450
+ where it is; read it before the first command of a run.
453
451
 
454
452
  Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
455
453
  `;
@@ -18,7 +18,6 @@ import {
18
18
  } from "../api.js";
19
19
  import { useWords } from "../i18n/index.js";
20
20
  import type { BoardFilter } from "../router.js";
21
- import { OPEN_TASK_STATES } from "../vocabulary.js";
22
21
  import { isPlainClick } from "./shell.js";
23
22
  import {
24
23
  Avatar,
@@ -35,12 +34,13 @@ import {
35
34
  // A card says what a person needs at a glance and nothing more: the number and the title, the
36
35
  // category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
37
36
  // said, since the board cannot know (ADR-0020); the task's page shows its runs. A card is moved
38
- // to another column by hand: pressed anywhere but on its title and its menu, then dragged, with
39
- // a mouse at once and under a finger once it has rested a moment, so that a finger that moves
40
- // at once scrolls as it always did. The card's menu holds the rest: the way to the task's form,
41
- // and the way to put the task away once it is done or dropped (ADR-0029). The keyboard changes a
42
- // state on the task's page. The board takes its data as props and nothing from the network;
43
- // what it is told on a click goes back up.
37
+ // to another column by hand: pressed anywhere but on its menu, its title included, then dragged
38
+ // whole, with a mouse at once and under a finger once it has rested a moment, so that a finger
39
+ // that moves at once scrolls as it always did; a click on the title opens the task as long as the
40
+ // card was not dragged. The card's menu holds the rest: the way to the task's form, and the way to
41
+ // put the task away (ADR-0029, ADR-0030). The keyboard changes a state on the task's page. The
42
+ // board takes its data as props and nothing from the network; what it is told on a click goes
43
+ // back up.
44
44
 
45
45
  export interface BoardProps {
46
46
  readonly tasks: readonly RestTask[];
@@ -57,7 +57,7 @@ export interface BoardProps {
57
57
  readonly editHref?: ((task: RestTask) => string) | undefined;
58
58
  /** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
59
59
  readonly onEdit?: ((task: RestTask) => void) | undefined;
60
- /** Called when a task done or dropped is put away from its card's menu (ADR-0029). Left out, the menu offers nothing of it. */
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
62
  }
63
63
 
@@ -78,12 +78,21 @@ export function columnStateOf(value: string | null | undefined): TaskState | und
78
78
  return TASK_STATES.find((state) => state === value);
79
79
  }
80
80
 
81
+ interface Point {
82
+ readonly x: number;
83
+ readonly y: number;
84
+ }
85
+
81
86
  /** A card on its way to another column. */
82
87
  interface Drag {
83
88
  readonly task: RestTask;
84
- readonly from: { readonly x: number; readonly y: number };
89
+ readonly from: Point;
85
90
  readonly x: number;
86
91
  readonly y: number;
92
+ /** Where on the card it was taken hold of, so that the card moves whole under the pointer. */
93
+ readonly grip: Point;
94
+ /** How wide the card is where it stands, so that the one in hand is the same. */
95
+ readonly width: number;
87
96
  /** Whether the card has come loose: the mouse has travelled, or the finger has rested. */
88
97
  readonly moving: boolean;
89
98
  /** Whether the press is a finger's, which must rest before the card comes loose. */
@@ -97,23 +106,21 @@ export interface TaskCardProps {
97
106
  readonly href: string;
98
107
  readonly onOpen: (task: RestTask) => void;
99
108
  readonly today?: string | undefined;
100
- /** Called when the card is pressed anywhere but on its controls, to drag it to another column; left out, the card stays where it is. */
109
+ /** Called when the card is pressed anywhere but on its menu, to drag it to another column; left out, the card stays where it is. */
101
110
  readonly onPress?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
102
- /** Whether the card is on its way to another column. */
111
+ /** Whether the card is on its way to another column: the one left in its place is faded. */
103
112
  readonly dragging?: boolean | undefined;
104
113
  /** Where the task is edited; given, the card's menu leads there. */
105
114
  readonly editHref?: string | undefined;
106
115
  /** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
107
116
  readonly onEdit?: ((task: RestTask) => void) | undefined;
108
- /** Called when the task is put away from the card's menu; offered once the task is done or dropped (ADR-0029). */
117
+ /** Called when the task is put away from the card's menu, whatever its state (ADR-0030). */
109
118
  readonly onArchive?: ((task: RestTask) => void) | undefined;
110
119
  }
111
120
 
112
121
  export function TaskCard(props: TaskCardProps) {
113
122
  const words = useWords();
114
123
  const { task, onPress } = props;
115
- const closed = !OPEN_TASK_STATES.includes(task.state);
116
- const archive = closed ? props.onArchive : undefined;
117
124
  return (
118
125
  <article
119
126
  className={cx(
@@ -124,12 +131,19 @@ export function TaskCard(props: TaskCardProps) {
124
131
  aria-label={`#${task.number} ${task.title}`}
125
132
  onPointerDown={onPress === undefined ? undefined : (event) => onPress(task, event)}
126
133
  >
127
- {(props.editHref !== undefined || archive !== undefined) && (
128
- <CardMenu task={task} editHref={props.editHref} onEdit={props.onEdit} onArchive={archive} />
134
+ {(props.editHref !== undefined || props.onArchive !== undefined) && (
135
+ <CardMenu
136
+ task={task}
137
+ editHref={props.editHref}
138
+ onEdit={props.onEdit}
139
+ onArchive={props.onArchive}
140
+ />
129
141
  )}
130
142
  <a
131
143
  className="sc-card-title"
132
144
  href={props.href}
145
+ // The card is dragged by our own hand, the title included; the browser's drag of a link is not it.
146
+ draggable={false}
133
147
  onClick={(event) => {
134
148
  if (isPlainClick(event)) {
135
149
  event.preventDefault();
@@ -169,7 +183,8 @@ const MENU_MARGIN = 8;
169
183
  /**
170
184
  * The card's menu: three dots in its corner, which open the ways a card has beyond being moved,
171
185
  * each given by the composition. The menu is rendered over the document, so that no column
172
- * clips it; a press elsewhere, the escape key, a scroll or a change of the window closes it.
186
+ * clips it; a press elsewhere, the escape key, a scroll or a change of the window closes it, and
187
+ * so does the button that opened it.
173
188
  */
174
189
  function CardMenu(props: {
175
190
  readonly task: RestTask;
@@ -194,6 +209,13 @@ function CardMenu(props: {
194
209
  right: Math.max(MENU_MARGIN, window.innerWidth - rect.right),
195
210
  });
196
211
  };
212
+ /** Whether the focus, or a press, is on the menu or on its button: the menu's own. */
213
+ const own = useCallback(
214
+ (target: EventTarget | null): boolean =>
215
+ target instanceof Node &&
216
+ (panel.current?.contains(target) === true || button.current?.contains(target) === true),
217
+ [],
218
+ );
197
219
  useEffect(() => {
198
220
  if (!open) {
199
221
  return;
@@ -214,14 +236,9 @@ function CardMenu(props: {
214
236
  }
215
237
  panel.current?.querySelector<HTMLElement>("[role='menuitem']")?.focus({ preventScroll: true });
216
238
  const away = (event: PointerEvent) => {
217
- const target = event.target;
218
- if (
219
- target instanceof Node &&
220
- (panel.current?.contains(target) === true || button.current?.contains(target) === true)
221
- ) {
222
- return;
239
+ if (!own(event.target)) {
240
+ close();
223
241
  }
224
- close();
225
242
  };
226
243
  const key = (event: KeyboardEvent) => {
227
244
  if (event.key === "Escape") {
@@ -239,7 +256,7 @@ function CardMenu(props: {
239
256
  window.removeEventListener("scroll", close, true);
240
257
  window.removeEventListener("resize", close);
241
258
  };
242
- }, [open, close]);
259
+ }, [open, close, own]);
243
260
  // The arrow keys walk the items, around the ends.
244
261
  const walk = (event: ReactKeyboardEvent<HTMLDivElement>) => {
245
262
  if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
@@ -283,9 +300,9 @@ function CardMenu(props: {
283
300
  aria-label={`#${task.number} ${task.title}`}
284
301
  style={{ top: place.top, right: place.right }}
285
302
  onKeyDown={walk}
303
+ // The focus leaving for anywhere but the menu's own button closes it; the button closes it itself.
286
304
  onBlur={(event) => {
287
- const next = event.relatedTarget;
288
- if (!(next instanceof Node && panel.current?.contains(next) === true)) {
305
+ if (!own(event.relatedTarget)) {
289
306
  close();
290
307
  }
291
308
  }}
@@ -340,6 +357,8 @@ export function byDue(a: RestTask, b: RestTask): number {
340
357
  return b.number - a.number;
341
358
  }
342
359
 
360
+ const nothing = () => undefined;
361
+
343
362
  export function Board(props: BoardProps) {
344
363
  const words = useWords();
345
364
  const { onMove } = props;
@@ -353,10 +372,10 @@ export function Board(props: BoardProps) {
353
372
  const loose = useRef(false);
354
373
 
355
374
  const press = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
356
- // The card's own controls, its title and its menu, are pressed for themselves.
375
+ // The card's menu is pressed for itself; everything else on the card takes hold of it.
357
376
  if (
358
377
  event.button !== 0 ||
359
- (event.target instanceof Element && event.target.closest("a, button") !== null)
378
+ (event.target instanceof Element && event.target.closest("button") !== null)
360
379
  ) {
361
380
  return;
362
381
  }
@@ -365,14 +384,26 @@ export function Board(props: BoardProps) {
365
384
  // The press is the drag's beginning and nothing else: no text is selected on the way.
366
385
  event.preventDefault();
367
386
  }
387
+ const rect = event.currentTarget.getBoundingClientRect();
368
388
  const from = { x: event.clientX, y: event.clientY };
369
- setDrag({ task, from, x: from.x, y: from.y, moving: false, hold, over: undefined });
389
+ setDrag({
390
+ task,
391
+ from,
392
+ x: from.x,
393
+ y: from.y,
394
+ grip: { x: from.x - rect.left, y: from.y - rect.top },
395
+ width: rect.width,
396
+ moving: false,
397
+ hold,
398
+ over: undefined,
399
+ });
370
400
  }, []);
371
401
 
372
402
  // While a card is held, the pointer is followed over the whole document: the column under it
373
403
  // is marked, the board scrolls when the pointer nears its edge, and the release moves the task.
374
404
  // A finger's hold is timed here, and once the card has come loose the page no longer scrolls
375
- // under the finger, nor asks for the menu a long press would otherwise bring up.
405
+ // under the finger, nor asks for the menu a long press would otherwise bring up; the click that
406
+ // follows a drag opens nothing.
376
407
  const held = drag !== null;
377
408
  useEffect(() => {
378
409
  if (!held) {
@@ -421,8 +452,18 @@ export function Board(props: BoardProps) {
421
452
  return { ...before, moving: true, over: columnUnder(before.x, before.y) };
422
453
  });
423
454
  };
455
+ const swallow = (event: MouseEvent) => {
456
+ event.preventDefault();
457
+ event.stopPropagation();
458
+ };
424
459
  const release = (event: PointerEvent) => {
425
460
  const last = current.current;
461
+ if (loose.current) {
462
+ // The click the browser sends after the pointer is let go would open the title's task:
463
+ // it is swallowed, and the ear for it taken down once that moment has passed.
464
+ window.addEventListener("click", swallow, true);
465
+ window.setTimeout(() => window.removeEventListener("click", swallow, true), 0);
466
+ }
426
467
  if (last?.moving === true && event.type === "pointerup") {
427
468
  const over = columnUnder(event.clientX, event.clientY);
428
469
  if (over !== undefined && over !== last.task.state) {
@@ -506,12 +547,24 @@ export function Board(props: BoardProps) {
506
547
  );
507
548
  })}
508
549
  {dragging !== null && (
550
+ // The card in hand: the whole of it, as wide as where it stands, under the pointer where it was taken hold of.
509
551
  <div
510
552
  className="sc-drag-ghost"
511
553
  aria-hidden="true"
512
- style={{ transform: `translate(${dragging.x + 12}px, ${dragging.y + 12}px)` }}
554
+ style={{
555
+ width: dragging.width,
556
+ transform: `translate(${dragging.x - dragging.grip.x}px, ${dragging.y - dragging.grip.y}px)`,
557
+ }}
513
558
  >
514
- <span className="sc-card-number">#{dragging.task.number}</span> {dragging.task.title}
559
+ <TaskCard
560
+ task={dragging.task}
561
+ href={props.taskHref(dragging.task)}
562
+ onOpen={nothing}
563
+ today={props.today}
564
+ // The menu too, inert under the pointer, so that the card in hand keeps the shape of the one in its place.
565
+ editHref={props.editHref?.(dragging.task)}
566
+ onArchive={props.onArchive}
567
+ />
515
568
  </div>
516
569
  )}
517
570
  </div>
@@ -16,7 +16,7 @@ import type {
16
16
  import { formatDay } from "../dates.js";
17
17
  import { useLanguage, useWords } from "../i18n/index.js";
18
18
  import type { TaskFormKind } from "../router.js";
19
- import { OPEN_TASK_STATES, TASK_STATES } from "../vocabulary.js";
19
+ import { TASK_STATES } from "../vocabulary.js";
20
20
  import { type CommentDraft, TaskThread } from "./comments.js";
21
21
  import { DecisionForm } from "./decision-form.js";
22
22
  import { EventFeed } from "./event-feed.js";
@@ -354,17 +354,15 @@ export function TaskView(props: TaskViewProps) {
354
354
  <a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
355
355
  {words.task.raiseDecision}
356
356
  </a>
357
- {props.onArchive !== undefined &&
358
- task.archivedAt === null &&
359
- !OPEN_TASK_STATES.includes(task.state) && (
360
- <Button
361
- variant="secondary"
362
- disabled={props.busy === true}
363
- onClick={() => props.onArchive?.(task)}
364
- >
365
- {words.task.archive}
366
- </Button>
367
- )}
357
+ {props.onArchive !== undefined && task.archivedAt === null && (
358
+ <Button
359
+ variant="secondary"
360
+ disabled={props.busy === true}
361
+ onClick={() => props.onArchive?.(task)}
362
+ >
363
+ {words.task.archive}
364
+ </Button>
365
+ )}
368
366
  {props.onRestore !== undefined && task.archivedAt !== null && (
369
367
  <Button
370
368
  variant="secondary"
package/src/console.tsx CHANGED
@@ -738,7 +738,14 @@ function ProjectPage(props: {
738
738
  return (
739
739
  <>
740
740
  <div className="sc-page-head">
741
- <h1 className="sc-page-title">{project.name}</h1>
741
+ <div className="sc-page-heading">
742
+ <h1 className="sc-page-title">{project.name}</h1>
743
+ {project.description.length > 0 && (
744
+ <p className="sc-page-lead" title={project.description}>
745
+ {project.description}
746
+ </p>
747
+ )}
748
+ </div>
742
749
  <div className="sc-page-actions">
743
750
  <LinkButton href={projectHref(key, "archive")}>{words.board.archive}</LinkButton>
744
751
  <LinkButton href={newTaskHref(key)} variant="primary">
@@ -746,7 +753,6 @@ function ProjectPage(props: {
746
753
  </LinkButton>
747
754
  </div>
748
755
  </div>
749
- {project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
750
756
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
751
757
  {data.tasks.length > 0 && (
752
758
  <BoardFilters
package/src/i18n/en.ts CHANGED
@@ -605,7 +605,7 @@ export const en = {
605
605
  loading: "Loading the archive",
606
606
  empty: {
607
607
  title: "Nothing is put away",
608
- body: "A task done or dropped is put away from its page, with its goal, or by the console after a while.",
608
+ body: "A task is put away from its page or its card, with its goal once the goal is closed, or by the console a while after it was done or dropped.",
609
609
  },
610
610
  since: "put away",
611
611
  restore: "Bring back",
package/src/i18n/ko.ts CHANGED
@@ -569,7 +569,7 @@ export const ko: Messages = {
569
569
  loading: "보관함을 불러오는 중",
570
570
  empty: {
571
571
  title: "보관된 작업이 없습니다",
572
- body: "완료·폐기된 작업은 작업 페이지에서, 목표를 닫을 때 함께, 또는 일정 기간 뒤 콘솔이 보관합니다.",
572
+ body: "작업은 작업 페이지나 카드 메뉴에서, 목표를 닫을 때 함께, 또는 완료·폐기된 뒤 일정 기간이 지나면 콘솔이 보관합니다.",
573
573
  },
574
574
  since: "보관",
575
575
  restore: "되살리기",
package/src/rules.ts CHANGED
@@ -23,8 +23,9 @@ export const REFERENCE_RULES = [
23
23
  "",
24
24
  "## Start here, every time",
25
25
  "",
26
- "1. These rules come with the task: `console take` prints them under it, and `console rules` reads them any time. Read them before anything is made. The task says where the work is and what it links to; `console task <number>` shows a task in full, with its plan, its decisions, the runs on it and the discussion on it, without taking it. A task put away (archived) once done or dropped is out of the listings and the board, and opens by its number still. How the board is worked is the skill working-the-board, shipped with the command (`console skills` says where it is); these rules say what this organization asks, not how the command works.",
26
+ "1. These rules come with the task: `console take` prints them under it, and `console rules` reads them any time. Read them before anything is made. The task says where the work is and what it links to; `console task <number>` shows a task in full, with its plan, its decisions, the runs on it and the discussion on it, without taking it. A task put away (archived) is out of the listings and the board, and opens by its number still. How the board is worked is the skill working-the-board, shipped with the command: read it before the first command of a run (`console skills` prints where it is); these rules say what this organization asks, not how the command works.",
27
27
  "2. Act as the person whose token the command holds, and no further. What they may not do, the console refuses; that step goes back to them in a report, never around them.",
28
+ '3. Work given in chat, with no task on the board yet, goes on the board before it is done: write it (`console task new "<title>" --body <markdown>`), tell the person its number, take it (`console take <number>`) and work it as any task, without asking whether to. Until the task is written, the board is only read.',
28
29
  "",
29
30
  "## The organization's skills",
30
31
  "",
@@ -718,6 +718,34 @@ table {
718
718
  gap: var(--sc-space-2);
719
719
  }
720
720
 
721
+ /* A page's name with a word about it beside: on one line where the window is wide, so that the
722
+ head stays low over the board; under the name where it is not. */
723
+ .sc-page-heading {
724
+ display: flex;
725
+ flex: 1 1 auto;
726
+ flex-wrap: wrap;
727
+ align-items: baseline;
728
+ min-width: 0;
729
+ column-gap: var(--sc-space-4);
730
+ row-gap: var(--sc-space-1);
731
+ }
732
+
733
+ .sc-page-lead {
734
+ flex: 1 1 16rem;
735
+ min-width: 0;
736
+ margin: 0;
737
+ color: var(--sc-color-text-muted);
738
+ font-size: var(--sc-text-sm);
739
+ }
740
+
741
+ @media (min-width: 60rem) {
742
+ .sc-page-lead {
743
+ overflow: hidden;
744
+ text-overflow: ellipsis;
745
+ white-space: nowrap;
746
+ }
747
+ }
748
+
721
749
  .sc-lead {
722
750
  max-width: var(--sc-width-prose);
723
751
  margin-bottom: var(--sc-space-5);
@@ -786,6 +814,12 @@ table {
786
814
  pointer-events: none;
787
815
  }
788
816
 
817
+ /* A column's frame stands in front of what scrolls within it: a card scrolling out goes under
818
+ the lit edge, never over it. The other panes do not scroll, and keep the rim under the content. */
819
+ .sc-column::before {
820
+ z-index: 1;
821
+ }
822
+
789
823
  .sc-panel {
790
824
  margin-bottom: var(--sc-space-5);
791
825
  padding: var(--sc-space-5);
@@ -3768,16 +3802,16 @@ table {
3768
3802
  }
3769
3803
  }
3770
3804
 
3771
- /* A card is dragged to another column from anywhere on it but its title and its menu: with a
3772
- mouse at once, under a finger once it has rested a moment, so that a finger that moves scrolls.
3773
- The cursor is the standard `move`, which every platform draws itself; the column under the
3774
- dragged card says it would take it, and a ghost of the card follows the pointer. */
3805
+ /* A card is dragged to another column from anywhere on it but its menu, its title included: with
3806
+ a mouse at once, under a finger once it has rested a moment, so that a finger that moves
3807
+ scrolls. The column under the dragged card says it would take it, and the card itself, whole,
3808
+ follows the pointer while a faded one keeps its place. */
3775
3809
  .sc-card {
3776
3810
  position: relative;
3777
3811
  }
3778
3812
 
3779
3813
  .sc-card-movable {
3780
- cursor: move;
3814
+ cursor: grab;
3781
3815
  }
3782
3816
 
3783
3817
  /* A finger resting on a card is neither selecting its words nor asking for the page's menu. */
@@ -3789,13 +3823,13 @@ table {
3789
3823
  }
3790
3824
 
3791
3825
  .sc-card-dragging {
3792
- opacity: 0.5;
3826
+ opacity: 0.4;
3793
3827
  }
3794
3828
 
3795
3829
  .sc-board-dragging,
3796
3830
  .sc-board-dragging .sc-card,
3797
3831
  .sc-board-dragging .sc-card * {
3798
- cursor: move;
3832
+ cursor: grabbing;
3799
3833
  user-select: none;
3800
3834
  }
3801
3835
 
@@ -3828,6 +3862,9 @@ table {
3828
3862
  height: 1rem;
3829
3863
  }
3830
3864
 
3865
+ /* Shown on the card under the pointer or with the focus, while the menu is open, and on the card
3866
+ in hand, which is the card as it was hovered: its menu's dots, and the room they take. */
3867
+ .sc-drag-ghost .sc-card-menu-button,
3831
3868
  .sc-card:hover .sc-card-menu-button,
3832
3869
  .sc-card:focus-within .sc-card-menu-button,
3833
3870
  .sc-card-menu-button[aria-expanded="true"] {
@@ -3860,12 +3897,18 @@ table {
3860
3897
  z-index: 60;
3861
3898
  display: flex;
3862
3899
  flex-direction: column;
3863
- min-width: 10rem;
3900
+ min-width: 8rem;
3864
3901
  padding: var(--sc-space-1);
3865
3902
  border: var(--sc-border);
3866
- border-radius: var(--sc-radius-lg);
3903
+ border-radius: var(--sc-radius-md);
3867
3904
  background: var(--sc-color-bg-inset);
3868
3905
  box-shadow: var(--sc-shadow-stage);
3906
+ font-size: var(--sc-text-sm);
3907
+ }
3908
+
3909
+ .sc-card-menu .sc-menu-link {
3910
+ padding: calc(var(--sc-space-1) + 1px) var(--sc-space-2);
3911
+ border-radius: var(--sc-radius-sm);
3869
3912
  }
3870
3913
 
3871
3914
  .sc-column-over {
@@ -3878,17 +3921,16 @@ table {
3878
3921
  top: 0;
3879
3922
  left: 0;
3880
3923
  z-index: 50;
3881
- max-width: 18rem;
3882
- padding: var(--sc-space-2) var(--sc-space-3);
3883
- border: 1px solid var(--sc-color-accent);
3884
- border-radius: var(--sc-radius-lg);
3885
- background: var(--sc-color-bg-raised);
3886
- box-shadow: var(--sc-glow);
3887
- color: var(--sc-color-text);
3888
- font-size: var(--sc-text-sm);
3889
3924
  pointer-events: none;
3890
3925
  }
3891
3926
 
3927
+ .sc-drag-ghost .sc-card {
3928
+ border-color: var(--sc-color-accent);
3929
+ background: var(--sc-color-bg-raised);
3930
+ box-shadow: var(--sc-shadow-stage);
3931
+ transform: scale(1.02);
3932
+ }
3933
+
3892
3934
  /* A goal's page reads as the board does: the facts in a row at its head, then a switch between
3893
3935
  the views of its tasks, then the view, which takes the rest of the height as the board does. */
3894
3936
  .sc-goal-page {