@harborppm/cli 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Needs Node 22 or later.
12
12
 
13
13
  ## The full-screen app
14
14
 
15
- `harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads, and keeps a small one at the foot of the sidebar when the terminal is tall enough (21 rows or more; the larger one from 110 columns). Then:
15
+ `harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
16
16
 
17
17
  1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
18
18
  2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
@@ -27,6 +27,8 @@ A task changes in place too: title, status, owner, due date (`2026-10-14`, `toda
27
27
 
28
28
  Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
29
29
 
30
+ It has colours for dark terminals and for light ones, and picks by asking the terminal for its background (or reading `COLORFGBG`); a terminal that doesn't say gets the dark ones. `harbor theme light` or `harbor theme dark` settles it, `harbor theme auto` goes back to asking, and `HARBOR_THEME` overrides it for one run.
31
+
30
32
  `harbor help` shows the mark too, in a terminal with colour; piped, or with `NO_COLOR` set, it's plain text. `harbor --version` says which version you have.
31
33
 
32
34
  ## Staying up to date
package/dist/app.mjs CHANGED
@@ -192,137 +192,6 @@ const createImpl = (createState) => {
192
192
  };
193
193
  const create$1 = ((createState) => createState ? createImpl(createState) : createImpl);
194
194
 
195
- //#endregion
196
- //#region cli/lib/logo.mjs
197
- /**
198
- * Harbor's mark for the terminal: the favicon (public/favicon.svg) as
199
- * pixels, a teal tile with the white H and three coral boats. Each
200
- * terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
201
- * grid comes out square. `harbor help` prints it; the full-screen app draws
202
- * it on its splash and its keys overlay (src/tui/logo.tsx).
203
- *
204
- * T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
205
- * the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
206
- */
207
- /** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
208
- const LOGO = [
209
- "...TTTTTTTTTTTTTTTTTT...",
210
- "..TTTTTTTTTTTTTTTTTTTT..",
211
- ".TTTTTTTTTTTTTTTTTTTTTT.",
212
- "TTTTTWWWTTTTTTTTWWWTTTTT",
213
- "TTTTTWWWTTTTTTTTWWWTTTTT",
214
- "TTPPTWWWTTTTTTTTWWWTTTTT",
215
- "TTCCTWWWTTTTTTTTWWWTTTTT",
216
- "TTCCTWWWTTTTTTTTWWWTTTTT",
217
- "TTCCTWWWTTTTTTTTWWWTTTTT",
218
- "TTCCTWWWTTTTTTTTWWWTTTTT",
219
- "TTTTTWWWTTTTTTTTWWWTTTTT",
220
- "TTTTTWWWWWWWWWWWWWWTTTTT",
221
- "TTTTTWWWWWWWWWWWWWWTTTTT",
222
- "TTTTTWWWTTTTTTTTWWWTTTTT",
223
- "TTTTTWWWTTTTTTTTWWWTPPTT",
224
- "TTTTTWWWTTTPPTTTWWWTCCTT",
225
- "TTTTTWWWTTTCCTTTWWWTCCTT",
226
- "TTTTTWWWTTTCCTTTWWWTCCTT",
227
- "TTTTTWWWTTTCCTTTWWWTCCTT",
228
- "TTTTTWWWTTTCCTTTWWWTTTTT",
229
- "TTTTTWWWTTTTTTTTWWWTTTTT",
230
- ".TTTTTTTTTTTTTTTTTTTTTT.",
231
- "..TTTTTTTTTTTTTTTTTTTT..",
232
- "...TTTTTTTTTTTTTTTTTT..."
233
- ];
234
- /** 16 by 16, for narrow places. */
235
- const LOGO_SMALL = [
236
- "..TTTTTTTTTTTT..",
237
- ".TTTTTTTTTTTTTT.",
238
- "TTTTWWTTTTWWTTTT",
239
- "TTTTWWTTTTWWTTTT",
240
- "TPPTWWTTTTWWTTTT",
241
- "TCCTWWTTTTWWTTTT",
242
- "TCCTWWTTTTWWTTTT",
243
- "TTTTWWWWWWWWTTTT",
244
- "TTTTWWWWWWWWTTTT",
245
- "TTTTWWTTTTWWTPPT",
246
- "TTTTWWTPPTWWTCCT",
247
- "TTTTWWTCCTWWTCCT",
248
- "TTTTWWTCCTWWTTTT",
249
- "TTTTWWTTTTWWTTTT",
250
- ".TTTTTTTTTTTTTT.",
251
- "..TTTTTTTTTTTT.."
252
- ];
253
- /** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
254
- const LOGO_MINI = [
255
- "..TTTTTTTT..",
256
- ".TTTTTTTTTT.",
257
- "TTTWTTTTWTTT",
258
- "TCTWTTTTWTTT",
259
- "TCTWTTTTWTTT",
260
- "TCTWWWWWWTTT",
261
- "TTTWWWWWWTTT",
262
- "TTTWTTTTWTCT",
263
- "TTTWTCCTWTCT",
264
- "TTTWTCCTWTCT",
265
- ".TTTTTTTTTT.",
266
- "..TTTTTTTT.."
267
- ];
268
- /** The brand's fills, as in favicon.svg and src/styles.css. */
269
- const LOGO_COLOURS = {
270
- T: "#0f6e64",
271
- W: "#ffffff",
272
- C: "#c44a2c"
273
- };
274
- /**
275
- * @typedef {{ text: string, fg?: string, bg?: string }} Run
276
- * One stretch of a terminal row in the same colours.
277
- */
278
- /**
279
- * The logo as terminal rows, each a list of runs: the top pixel is the
280
- * half block's colour, the bottom one its background.
281
- * @param {string[]} pixels
282
- * @returns {Run[][]}
283
- */
284
- function logoRows(pixels = LOGO) {
285
- /** @type {Run[][]} */
286
- const rows = [];
287
- for (let y = 0; y < pixels.length; y += 2) {
288
- /** @type {Run[]} */
289
- const runs = [];
290
- const top = pixels[y] ?? "";
291
- const bottom = pixels[y + 1] ?? "";
292
- for (let x = 0; x < top.length; x++) {
293
- const t = LOGO_COLOURS[top[x]];
294
- const b = LOGO_COLOURS[bottom[x]];
295
- const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
296
- const left = bowRow ? bowRow[x - 1] !== "P" : false;
297
- /** @type {Run} */
298
- const cell = top[x] === "P" ? {
299
- text: left ? "▟" : "▙",
300
- fg: LOGO_COLOURS.C,
301
- bg: LOGO_COLOURS.T
302
- } : bottom[x] === "P" ? {
303
- text: left ? "▗" : "▖",
304
- fg: LOGO_COLOURS.C,
305
- bg: t
306
- } : !t && !b ? { text: " " } : t === b || !b ? {
307
- text: t === b ? "█" : "▀",
308
- fg: t
309
- } : !t ? {
310
- text: "▄",
311
- fg: b
312
- } : {
313
- text: "▀",
314
- fg: t,
315
- bg: b
316
- };
317
- const last = runs.at(-1);
318
- if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
319
- else runs.push(cell);
320
- }
321
- rows.push(runs);
322
- }
323
- return rows;
324
- }
325
-
326
195
  //#endregion
327
196
  //#region src/tui/context.tsx
328
197
  /**
@@ -341,71 +210,6 @@ function useHarbor(selector) {
341
210
  return useStore(useDeps().store, selector);
342
211
  }
343
212
 
344
- //#endregion
345
- //#region src/tui/theme.ts
346
- const theme = {
347
- accent: "#3dbcab",
348
- coral: "#e0714f",
349
- muted: "gray",
350
- selected: "#3dbcab"
351
- };
352
- const HEALTH_COLOR = {
353
- "on-track": "green",
354
- "at-risk": "yellow",
355
- "off-track": "red",
356
- paused: "gray",
357
- done: "cyan"
358
- };
359
- const PRIORITY_COLOR = {
360
- urgent: "red",
361
- high: "#e0714f",
362
- medium: void 0,
363
- low: "gray"
364
- };
365
-
366
- //#endregion
367
- //#region src/tui/logo.tsx
368
- /** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
369
- function Logo({ pixels = LOGO }) {
370
- return /* @__PURE__ */ jsx(Box, {
371
- flexDirection: "column",
372
- flexShrink: 0,
373
- children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
374
- color: r.fg,
375
- backgroundColor: r.bg,
376
- children: r.text
377
- }, i)) }, y))
378
- });
379
- }
380
- /** What shows while the harbor loads: the mark, centred, and which harbor. */
381
- function Splash({ opening }) {
382
- const { columns, rows } = useWindowSize();
383
- const mark = rows >= 18 && columns >= 30;
384
- const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
385
- return /* @__PURE__ */ jsxs(Box, {
386
- width: columns,
387
- height: rows,
388
- flexDirection: "column",
389
- alignItems: "center",
390
- paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
391
- children: [
392
- mark ? /* @__PURE__ */ jsx(Box, {
393
- marginBottom: 1,
394
- children: /* @__PURE__ */ jsx(Logo, {})
395
- }) : null,
396
- /* @__PURE__ */ jsx(Text, {
397
- bold: true,
398
- color: theme.accent,
399
- children: "Harbor"
400
- }),
401
- /* @__PURE__ */ jsx(Text, {
402
- color: theme.muted,
403
- children: opening ? `Opening ${opening}…` : "Opening your harbor…"
404
- })
405
- ]
406
- });
407
- }
408
-
409
213
  //#endregion
410
214
  //#region node_modules/date-fns/constants.js
411
215
  /**
@@ -2271,6 +2075,66 @@ function forecastLine(f) {
2271
2075
  return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
2272
2076
  }
2273
2077
 
2078
+ //#endregion
2079
+ //#region src/tui/theme.ts
2080
+ /**
2081
+ * Dark: Harbor's teal and coral lifted so they read on black, and status in
2082
+ * softened hues rather than the terminal's own loud green and yellow (a
2083
+ * 16-colour terminal still gets its nearest plain colour). Light: the brand's own darker teal
2084
+ * and coral, and status in hues dark enough for text on white (4.5:1 or more).
2085
+ */
2086
+ const PALETTES = {
2087
+ dark: {
2088
+ accent: "#3dbcab",
2089
+ coral: "#e0714f",
2090
+ muted: "gray",
2091
+ selected: "#3dbcab",
2092
+ border: "gray",
2093
+ good: "#86c294",
2094
+ warn: "#e3b55c",
2095
+ bad: "#f2665d",
2096
+ done: "#7fb6cc"
2097
+ },
2098
+ light: {
2099
+ accent: "#0f6e64",
2100
+ coral: "#b8432c",
2101
+ muted: "#5f5e58",
2102
+ selected: "#0f6e64",
2103
+ border: "#a8a69e",
2104
+ good: "#1f7a3a",
2105
+ warn: "#8a5a0a",
2106
+ bad: "#b8432c",
2107
+ done: "#146c6b"
2108
+ }
2109
+ };
2110
+ const theme = {
2111
+ name: "dark",
2112
+ ...PALETTES.dark
2113
+ };
2114
+ const HEALTH_COLOR = {};
2115
+ const PRIORITY_COLOR = {};
2116
+ /** Switch every colour to one set. Call before the app draws. */
2117
+ function applyTheme(name) {
2118
+ Object.assign(theme, {
2119
+ name,
2120
+ ...PALETTES[name]
2121
+ });
2122
+ Object.assign(HEALTH_COLOR, {
2123
+ "on-track": theme.good,
2124
+ "at-risk": theme.warn,
2125
+ "off-track": theme.bad,
2126
+ paused: theme.muted,
2127
+ done: theme.done
2128
+ });
2129
+ Object.assign(PRIORITY_COLOR, {
2130
+ urgent: theme.bad,
2131
+ high: theme.coral,
2132
+ medium: void 0,
2133
+ low: theme.muted
2134
+ });
2135
+ }
2136
+ applyTheme("dark");
2137
+
2274
2138
  //#endregion
2275
2139
  //#region src/tui/ui.tsx
2276
2140
  /**
@@ -2292,7 +2156,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
2292
2156
  return /* @__PURE__ */ jsxs(Box, {
2293
2157
  flexDirection: "column",
2294
2158
  borderStyle: "round",
2295
- borderColor: theme.accent,
2159
+ borderColor: theme.border,
2296
2160
  paddingX: 1,
2297
2161
  children: [
2298
2162
  /* @__PURE__ */ jsx(Text, {
@@ -2326,7 +2190,7 @@ function Picker({ title, choices, initial = 0, rows = 10, onPick, onCancel }) {
2326
2190
  return /* @__PURE__ */ jsxs(Box, {
2327
2191
  flexDirection: "column",
2328
2192
  borderStyle: "round",
2329
- borderColor: theme.accent,
2193
+ borderColor: theme.border,
2330
2194
  paddingX: 1,
2331
2195
  children: [
2332
2196
  /* @__PURE__ */ jsx(Text, {
@@ -2395,7 +2259,6 @@ function Hints({ keys }) {
2395
2259
  children: keys.map(([k, label], i) => /* @__PURE__ */ jsxs(Text, { children: [
2396
2260
  i > 0 ? " " : "",
2397
2261
  /* @__PURE__ */ jsx(Text, {
2398
- color: theme.accent,
2399
2262
  bold: true,
2400
2263
  children: k
2401
2264
  }),
@@ -2423,7 +2286,6 @@ function Heading({ children, right }) {
2423
2286
  justifyContent: "space-between",
2424
2287
  children: [/* @__PURE__ */ jsx(Text, {
2425
2288
  bold: true,
2426
- color: theme.accent,
2427
2289
  children
2428
2290
  }), right ? /* @__PURE__ */ jsx(Text, {
2429
2291
  color: theme.muted,
@@ -2518,7 +2380,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
2518
2380
  width,
2519
2381
  height,
2520
2382
  borderStyle: "round",
2521
- borderColor: theme.accent,
2383
+ borderColor: theme.border,
2522
2384
  paddingX: 1,
2523
2385
  children: [
2524
2386
  /* @__PURE__ */ jsx(Text, {
@@ -2861,7 +2723,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2861
2723
  " ",
2862
2724
  /* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
2863
2725
  task.dueDate ? /* @__PURE__ */ jsxs(Text, {
2864
- color: late ? "red" : theme.muted,
2726
+ color: late ? theme.bad : theme.muted,
2865
2727
  children: [
2866
2728
  " ",
2867
2729
  late ? "late · " : "",
@@ -2869,7 +2731,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2869
2731
  ]
2870
2732
  }) : null,
2871
2733
  blocked ? /* @__PURE__ */ jsx(Text, {
2872
- color: "yellow",
2734
+ color: theme.warn,
2873
2735
  children: " waits"
2874
2736
  }) : null,
2875
2737
  who ? /* @__PURE__ */ jsxs(Text, {
@@ -3023,7 +2885,7 @@ function HistoryView({ active, width, height, go }) {
3023
2885
  children: "Loading…"
3024
2886
  }) : null,
3025
2887
  state === "failed" ? /* @__PURE__ */ jsx(Text, {
3026
- color: "red",
2888
+ color: theme.bad,
3027
2889
  children: "Couldn’t load History. It tries again after the next save."
3028
2890
  }) : null,
3029
2891
  state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -3601,17 +3463,19 @@ function PeopleView({ active, width, height, go }) {
3601
3463
  const { today } = useDeps();
3602
3464
  const rows = useMemo(() => {
3603
3465
  const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
3466
+ const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
3604
3467
  return people.map((p) => {
3605
3468
  const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
3606
3469
  const load = weeklyLoad(tasks, p.id, now, 4);
3607
3470
  const cap = p.weeklyCapacity ?? 5;
3608
- const weeks = load.weeks.map((w) => w.due > cap ? `${w.due}!` : String(w.due)).join(" ");
3471
+ const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
3472
+ const late = load.overdue ? `${load.overdue} late` : "";
3609
3473
  return {
3610
3474
  id: p.id,
3611
3475
  kind: "person",
3612
3476
  depth: 0,
3613
- title: p.name,
3614
- detail: `${p.role} · ${open.length} open${load.overdue ? ` · ${load.overdue} late` : ""} · next 4 weeks ${weeks} of ${cap}`
3477
+ title: p.name.padEnd(nameWidth),
3478
+ detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
3615
3479
  };
3616
3480
  });
3617
3481
  }, [
@@ -3631,6 +3495,165 @@ function PeopleView({ active, width, height, go }) {
3631
3495
  });
3632
3496
  }
3633
3497
 
3498
+ //#endregion
3499
+ //#region cli/lib/logo.mjs
3500
+ /**
3501
+ * Harbor's mark for the terminal: the favicon (public/favicon.svg) as
3502
+ * pixels, a teal tile with the white H and three coral boats. Each
3503
+ * terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
3504
+ * grid comes out square. `harbor help` prints it; the full-screen app draws
3505
+ * it on its splash and its keys overlay (src/tui/logo.tsx).
3506
+ *
3507
+ * T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
3508
+ * the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
3509
+ */
3510
+ /** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
3511
+ const LOGO = [
3512
+ "...TTTTTTTTTTTTTTTTTT...",
3513
+ "..TTTTTTTTTTTTTTTTTTTT..",
3514
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
3515
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3516
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3517
+ "TTPPTWWWTTTTTTTTWWWTTTTT",
3518
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3519
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3520
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3521
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3522
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3523
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
3524
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
3525
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3526
+ "TTTTTWWWTTTTTTTTWWWTPPTT",
3527
+ "TTTTTWWWTTTPPTTTWWWTCCTT",
3528
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3529
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3530
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3531
+ "TTTTTWWWTTTCCTTTWWWTTTTT",
3532
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3533
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
3534
+ "..TTTTTTTTTTTTTTTTTTTT..",
3535
+ "...TTTTTTTTTTTTTTTTTT..."
3536
+ ];
3537
+ /** 16 by 16, for the keys overlay. */
3538
+ const LOGO_SMALL = [
3539
+ "..TTTTTTTTTTTT..",
3540
+ ".TTTTTTTTTTTTTT.",
3541
+ "TTTTWWTTTTWWTTTT",
3542
+ "TTTTWWTTTTWWTTTT",
3543
+ "TPPTWWTTTTWWTTTT",
3544
+ "TCCTWWTTTTWWTTTT",
3545
+ "TCCTWWTTTTWWTTTT",
3546
+ "TTTTWWWWWWWWTTTT",
3547
+ "TTTTWWWWWWWWTTTT",
3548
+ "TTTTWWTTTTWWTPPT",
3549
+ "TTTTWWTPPTWWTCCT",
3550
+ "TTTTWWTCCTWWTCCT",
3551
+ "TTTTWWTCCTWWTTTT",
3552
+ "TTTTWWTTTTWWTTTT",
3553
+ ".TTTTTTTTTTTTTT.",
3554
+ "..TTTTTTTTTTTT.."
3555
+ ];
3556
+ /** The brand's fills, as in favicon.svg and src/styles.css. */
3557
+ const LOGO_COLOURS = {
3558
+ T: "#0f6e64",
3559
+ W: "#ffffff",
3560
+ C: "#c44a2c"
3561
+ };
3562
+ /**
3563
+ * @typedef {{ text: string, fg?: string, bg?: string }} Run
3564
+ * One stretch of a terminal row in the same colours.
3565
+ */
3566
+ /**
3567
+ * The logo as terminal rows, each a list of runs: the top pixel is the
3568
+ * half block's colour, the bottom one its background.
3569
+ * @param {string[]} pixels
3570
+ * @returns {Run[][]}
3571
+ */
3572
+ function logoRows(pixels = LOGO) {
3573
+ /** @type {Run[][]} */
3574
+ const rows = [];
3575
+ for (let y = 0; y < pixels.length; y += 2) {
3576
+ /** @type {Run[]} */
3577
+ const runs = [];
3578
+ const top = pixels[y] ?? "";
3579
+ const bottom = pixels[y + 1] ?? "";
3580
+ for (let x = 0; x < top.length; x++) {
3581
+ const t = LOGO_COLOURS[top[x]];
3582
+ const b = LOGO_COLOURS[bottom[x]];
3583
+ const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
3584
+ const left = bowRow ? bowRow[x - 1] !== "P" : false;
3585
+ /** @type {Run} */
3586
+ const cell = top[x] === "P" ? {
3587
+ text: left ? "▟" : "▙",
3588
+ fg: LOGO_COLOURS.C,
3589
+ bg: LOGO_COLOURS.T
3590
+ } : bottom[x] === "P" ? {
3591
+ text: left ? "▗" : "▖",
3592
+ fg: LOGO_COLOURS.C,
3593
+ bg: t
3594
+ } : !t && !b ? { text: " " } : t === b || !b ? {
3595
+ text: t === b ? "█" : "▀",
3596
+ fg: t
3597
+ } : !t ? {
3598
+ text: "▄",
3599
+ fg: b
3600
+ } : {
3601
+ text: "▀",
3602
+ fg: t,
3603
+ bg: b
3604
+ };
3605
+ const last = runs.at(-1);
3606
+ if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
3607
+ else runs.push(cell);
3608
+ }
3609
+ rows.push(runs);
3610
+ }
3611
+ return rows;
3612
+ }
3613
+
3614
+ //#endregion
3615
+ //#region src/tui/logo.tsx
3616
+ /** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
3617
+ function Logo({ pixels = LOGO }) {
3618
+ return /* @__PURE__ */ jsx(Box, {
3619
+ flexDirection: "column",
3620
+ flexShrink: 0,
3621
+ children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
3622
+ color: r.fg,
3623
+ backgroundColor: r.bg,
3624
+ children: r.text
3625
+ }, i)) }, y))
3626
+ });
3627
+ }
3628
+ /** What shows while the harbor loads: the mark, centred, and which harbor. */
3629
+ function Splash({ opening }) {
3630
+ const { columns, rows } = useWindowSize();
3631
+ const mark = rows >= 18 && columns >= 30;
3632
+ const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
3633
+ return /* @__PURE__ */ jsxs(Box, {
3634
+ width: columns,
3635
+ height: rows,
3636
+ flexDirection: "column",
3637
+ alignItems: "center",
3638
+ paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
3639
+ children: [
3640
+ mark ? /* @__PURE__ */ jsx(Box, {
3641
+ marginBottom: 1,
3642
+ children: /* @__PURE__ */ jsx(Logo, {})
3643
+ }) : null,
3644
+ /* @__PURE__ */ jsx(Text, {
3645
+ bold: true,
3646
+ color: theme.accent,
3647
+ children: "Harbor"
3648
+ }),
3649
+ /* @__PURE__ */ jsx(Text, {
3650
+ color: theme.muted,
3651
+ children: opening ? `Opening ${opening}…` : "Opening your harbor…"
3652
+ })
3653
+ ]
3654
+ });
3655
+ }
3656
+
3634
3657
  //#endregion
3635
3658
  //#region src/tui/moves.ts
3636
3659
  /**
@@ -3696,7 +3719,7 @@ function SearchBox({ width, onOpen, onClose }) {
3696
3719
  flexDirection: "column",
3697
3720
  width,
3698
3721
  borderStyle: "round",
3699
- borderColor: theme.accent,
3722
+ borderColor: theme.border,
3700
3723
  paddingX: 1,
3701
3724
  children: [
3702
3725
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -3833,6 +3856,12 @@ const HELP = [
3833
3856
  ["r", "retry saving now"],
3834
3857
  ["q", "quit (saves first)"]
3835
3858
  ];
3859
+ /**
3860
+ * The mark sits beside the keys only when both fit: the longest line, a gap,
3861
+ * the 16-pixel mark, and the box's border and padding. Narrower, the lines
3862
+ * would wrap and split the mark.
3863
+ */
3864
+ const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3836
3865
  function Help({ width, onClose }) {
3837
3866
  useInput((input, key) => {
3838
3867
  if (key.escape || key.return || input === "?" || input === "q") onClose();
@@ -3840,7 +3869,7 @@ function Help({ width, onClose }) {
3840
3869
  return /* @__PURE__ */ jsxs(Box, {
3841
3870
  width,
3842
3871
  borderStyle: "round",
3843
- borderColor: theme.accent,
3872
+ borderColor: theme.border,
3844
3873
  paddingX: 1,
3845
3874
  children: [/* @__PURE__ */ jsxs(Box, {
3846
3875
  flexDirection: "column",
@@ -3860,7 +3889,7 @@ function Help({ width, onClose }) {
3860
3889
  children: "Everything saves as you go, and shows on the website for everyone."
3861
3890
  })
3862
3891
  ]
3863
- }), width >= 90 ? /* @__PURE__ */ jsxs(Box, {
3892
+ }), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
3864
3893
  flexDirection: "column",
3865
3894
  alignItems: "center",
3866
3895
  marginLeft: 2,
@@ -3942,7 +3971,7 @@ function Palette({ width, base, open, onClose }) {
3942
3971
  flexDirection: "column",
3943
3972
  width,
3944
3973
  borderStyle: "round",
3945
- borderColor: theme.accent,
3974
+ borderColor: theme.border,
3946
3975
  paddingX: 1,
3947
3976
  children: [
3948
3977
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -4332,7 +4361,7 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4332
4361
  {
4333
4362
  label: "Due",
4334
4363
  value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
4335
- color: late ? "red" : void 0,
4364
+ color: late ? theme.bad : void 0,
4336
4365
  edit: {
4337
4366
  kind: "date",
4338
4367
  initial: milestone.dueDate,
@@ -4795,7 +4824,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4795
4824
  }) : m.name,
4796
4825
  " ",
4797
4826
  /* @__PURE__ */ jsxs(Text, {
4798
- color: late ? "red" : theme.muted,
4827
+ color: late ? theme.bad : theme.muted,
4799
4828
  children: [late ? "late · " : "", shortDay(m.dueDate, today())]
4800
4829
  }),
4801
4830
  m.approverId ? /* @__PURE__ */ jsx(Text, {
@@ -5064,7 +5093,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5064
5093
  flexDirection: "column",
5065
5094
  width: w,
5066
5095
  borderStyle: "round",
5067
- borderColor: on ? theme.accent : "gray",
5096
+ borderColor: on ? theme.accent : theme.border,
5068
5097
  paddingX: 1,
5069
5098
  children: [
5070
5099
  /* @__PURE__ */ jsxs(Text, {
@@ -5075,7 +5104,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5075
5104
  c.label.toUpperCase(),
5076
5105
  " ",
5077
5106
  /* @__PURE__ */ jsxs(Text, {
5078
- color: over ? "red" : theme.muted,
5107
+ color: over ? theme.bad : theme.muted,
5079
5108
  children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5080
5109
  })
5081
5110
  ]
@@ -5140,7 +5169,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5140
5169
  ]
5141
5170
  });
5142
5171
  }
5143
- /** One card on the board: its key, owner and due date, then the title on up to two lines. */
5172
+ /** One card on the board: its key, due date and owner, then the title on up to two lines. */
5144
5173
  function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5145
5174
  return /* @__PURE__ */ jsxs(Box, {
5146
5175
  flexDirection: "column",
@@ -5156,17 +5185,17 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5156
5185
  }),
5157
5186
  " ",
5158
5187
  /* @__PURE__ */ jsx(PriorityMark, { priority }),
5159
- owner ? /* @__PURE__ */ jsxs(Text, {
5160
- color: selected ? void 0 : theme.muted,
5161
- children: [" ", owner]
5162
- }) : null,
5163
5188
  due ? /* @__PURE__ */ jsxs(Text, {
5164
- color: late ? "red" : selected ? void 0 : theme.muted,
5189
+ color: late ? theme.bad : selected ? void 0 : theme.muted,
5165
5190
  children: [" ", due]
5166
5191
  }) : null,
5167
5192
  waits ? /* @__PURE__ */ jsx(Text, {
5168
- color: "yellow",
5193
+ color: theme.warn,
5169
5194
  children: " waits"
5195
+ }) : null,
5196
+ owner ? /* @__PURE__ */ jsxs(Text, {
5197
+ color: selected ? void 0 : theme.muted,
5198
+ children: [" ", owner]
5170
5199
  }) : null
5171
5200
  ]
5172
5201
  }), /* @__PURE__ */ jsx(Box, {
@@ -5183,7 +5212,7 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5183
5212
  /** A project's money: budget, spent, what's left and the forecast, then the ledger. */
5184
5213
  function BudgetPane({ project, spendEntries, at, active, height, today }) {
5185
5214
  const b = budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date(`${today}T12:00:00`));
5186
- const tone = b.forecast?.tone === "over" ? "red" : b.forecast?.tone === "tight" ? "yellow" : "green";
5215
+ const tone = b.forecast?.tone === "over" ? theme.bad : b.forecast?.tone === "tight" ? theme.warn : theme.good;
5187
5216
  return /* @__PURE__ */ jsxs(Box, {
5188
5217
  flexDirection: "column",
5189
5218
  height,
@@ -5211,7 +5240,7 @@ function BudgetPane({ project, spendEntries, at, active, height, today }) {
5211
5240
  " ",
5212
5241
  /* @__PURE__ */ jsxs(Text, {
5213
5242
  bold: true,
5214
- color: b.left < 0 ? "red" : void 0,
5243
+ color: b.left < 0 ? theme.bad : void 0,
5215
5244
  children: [money(Math.abs(b.left)), b.left < 0 ? " over" : ""]
5216
5245
  })
5217
5246
  ] }),
@@ -5438,7 +5467,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5438
5467
  status: STATUS_LABEL[task.status],
5439
5468
  assignee: owner?.name ?? "Nobody yet",
5440
5469
  due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
5441
- priority: task.priority,
5470
+ priority: PRIORITY_LABEL[task.priority],
5442
5471
  project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
5443
5472
  notes: task.description.replace(/\s+/g, " ").trim() || "None"
5444
5473
  };
@@ -5499,7 +5528,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5499
5528
  } else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
5500
5529
  title: "Priority",
5501
5530
  choices: PRIORITIES.map((p) => ({
5502
- label: p,
5531
+ label: PRIORITY_LABEL[p],
5503
5532
  value: p
5504
5533
  })),
5505
5534
  initial: PRIORITIES.indexOf(task.priority),
@@ -5551,7 +5580,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5551
5580
  width,
5552
5581
  height,
5553
5582
  borderStyle: "round",
5554
- borderColor: theme.accent,
5583
+ borderColor: theme.border,
5555
5584
  paddingX: 1,
5556
5585
  children: [
5557
5586
  /* @__PURE__ */ jsxs(Text, {
@@ -5568,7 +5597,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5568
5597
  children: task.title
5569
5598
  }),
5570
5599
  viewOnly ? /* @__PURE__ */ jsx(Text, {
5571
- color: "yellow",
5600
+ color: theme.warn,
5572
5601
  children: " view only"
5573
5602
  }) : null
5574
5603
  ]
@@ -5588,7 +5617,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5588
5617
  ]
5589
5618
  }, f.field)),
5590
5619
  waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
5591
- color: "yellow",
5620
+ color: theme.warn,
5592
5621
  wrap: "truncate-end",
5593
5622
  children: [
5594
5623
  " ",
@@ -5602,7 +5631,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5602
5631
  children: [
5603
5632
  " ",
5604
5633
  "Checklist",
5605
- task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : ""
5634
+ task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : " · none yet, a adds a step"
5606
5635
  ]
5607
5636
  }),
5608
5637
  task.checklist.map((item, i) => {
@@ -5772,7 +5801,7 @@ function TodayView({ active, width, height, go }) {
5772
5801
  children: [
5773
5802
  " ",
5774
5803
  /* @__PURE__ */ jsx(Text, {
5775
- color: r.kind === "risk" ? "yellow" : r.kind === "issue" ? "red" : theme.muted,
5804
+ color: r.kind === "risk" ? theme.warn : r.kind === "issue" ? theme.bad : theme.muted,
5776
5805
  children: r.kind
5777
5806
  }),
5778
5807
  " ",
@@ -5826,14 +5855,15 @@ const NAV = [
5826
5855
  label: "History"
5827
5856
  }
5828
5857
  ];
5829
- /** The sidebar's lines: the screens, a gap, then Harbors and Anything. */
5830
- const NAV_LINES = NAV.length + 3;
5858
+ /** The sidebar's width: its longest line ("8 History", ": Anything") with room either side. */
5859
+ const NAV_WIDTH = 15;
5860
+ /** What the corner says about saving, and in which of the theme's colours. */
5831
5861
  const SAVE_LABEL = {
5832
- saved: ["● saved", "green"],
5833
- pending: ["● saving soon", "yellow"],
5834
- saving: ["● saving…", "yellow"],
5835
- retrying: ["● not saved, retrying", "red"],
5836
- offline: ["● offline, will save", "red"]
5862
+ saved: ["● saved", "good"],
5863
+ pending: ["● saving soon", "warn"],
5864
+ saving: ["● saving…", "warn"],
5865
+ retrying: ["● not saved, retrying", "bad"],
5866
+ offline: ["● offline, will save", "bad"]
5837
5867
  };
5838
5868
  function App({ ui }) {
5839
5869
  const { store, sync, account, harbors, today } = useDeps();
@@ -6021,11 +6051,10 @@ function App({ ui }) {
6021
6051
  if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
6022
6052
  }, { isActive: !covered && panel !== null });
6023
6053
  const bodyHeight = Math.max(8, rows - 1 - 2);
6024
- const mark = columns >= 110 && bodyHeight >= NAV_LINES + 1 + LOGO_SMALL.length / 2 ? LOGO_SMALL : bodyHeight >= NAV_LINES + 1 + LOGO_MINI.length / 2 ? LOGO_MINI : null;
6025
- const navWidth = (mark?.[0]?.length ?? LOGO_MINI.length) + 3;
6026
- const mainWidth = Math.max(30, columns - navWidth - 1);
6054
+ const mainWidth = Math.max(30, columns - NAV_WIDTH - 1);
6027
6055
  const mainActive = focus === "main" && !covered && panel === null;
6028
- const [saveText, saveColor] = SAVE_LABEL[save];
6056
+ const [saveText, saveTone] = SAVE_LABEL[save];
6057
+ const saveColor = theme[saveTone];
6029
6058
  const viewProps = {
6030
6059
  active: mainActive,
6031
6060
  width: mainWidth,
@@ -6256,7 +6285,7 @@ function App({ ui }) {
6256
6285
  children: harborName ?? "…"
6257
6286
  }),
6258
6287
  viewOnly ? /* @__PURE__ */ jsx(Text, {
6259
- color: "yellow",
6288
+ color: theme.warn,
6260
6289
  children: " · view only"
6261
6290
  }) : null
6262
6291
  ]
@@ -6272,13 +6301,13 @@ function App({ ui }) {
6272
6301
  height: bodyHeight,
6273
6302
  children: [/* @__PURE__ */ jsxs(Box, {
6274
6303
  flexDirection: "column",
6275
- width: navWidth,
6304
+ width: NAV_WIDTH,
6276
6305
  paddingX: 1,
6277
6306
  borderStyle: "single",
6278
6307
  borderTop: false,
6279
6308
  borderBottom: false,
6280
6309
  borderLeft: false,
6281
- borderColor: focus === "nav" ? theme.accent : "gray",
6310
+ borderColor: focus === "nav" ? theme.accent : theme.border,
6282
6311
  children: [
6283
6312
  NAV.map((x, i) => {
6284
6313
  const on = x.screen === screen && stack.length === 0;
@@ -6300,8 +6329,7 @@ function App({ ui }) {
6300
6329
  /* @__PURE__ */ jsx(Text, {
6301
6330
  color: theme.muted,
6302
6331
  children: ": Anything"
6303
- }),
6304
- mark ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, { flexGrow: 1 }), /* @__PURE__ */ jsx(Logo, { pixels: mark })] }) : null
6332
+ })
6305
6333
  ]
6306
6334
  }), /* @__PURE__ */ jsxs(Box, {
6307
6335
  flexDirection: "column",
@@ -7036,12 +7064,24 @@ function sessionSlice({ set, get, write }) {
7036
7064
  keepInterval: k.interval,
7037
7065
  stripeReady: k.stripe
7038
7066
  }),
7039
- ensureAccountPerson: ({ accountId, name }) => {
7067
+ ensureAccountPerson: ({ accountId, name, personId }) => {
7040
7068
  const hit = get().people.find((p) => p.accountId === accountId);
7041
7069
  if (hit) {
7042
7070
  if (get().currentUserId !== hit.id) set({ currentUserId: hit.id });
7043
7071
  return hit.id;
7044
7072
  }
7073
+ const named = personId ? get().people.find((p) => p.id === personId && !p.accountId) : null;
7074
+ if (named) {
7075
+ set({
7076
+ people: get().people.map((p) => p.id === named.id ? {
7077
+ ...p,
7078
+ accountId
7079
+ } : p),
7080
+ currentUserId: named.id
7081
+ });
7082
+ get().log("came aboard", "/people");
7083
+ return named.id;
7084
+ }
7045
7085
  const used = new Set(get().people.map((p) => p.swatch));
7046
7086
  const swatch = SWATCHES.find((s) => !used.has(s)) ?? "pine";
7047
7087
  const id = uid("p");
@@ -7402,7 +7442,8 @@ function hierarchySlice({ get, write }) {
7402
7442
  write({
7403
7443
  programs: add(state.programs, rows.programs),
7404
7444
  projects: add(state.projects, rows.projects),
7405
- tasks: add(state.tasks, rows.tasks)
7445
+ tasks: add(state.tasks, rows.tasks),
7446
+ people: add(state.people, rows.people)
7406
7447
  });
7407
7448
  const n = rows.tasks?.length ?? 0;
7408
7449
  get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
@@ -7412,12 +7453,18 @@ function hierarchySlice({ get, write }) {
7412
7453
  const gone = {
7413
7454
  programs: new Set(ids.programs ?? []),
7414
7455
  projects: new Set(ids.projects ?? []),
7415
- tasks: new Set(ids.tasks ?? [])
7456
+ tasks: new Set(ids.tasks ?? []),
7457
+ people: new Set(ids.people ?? [])
7416
7458
  };
7459
+ const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
7460
+ if (gone.people.size) {
7461
+ for (const t of tasks) if (t.assigneeId) gone.people.delete(t.assigneeId);
7462
+ }
7417
7463
  write({
7418
7464
  programs: state.programs.filter((r) => !gone.programs.has(r.id)),
7419
7465
  projects: state.projects.filter((r) => !gone.projects.has(r.id)),
7420
- tasks: state.tasks.filter((r) => !gone.tasks.has(r.id)),
7466
+ tasks,
7467
+ people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
7421
7468
  selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
7422
7469
  });
7423
7470
  },
@@ -8899,6 +8946,53 @@ function createSync(opts) {
8899
8946
  };
8900
8947
  }
8901
8948
 
8949
+ //#endregion
8950
+ //#region src/tui/theme-detect.ts
8951
+ const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
8952
+ /** COLORFGBG is "fg;bg" (sometimes "fg;x;bg"): background 7 or 15 is a light terminal. */
8953
+ function themeFromColorFgBg(value) {
8954
+ const bg = value?.split(";").at(-1);
8955
+ if (!bg || !/^\d+$/.test(bg)) return null;
8956
+ return bg === "7" || bg === "15" ? "light" : "dark";
8957
+ }
8958
+ /** A terminal's answer to OSC 11, like "\x1b]11;rgb:ffff/ffff/ffff\x07": light when its luminance is over half. */
8959
+ function themeFromReply(reply) {
8960
+ const m = /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(reply);
8961
+ if (!m) return null;
8962
+ const [r, g, b] = m.slice(1, 4).map((hex) => parseInt(hex, 16) / (16 ** hex.length - 1));
8963
+ return .2126 * r + .7152 * g + .0722 * b > .5 ? "light" : "dark";
8964
+ }
8965
+ /** Ask the terminal for its background colour; null when it doesn't say in time. */
8966
+ function askBackground(stdin, stdout, ms = 300) {
8967
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") return Promise.resolve(null);
8968
+ return new Promise((resolve) => {
8969
+ let reply = "";
8970
+ const done = (theme) => {
8971
+ clearTimeout(timer);
8972
+ stdin.off("data", onData);
8973
+ stdin.setRawMode(false);
8974
+ stdin.pause();
8975
+ resolve(theme);
8976
+ };
8977
+ const onData = (chunk) => {
8978
+ reply += chunk.toString();
8979
+ if (/\x07|\x1b\\/.test(reply)) done(themeFromReply(reply));
8980
+ };
8981
+ const timer = setTimeout(() => done(null), ms);
8982
+ stdin.setRawMode(true);
8983
+ stdin.resume();
8984
+ stdin.on("data", onData);
8985
+ stdout.write("\x1B]11;?\x1B\\");
8986
+ });
8987
+ }
8988
+ /** The set to draw with, from the saved setting, the environment, then the terminal itself. */
8989
+ async function pickTheme(setting, env = process.env, ask = () => askBackground(process.stdin, process.stdout)) {
8990
+ const fromEnv = env.HARBOR_THEME?.toLowerCase();
8991
+ const chosen = isThemeSetting(fromEnv) ? fromEnv : setting;
8992
+ if (chosen === "light" || chosen === "dark") return chosen;
8993
+ return themeFromColorFgBg(env.COLORFGBG) ?? await ask() ?? "dark";
8994
+ }
8995
+
8902
8996
  //#endregion
8903
8997
  //#region src/tui/ui-state.ts
8904
8998
  /** What the shell shows beside the harbor: the save state and the latest notice. */
@@ -8924,6 +9018,7 @@ const SPLASH_MS = 1200;
8924
9018
  * most, the store and the sync loop, drawn with Ink.
8925
9019
  */
8926
9020
  async function startApp(deps, options = {}) {
9021
+ applyTheme(deps.theme ?? "dark");
8927
9022
  const transport = plainTransport(deps.transport);
8928
9023
  const account = plainDeep(deps.account);
8929
9024
  const harbors = plainDeep(deps.harbors);
@@ -8970,6 +9065,7 @@ async function startApp(deps, options = {}) {
8970
9065
  }
8971
9066
  /** `harbor`: sign-in already done, take over the terminal until q. */
8972
9067
  async function runApp(opts) {
9068
+ const theme = await pickTheme(opts.theme);
8973
9069
  const transport = httpTransport(opts.source, opts.fetcher);
8974
9070
  const { account, harbors } = await transport.harbors();
8975
9071
  if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
@@ -8981,7 +9077,8 @@ async function runApp(opts) {
8981
9077
  account,
8982
9078
  harbors,
8983
9079
  workspaceId: pick?.id,
8984
- splashMs: SPLASH_MS
9080
+ splashMs: SPLASH_MS,
9081
+ theme
8985
9082
  }, { alternateScreen: true });
8986
9083
  await instance.waitUntilExit();
8987
9084
  }
package/lib/cli.mjs CHANGED
@@ -8,7 +8,14 @@ import { readFileSync } from "node:fs";
8
8
  import { parseArgs } from "./args.mjs";
9
9
  import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
10
10
  import { login } from "./auth.mjs";
11
- import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
11
+ import {
12
+ DEFAULT_SERVER,
13
+ forgetCredentials,
14
+ readCredentials,
15
+ readSettings,
16
+ writeCredentials,
17
+ writeSettings,
18
+ } from "./config.mjs";
12
19
  import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
13
20
  import { colourDepth, logoLines } from "./logo.mjs";
14
21
  import { HarborError, createClient } from "./mcp.mjs";
@@ -20,6 +27,7 @@ export const HELP = `harbor: Harbor from the terminal
20
27
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
21
28
  harbor logout forget the sign-in on this machine
22
29
  harbor harbors the harbors you belong to
30
+ harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
23
31
  harbor --version which version this is
24
32
 
25
33
  harbor mine what's on you, late first
@@ -55,6 +63,8 @@ function banner(io, env, words) {
55
63
  return `\n${logoLines(depth, words({ bold, dim })).join("\n")}\n`;
56
64
  }
57
65
 
66
+ const THEMES = ["auto", "light", "dark"];
67
+
58
68
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
59
69
  const spec = (flags = [], options = []) => ({
60
70
  flags: [...COMMON.flags, ...flags],
@@ -184,6 +194,7 @@ export async function run(argv, io = {}) {
184
194
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
185
195
  fetcher,
186
196
  harbor: values.harbor,
197
+ theme: (await readSettings(env)).theme,
187
198
  });
188
199
  return 0;
189
200
  }
@@ -216,6 +227,26 @@ export async function run(argv, io = {}) {
216
227
  return 0;
217
228
  }
218
229
 
230
+ if (command === "theme") {
231
+ const { positionals } = parseArgs(rest, { flags: [], options: [] });
232
+ const settings = await readSettings(env);
233
+ const [choice] = positionals;
234
+ if (!choice) {
235
+ out(`The app's colours: ${settings.theme ?? "auto"}.`);
236
+ return 0;
237
+ }
238
+ if (!THEMES.includes(choice)) {
239
+ throw new HarborError(`Pick auto, light or dark (not "${choice}").`);
240
+ }
241
+ await writeSettings({ ...settings, theme: choice }, env);
242
+ out(
243
+ choice === "auto"
244
+ ? "The app will ask the terminal for its background and match it."
245
+ : `The app will use its ${choice} colours.`,
246
+ );
247
+ return 0;
248
+ }
249
+
219
250
  if (command === "logout") {
220
251
  await forgetCredentials(env);
221
252
  out(
package/lib/config.mjs CHANGED
@@ -38,3 +38,19 @@ export async function writeCredentials(creds, env = process.env) {
38
38
  export async function forgetCredentials(env = process.env) {
39
39
  await rm(file(env), { force: true });
40
40
  }
41
+
42
+ const settingsFile = (env) => join(configDir(env), "settings.json");
43
+
44
+ /** Choices that aren't a sign-in (the app's colours), or {} when none are saved. */
45
+ export async function readSettings(env = process.env) {
46
+ try {
47
+ return JSON.parse(await readFile(settingsFile(env), "utf8"));
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ export async function writeSettings(settings, env = process.env) {
54
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
55
+ await writeFile(settingsFile(env), JSON.stringify(settings, null, 2));
56
+ }
package/lib/logo.mjs CHANGED
@@ -37,7 +37,7 @@ export const LOGO = [
37
37
  "...TTTTTTTTTTTTTTTTTT...",
38
38
  ];
39
39
 
40
- /** 16 by 16, for narrow places. */
40
+ /** 16 by 16, for the keys overlay. */
41
41
  export const LOGO_SMALL = [
42
42
  "..TTTTTTTTTTTT..",
43
43
  ".TTTTTTTTTTTTTT.",
@@ -57,22 +57,6 @@ export const LOGO_SMALL = [
57
57
  "..TTTTTTTTTTTT..",
58
58
  ];
59
59
 
60
- /** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
61
- export const LOGO_MINI = [
62
- "..TTTTTTTT..",
63
- ".TTTTTTTTTT.",
64
- "TTTWTTTTWTTT",
65
- "TCTWTTTTWTTT",
66
- "TCTWTTTTWTTT",
67
- "TCTWWWWWWTTT",
68
- "TTTWWWWWWTTT",
69
- "TTTWTTTTWTCT",
70
- "TTTWTCCTWTCT",
71
- "TTTWTCCTWTCT",
72
- ".TTTTTTTTTT.",
73
- "..TTTTTTTT..",
74
- ];
75
-
76
60
  /** The brand's fills, as in favicon.svg and src/styles.css. */
77
61
  export const LOGO_COLOURS = { T: "#0f6e64", W: "#ffffff", C: "#c44a2c" };
78
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "type": "module",
6
6
  "bin": {