@harborppm/cli 0.4.0 → 0.5.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.
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,65 @@ 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, with the plain
2082
+ * colours every terminal draws for status. Light: the brand's own darker teal
2083
+ * and coral, and status in hues dark enough for text on white (4.5:1 or more).
2084
+ */
2085
+ const PALETTES = {
2086
+ dark: {
2087
+ accent: "#3dbcab",
2088
+ coral: "#e0714f",
2089
+ muted: "gray",
2090
+ selected: "#3dbcab",
2091
+ border: "gray",
2092
+ good: "green",
2093
+ warn: "yellow",
2094
+ bad: "red",
2095
+ done: "cyan"
2096
+ },
2097
+ light: {
2098
+ accent: "#0f6e64",
2099
+ coral: "#b8432c",
2100
+ muted: "#5f5e58",
2101
+ selected: "#0f6e64",
2102
+ border: "#a8a69e",
2103
+ good: "#1f7a3a",
2104
+ warn: "#8a5a0a",
2105
+ bad: "#b8432c",
2106
+ done: "#146c6b"
2107
+ }
2108
+ };
2109
+ const theme = {
2110
+ name: "dark",
2111
+ ...PALETTES.dark
2112
+ };
2113
+ const HEALTH_COLOR = {};
2114
+ const PRIORITY_COLOR = {};
2115
+ /** Switch every colour to one set. Call before the app draws. */
2116
+ function applyTheme(name) {
2117
+ Object.assign(theme, {
2118
+ name,
2119
+ ...PALETTES[name]
2120
+ });
2121
+ Object.assign(HEALTH_COLOR, {
2122
+ "on-track": theme.good,
2123
+ "at-risk": theme.warn,
2124
+ "off-track": theme.bad,
2125
+ paused: theme.muted,
2126
+ done: theme.done
2127
+ });
2128
+ Object.assign(PRIORITY_COLOR, {
2129
+ urgent: theme.bad,
2130
+ high: theme.coral,
2131
+ medium: void 0,
2132
+ low: theme.muted
2133
+ });
2134
+ }
2135
+ applyTheme("dark");
2136
+
2274
2137
  //#endregion
2275
2138
  //#region src/tui/ui.tsx
2276
2139
  /**
@@ -2292,7 +2155,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
2292
2155
  return /* @__PURE__ */ jsxs(Box, {
2293
2156
  flexDirection: "column",
2294
2157
  borderStyle: "round",
2295
- borderColor: theme.accent,
2158
+ borderColor: theme.border,
2296
2159
  paddingX: 1,
2297
2160
  children: [
2298
2161
  /* @__PURE__ */ jsx(Text, {
@@ -2326,7 +2189,7 @@ function Picker({ title, choices, initial = 0, rows = 10, onPick, onCancel }) {
2326
2189
  return /* @__PURE__ */ jsxs(Box, {
2327
2190
  flexDirection: "column",
2328
2191
  borderStyle: "round",
2329
- borderColor: theme.accent,
2192
+ borderColor: theme.border,
2330
2193
  paddingX: 1,
2331
2194
  children: [
2332
2195
  /* @__PURE__ */ jsx(Text, {
@@ -2395,7 +2258,6 @@ function Hints({ keys }) {
2395
2258
  children: keys.map(([k, label], i) => /* @__PURE__ */ jsxs(Text, { children: [
2396
2259
  i > 0 ? " " : "",
2397
2260
  /* @__PURE__ */ jsx(Text, {
2398
- color: theme.accent,
2399
2261
  bold: true,
2400
2262
  children: k
2401
2263
  }),
@@ -2423,7 +2285,6 @@ function Heading({ children, right }) {
2423
2285
  justifyContent: "space-between",
2424
2286
  children: [/* @__PURE__ */ jsx(Text, {
2425
2287
  bold: true,
2426
- color: theme.accent,
2427
2288
  children
2428
2289
  }), right ? /* @__PURE__ */ jsx(Text, {
2429
2290
  color: theme.muted,
@@ -2518,7 +2379,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
2518
2379
  width,
2519
2380
  height,
2520
2381
  borderStyle: "round",
2521
- borderColor: theme.accent,
2382
+ borderColor: theme.border,
2522
2383
  paddingX: 1,
2523
2384
  children: [
2524
2385
  /* @__PURE__ */ jsx(Text, {
@@ -2861,7 +2722,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2861
2722
  " ",
2862
2723
  /* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
2863
2724
  task.dueDate ? /* @__PURE__ */ jsxs(Text, {
2864
- color: late ? "red" : theme.muted,
2725
+ color: late ? theme.bad : theme.muted,
2865
2726
  children: [
2866
2727
  " ",
2867
2728
  late ? "late · " : "",
@@ -2869,7 +2730,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2869
2730
  ]
2870
2731
  }) : null,
2871
2732
  blocked ? /* @__PURE__ */ jsx(Text, {
2872
- color: "yellow",
2733
+ color: theme.warn,
2873
2734
  children: " waits"
2874
2735
  }) : null,
2875
2736
  who ? /* @__PURE__ */ jsxs(Text, {
@@ -3023,7 +2884,7 @@ function HistoryView({ active, width, height, go }) {
3023
2884
  children: "Loading…"
3024
2885
  }) : null,
3025
2886
  state === "failed" ? /* @__PURE__ */ jsx(Text, {
3026
- color: "red",
2887
+ color: theme.bad,
3027
2888
  children: "Couldn’t load History. It tries again after the next save."
3028
2889
  }) : null,
3029
2890
  state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -3631,6 +3492,165 @@ function PeopleView({ active, width, height, go }) {
3631
3492
  });
3632
3493
  }
3633
3494
 
3495
+ //#endregion
3496
+ //#region cli/lib/logo.mjs
3497
+ /**
3498
+ * Harbor's mark for the terminal: the favicon (public/favicon.svg) as
3499
+ * pixels, a teal tile with the white H and three coral boats. Each
3500
+ * terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
3501
+ * grid comes out square. `harbor help` prints it; the full-screen app draws
3502
+ * it on its splash and its keys overlay (src/tui/logo.tsx).
3503
+ *
3504
+ * T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
3505
+ * the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
3506
+ */
3507
+ /** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
3508
+ const LOGO = [
3509
+ "...TTTTTTTTTTTTTTTTTT...",
3510
+ "..TTTTTTTTTTTTTTTTTTTT..",
3511
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
3512
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3513
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3514
+ "TTPPTWWWTTTTTTTTWWWTTTTT",
3515
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3516
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3517
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3518
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
3519
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3520
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
3521
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
3522
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3523
+ "TTTTTWWWTTTTTTTTWWWTPPTT",
3524
+ "TTTTTWWWTTTPPTTTWWWTCCTT",
3525
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3526
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3527
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
3528
+ "TTTTTWWWTTTCCTTTWWWTTTTT",
3529
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
3530
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
3531
+ "..TTTTTTTTTTTTTTTTTTTT..",
3532
+ "...TTTTTTTTTTTTTTTTTT..."
3533
+ ];
3534
+ /** 16 by 16, for the keys overlay. */
3535
+ const LOGO_SMALL = [
3536
+ "..TTTTTTTTTTTT..",
3537
+ ".TTTTTTTTTTTTTT.",
3538
+ "TTTTWWTTTTWWTTTT",
3539
+ "TTTTWWTTTTWWTTTT",
3540
+ "TPPTWWTTTTWWTTTT",
3541
+ "TCCTWWTTTTWWTTTT",
3542
+ "TCCTWWTTTTWWTTTT",
3543
+ "TTTTWWWWWWWWTTTT",
3544
+ "TTTTWWWWWWWWTTTT",
3545
+ "TTTTWWTTTTWWTPPT",
3546
+ "TTTTWWTPPTWWTCCT",
3547
+ "TTTTWWTCCTWWTCCT",
3548
+ "TTTTWWTCCTWWTTTT",
3549
+ "TTTTWWTTTTWWTTTT",
3550
+ ".TTTTTTTTTTTTTT.",
3551
+ "..TTTTTTTTTTTT.."
3552
+ ];
3553
+ /** The brand's fills, as in favicon.svg and src/styles.css. */
3554
+ const LOGO_COLOURS = {
3555
+ T: "#0f6e64",
3556
+ W: "#ffffff",
3557
+ C: "#c44a2c"
3558
+ };
3559
+ /**
3560
+ * @typedef {{ text: string, fg?: string, bg?: string }} Run
3561
+ * One stretch of a terminal row in the same colours.
3562
+ */
3563
+ /**
3564
+ * The logo as terminal rows, each a list of runs: the top pixel is the
3565
+ * half block's colour, the bottom one its background.
3566
+ * @param {string[]} pixels
3567
+ * @returns {Run[][]}
3568
+ */
3569
+ function logoRows(pixels = LOGO) {
3570
+ /** @type {Run[][]} */
3571
+ const rows = [];
3572
+ for (let y = 0; y < pixels.length; y += 2) {
3573
+ /** @type {Run[]} */
3574
+ const runs = [];
3575
+ const top = pixels[y] ?? "";
3576
+ const bottom = pixels[y + 1] ?? "";
3577
+ for (let x = 0; x < top.length; x++) {
3578
+ const t = LOGO_COLOURS[top[x]];
3579
+ const b = LOGO_COLOURS[bottom[x]];
3580
+ const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
3581
+ const left = bowRow ? bowRow[x - 1] !== "P" : false;
3582
+ /** @type {Run} */
3583
+ const cell = top[x] === "P" ? {
3584
+ text: left ? "▟" : "▙",
3585
+ fg: LOGO_COLOURS.C,
3586
+ bg: LOGO_COLOURS.T
3587
+ } : bottom[x] === "P" ? {
3588
+ text: left ? "▗" : "▖",
3589
+ fg: LOGO_COLOURS.C,
3590
+ bg: t
3591
+ } : !t && !b ? { text: " " } : t === b || !b ? {
3592
+ text: t === b ? "█" : "▀",
3593
+ fg: t
3594
+ } : !t ? {
3595
+ text: "▄",
3596
+ fg: b
3597
+ } : {
3598
+ text: "▀",
3599
+ fg: t,
3600
+ bg: b
3601
+ };
3602
+ const last = runs.at(-1);
3603
+ if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
3604
+ else runs.push(cell);
3605
+ }
3606
+ rows.push(runs);
3607
+ }
3608
+ return rows;
3609
+ }
3610
+
3611
+ //#endregion
3612
+ //#region src/tui/logo.tsx
3613
+ /** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
3614
+ function Logo({ pixels = LOGO }) {
3615
+ return /* @__PURE__ */ jsx(Box, {
3616
+ flexDirection: "column",
3617
+ flexShrink: 0,
3618
+ children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
3619
+ color: r.fg,
3620
+ backgroundColor: r.bg,
3621
+ children: r.text
3622
+ }, i)) }, y))
3623
+ });
3624
+ }
3625
+ /** What shows while the harbor loads: the mark, centred, and which harbor. */
3626
+ function Splash({ opening }) {
3627
+ const { columns, rows } = useWindowSize();
3628
+ const mark = rows >= 18 && columns >= 30;
3629
+ const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
3630
+ return /* @__PURE__ */ jsxs(Box, {
3631
+ width: columns,
3632
+ height: rows,
3633
+ flexDirection: "column",
3634
+ alignItems: "center",
3635
+ paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
3636
+ children: [
3637
+ mark ? /* @__PURE__ */ jsx(Box, {
3638
+ marginBottom: 1,
3639
+ children: /* @__PURE__ */ jsx(Logo, {})
3640
+ }) : null,
3641
+ /* @__PURE__ */ jsx(Text, {
3642
+ bold: true,
3643
+ color: theme.accent,
3644
+ children: "Harbor"
3645
+ }),
3646
+ /* @__PURE__ */ jsx(Text, {
3647
+ color: theme.muted,
3648
+ children: opening ? `Opening ${opening}…` : "Opening your harbor…"
3649
+ })
3650
+ ]
3651
+ });
3652
+ }
3653
+
3634
3654
  //#endregion
3635
3655
  //#region src/tui/moves.ts
3636
3656
  /**
@@ -3696,7 +3716,7 @@ function SearchBox({ width, onOpen, onClose }) {
3696
3716
  flexDirection: "column",
3697
3717
  width,
3698
3718
  borderStyle: "round",
3699
- borderColor: theme.accent,
3719
+ borderColor: theme.border,
3700
3720
  paddingX: 1,
3701
3721
  children: [
3702
3722
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -3833,6 +3853,12 @@ const HELP = [
3833
3853
  ["r", "retry saving now"],
3834
3854
  ["q", "quit (saves first)"]
3835
3855
  ];
3856
+ /**
3857
+ * The mark sits beside the keys only when both fit: the longest line, a gap,
3858
+ * the 16-pixel mark, and the box's border and padding. Narrower, the lines
3859
+ * would wrap and split the mark.
3860
+ */
3861
+ const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3836
3862
  function Help({ width, onClose }) {
3837
3863
  useInput((input, key) => {
3838
3864
  if (key.escape || key.return || input === "?" || input === "q") onClose();
@@ -3840,7 +3866,7 @@ function Help({ width, onClose }) {
3840
3866
  return /* @__PURE__ */ jsxs(Box, {
3841
3867
  width,
3842
3868
  borderStyle: "round",
3843
- borderColor: theme.accent,
3869
+ borderColor: theme.border,
3844
3870
  paddingX: 1,
3845
3871
  children: [/* @__PURE__ */ jsxs(Box, {
3846
3872
  flexDirection: "column",
@@ -3860,7 +3886,7 @@ function Help({ width, onClose }) {
3860
3886
  children: "Everything saves as you go, and shows on the website for everyone."
3861
3887
  })
3862
3888
  ]
3863
- }), width >= 90 ? /* @__PURE__ */ jsxs(Box, {
3889
+ }), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
3864
3890
  flexDirection: "column",
3865
3891
  alignItems: "center",
3866
3892
  marginLeft: 2,
@@ -3942,7 +3968,7 @@ function Palette({ width, base, open, onClose }) {
3942
3968
  flexDirection: "column",
3943
3969
  width,
3944
3970
  borderStyle: "round",
3945
- borderColor: theme.accent,
3971
+ borderColor: theme.border,
3946
3972
  paddingX: 1,
3947
3973
  children: [
3948
3974
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -4332,7 +4358,7 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4332
4358
  {
4333
4359
  label: "Due",
4334
4360
  value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
4335
- color: late ? "red" : void 0,
4361
+ color: late ? theme.bad : void 0,
4336
4362
  edit: {
4337
4363
  kind: "date",
4338
4364
  initial: milestone.dueDate,
@@ -4795,7 +4821,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4795
4821
  }) : m.name,
4796
4822
  " ",
4797
4823
  /* @__PURE__ */ jsxs(Text, {
4798
- color: late ? "red" : theme.muted,
4824
+ color: late ? theme.bad : theme.muted,
4799
4825
  children: [late ? "late · " : "", shortDay(m.dueDate, today())]
4800
4826
  }),
4801
4827
  m.approverId ? /* @__PURE__ */ jsx(Text, {
@@ -5064,7 +5090,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5064
5090
  flexDirection: "column",
5065
5091
  width: w,
5066
5092
  borderStyle: "round",
5067
- borderColor: on ? theme.accent : "gray",
5093
+ borderColor: on ? theme.accent : theme.border,
5068
5094
  paddingX: 1,
5069
5095
  children: [
5070
5096
  /* @__PURE__ */ jsxs(Text, {
@@ -5075,7 +5101,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5075
5101
  c.label.toUpperCase(),
5076
5102
  " ",
5077
5103
  /* @__PURE__ */ jsxs(Text, {
5078
- color: over ? "red" : theme.muted,
5104
+ color: over ? theme.bad : theme.muted,
5079
5105
  children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5080
5106
  })
5081
5107
  ]
@@ -5161,11 +5187,11 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5161
5187
  children: [" ", owner]
5162
5188
  }) : null,
5163
5189
  due ? /* @__PURE__ */ jsxs(Text, {
5164
- color: late ? "red" : selected ? void 0 : theme.muted,
5190
+ color: late ? theme.bad : selected ? void 0 : theme.muted,
5165
5191
  children: [" ", due]
5166
5192
  }) : null,
5167
5193
  waits ? /* @__PURE__ */ jsx(Text, {
5168
- color: "yellow",
5194
+ color: theme.warn,
5169
5195
  children: " waits"
5170
5196
  }) : null
5171
5197
  ]
@@ -5183,7 +5209,7 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5183
5209
  /** A project's money: budget, spent, what's left and the forecast, then the ledger. */
5184
5210
  function BudgetPane({ project, spendEntries, at, active, height, today }) {
5185
5211
  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";
5212
+ const tone = b.forecast?.tone === "over" ? theme.bad : b.forecast?.tone === "tight" ? theme.warn : theme.good;
5187
5213
  return /* @__PURE__ */ jsxs(Box, {
5188
5214
  flexDirection: "column",
5189
5215
  height,
@@ -5211,7 +5237,7 @@ function BudgetPane({ project, spendEntries, at, active, height, today }) {
5211
5237
  " ",
5212
5238
  /* @__PURE__ */ jsxs(Text, {
5213
5239
  bold: true,
5214
- color: b.left < 0 ? "red" : void 0,
5240
+ color: b.left < 0 ? theme.bad : void 0,
5215
5241
  children: [money(Math.abs(b.left)), b.left < 0 ? " over" : ""]
5216
5242
  })
5217
5243
  ] }),
@@ -5551,7 +5577,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5551
5577
  width,
5552
5578
  height,
5553
5579
  borderStyle: "round",
5554
- borderColor: theme.accent,
5580
+ borderColor: theme.border,
5555
5581
  paddingX: 1,
5556
5582
  children: [
5557
5583
  /* @__PURE__ */ jsxs(Text, {
@@ -5568,7 +5594,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5568
5594
  children: task.title
5569
5595
  }),
5570
5596
  viewOnly ? /* @__PURE__ */ jsx(Text, {
5571
- color: "yellow",
5597
+ color: theme.warn,
5572
5598
  children: " view only"
5573
5599
  }) : null
5574
5600
  ]
@@ -5588,7 +5614,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5588
5614
  ]
5589
5615
  }, f.field)),
5590
5616
  waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
5591
- color: "yellow",
5617
+ color: theme.warn,
5592
5618
  wrap: "truncate-end",
5593
5619
  children: [
5594
5620
  " ",
@@ -5772,7 +5798,7 @@ function TodayView({ active, width, height, go }) {
5772
5798
  children: [
5773
5799
  " ",
5774
5800
  /* @__PURE__ */ jsx(Text, {
5775
- color: r.kind === "risk" ? "yellow" : r.kind === "issue" ? "red" : theme.muted,
5801
+ color: r.kind === "risk" ? theme.warn : r.kind === "issue" ? theme.bad : theme.muted,
5776
5802
  children: r.kind
5777
5803
  }),
5778
5804
  " ",
@@ -5826,14 +5852,15 @@ const NAV = [
5826
5852
  label: "History"
5827
5853
  }
5828
5854
  ];
5829
- /** The sidebar's lines: the screens, a gap, then Harbors and Anything. */
5830
- const NAV_LINES = NAV.length + 3;
5855
+ /** The sidebar's width: its longest line ("8 History", ": Anything") with room either side. */
5856
+ const NAV_WIDTH = 15;
5857
+ /** What the corner says about saving, and in which of the theme's colours. */
5831
5858
  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"]
5859
+ saved: ["● saved", "good"],
5860
+ pending: ["● saving soon", "warn"],
5861
+ saving: ["● saving…", "warn"],
5862
+ retrying: ["● not saved, retrying", "bad"],
5863
+ offline: ["● offline, will save", "bad"]
5837
5864
  };
5838
5865
  function App({ ui }) {
5839
5866
  const { store, sync, account, harbors, today } = useDeps();
@@ -6021,11 +6048,10 @@ function App({ ui }) {
6021
6048
  if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
6022
6049
  }, { isActive: !covered && panel !== null });
6023
6050
  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);
6051
+ const mainWidth = Math.max(30, columns - NAV_WIDTH - 1);
6027
6052
  const mainActive = focus === "main" && !covered && panel === null;
6028
- const [saveText, saveColor] = SAVE_LABEL[save];
6053
+ const [saveText, saveTone] = SAVE_LABEL[save];
6054
+ const saveColor = theme[saveTone];
6029
6055
  const viewProps = {
6030
6056
  active: mainActive,
6031
6057
  width: mainWidth,
@@ -6256,7 +6282,7 @@ function App({ ui }) {
6256
6282
  children: harborName ?? "…"
6257
6283
  }),
6258
6284
  viewOnly ? /* @__PURE__ */ jsx(Text, {
6259
- color: "yellow",
6285
+ color: theme.warn,
6260
6286
  children: " · view only"
6261
6287
  }) : null
6262
6288
  ]
@@ -6272,13 +6298,13 @@ function App({ ui }) {
6272
6298
  height: bodyHeight,
6273
6299
  children: [/* @__PURE__ */ jsxs(Box, {
6274
6300
  flexDirection: "column",
6275
- width: navWidth,
6301
+ width: NAV_WIDTH,
6276
6302
  paddingX: 1,
6277
6303
  borderStyle: "single",
6278
6304
  borderTop: false,
6279
6305
  borderBottom: false,
6280
6306
  borderLeft: false,
6281
- borderColor: focus === "nav" ? theme.accent : "gray",
6307
+ borderColor: focus === "nav" ? theme.accent : theme.border,
6282
6308
  children: [
6283
6309
  NAV.map((x, i) => {
6284
6310
  const on = x.screen === screen && stack.length === 0;
@@ -6300,8 +6326,7 @@ function App({ ui }) {
6300
6326
  /* @__PURE__ */ jsx(Text, {
6301
6327
  color: theme.muted,
6302
6328
  children: ": Anything"
6303
- }),
6304
- mark ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, { flexGrow: 1 }), /* @__PURE__ */ jsx(Logo, { pixels: mark })] }) : null
6329
+ })
6305
6330
  ]
6306
6331
  }), /* @__PURE__ */ jsxs(Box, {
6307
6332
  flexDirection: "column",
@@ -7036,12 +7061,24 @@ function sessionSlice({ set, get, write }) {
7036
7061
  keepInterval: k.interval,
7037
7062
  stripeReady: k.stripe
7038
7063
  }),
7039
- ensureAccountPerson: ({ accountId, name }) => {
7064
+ ensureAccountPerson: ({ accountId, name, personId }) => {
7040
7065
  const hit = get().people.find((p) => p.accountId === accountId);
7041
7066
  if (hit) {
7042
7067
  if (get().currentUserId !== hit.id) set({ currentUserId: hit.id });
7043
7068
  return hit.id;
7044
7069
  }
7070
+ const named = personId ? get().people.find((p) => p.id === personId && !p.accountId) : null;
7071
+ if (named) {
7072
+ set({
7073
+ people: get().people.map((p) => p.id === named.id ? {
7074
+ ...p,
7075
+ accountId
7076
+ } : p),
7077
+ currentUserId: named.id
7078
+ });
7079
+ get().log("came aboard", "/people");
7080
+ return named.id;
7081
+ }
7045
7082
  const used = new Set(get().people.map((p) => p.swatch));
7046
7083
  const swatch = SWATCHES.find((s) => !used.has(s)) ?? "pine";
7047
7084
  const id = uid("p");
@@ -7402,7 +7439,8 @@ function hierarchySlice({ get, write }) {
7402
7439
  write({
7403
7440
  programs: add(state.programs, rows.programs),
7404
7441
  projects: add(state.projects, rows.projects),
7405
- tasks: add(state.tasks, rows.tasks)
7442
+ tasks: add(state.tasks, rows.tasks),
7443
+ people: add(state.people, rows.people)
7406
7444
  });
7407
7445
  const n = rows.tasks?.length ?? 0;
7408
7446
  get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
@@ -7412,12 +7450,18 @@ function hierarchySlice({ get, write }) {
7412
7450
  const gone = {
7413
7451
  programs: new Set(ids.programs ?? []),
7414
7452
  projects: new Set(ids.projects ?? []),
7415
- tasks: new Set(ids.tasks ?? [])
7453
+ tasks: new Set(ids.tasks ?? []),
7454
+ people: new Set(ids.people ?? [])
7416
7455
  };
7456
+ const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
7457
+ if (gone.people.size) {
7458
+ for (const t of tasks) if (t.assigneeId) gone.people.delete(t.assigneeId);
7459
+ }
7417
7460
  write({
7418
7461
  programs: state.programs.filter((r) => !gone.programs.has(r.id)),
7419
7462
  projects: state.projects.filter((r) => !gone.projects.has(r.id)),
7420
- tasks: state.tasks.filter((r) => !gone.tasks.has(r.id)),
7463
+ tasks,
7464
+ people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
7421
7465
  selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
7422
7466
  });
7423
7467
  },
@@ -8899,6 +8943,53 @@ function createSync(opts) {
8899
8943
  };
8900
8944
  }
8901
8945
 
8946
+ //#endregion
8947
+ //#region src/tui/theme-detect.ts
8948
+ const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
8949
+ /** COLORFGBG is "fg;bg" (sometimes "fg;x;bg"): background 7 or 15 is a light terminal. */
8950
+ function themeFromColorFgBg(value) {
8951
+ const bg = value?.split(";").at(-1);
8952
+ if (!bg || !/^\d+$/.test(bg)) return null;
8953
+ return bg === "7" || bg === "15" ? "light" : "dark";
8954
+ }
8955
+ /** A terminal's answer to OSC 11, like "\x1b]11;rgb:ffff/ffff/ffff\x07": light when its luminance is over half. */
8956
+ function themeFromReply(reply) {
8957
+ const m = /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(reply);
8958
+ if (!m) return null;
8959
+ const [r, g, b] = m.slice(1, 4).map((hex) => parseInt(hex, 16) / (16 ** hex.length - 1));
8960
+ return .2126 * r + .7152 * g + .0722 * b > .5 ? "light" : "dark";
8961
+ }
8962
+ /** Ask the terminal for its background colour; null when it doesn't say in time. */
8963
+ function askBackground(stdin, stdout, ms = 300) {
8964
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") return Promise.resolve(null);
8965
+ return new Promise((resolve) => {
8966
+ let reply = "";
8967
+ const done = (theme) => {
8968
+ clearTimeout(timer);
8969
+ stdin.off("data", onData);
8970
+ stdin.setRawMode(false);
8971
+ stdin.pause();
8972
+ resolve(theme);
8973
+ };
8974
+ const onData = (chunk) => {
8975
+ reply += chunk.toString();
8976
+ if (/\x07|\x1b\\/.test(reply)) done(themeFromReply(reply));
8977
+ };
8978
+ const timer = setTimeout(() => done(null), ms);
8979
+ stdin.setRawMode(true);
8980
+ stdin.resume();
8981
+ stdin.on("data", onData);
8982
+ stdout.write("\x1B]11;?\x1B\\");
8983
+ });
8984
+ }
8985
+ /** The set to draw with, from the saved setting, the environment, then the terminal itself. */
8986
+ async function pickTheme(setting, env = process.env, ask = () => askBackground(process.stdin, process.stdout)) {
8987
+ const fromEnv = env.HARBOR_THEME?.toLowerCase();
8988
+ const chosen = isThemeSetting(fromEnv) ? fromEnv : setting;
8989
+ if (chosen === "light" || chosen === "dark") return chosen;
8990
+ return themeFromColorFgBg(env.COLORFGBG) ?? await ask() ?? "dark";
8991
+ }
8992
+
8902
8993
  //#endregion
8903
8994
  //#region src/tui/ui-state.ts
8904
8995
  /** What the shell shows beside the harbor: the save state and the latest notice. */
@@ -8924,6 +9015,7 @@ const SPLASH_MS = 1200;
8924
9015
  * most, the store and the sync loop, drawn with Ink.
8925
9016
  */
8926
9017
  async function startApp(deps, options = {}) {
9018
+ applyTheme(deps.theme ?? "dark");
8927
9019
  const transport = plainTransport(deps.transport);
8928
9020
  const account = plainDeep(deps.account);
8929
9021
  const harbors = plainDeep(deps.harbors);
@@ -8970,6 +9062,7 @@ async function startApp(deps, options = {}) {
8970
9062
  }
8971
9063
  /** `harbor`: sign-in already done, take over the terminal until q. */
8972
9064
  async function runApp(opts) {
9065
+ const theme = await pickTheme(opts.theme);
8973
9066
  const transport = httpTransport(opts.source, opts.fetcher);
8974
9067
  const { account, harbors } = await transport.harbors();
8975
9068
  if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
@@ -8981,7 +9074,8 @@ async function runApp(opts) {
8981
9074
  account,
8982
9075
  harbors,
8983
9076
  workspaceId: pick?.id,
8984
- splashMs: SPLASH_MS
9077
+ splashMs: SPLASH_MS,
9078
+ theme
8985
9079
  }, { alternateScreen: true });
8986
9080
  await instance.waitUntilExit();
8987
9081
  }
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.0",
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": {