sideby 0.11.0 → 0.12.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
@@ -29,7 +29,7 @@ sideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota
29
29
  sideby ui # every account's quota at http://127.0.0.1:17420
30
30
  ```
31
31
 
32
- `sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.
32
+ `sideby update` installs a newer sideby when there is one; sideby says so after a command and in the panel. `sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.
33
33
 
34
34
  ## Set up with your AI agent
35
35
 
@@ -110,7 +110,7 @@ macOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 2
110
110
  ## Safe by design
111
111
 
112
112
  - It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.
113
- - It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.
113
+ - It never reads, prints or sends a credential value. Nothing about your accounts leaves your machine: the only request sideby makes on its own is a daily check of the npm registry for its newest version (off with `NO_UPDATE_NOTIFIER=1`; see [Updates](docs/guide/configuration.md#updates)).
114
114
  - `doctor --fix` never replaces a real file and never changes where a link points.
115
115
 
116
116
  Details and the terms-of-service question are in the [FAQ](docs/guide/faq.md).
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261016T040419-834a8d"}
1
+ {"id":"20261016T081651-7c664d"}
@@ -61,6 +61,13 @@ export declare function cmdHandoff(a: ParsedArgs, io: Io): Promise<number>;
61
61
  export declare function cmdPlugins(a: ParsedArgs, io: Io): Promise<number>;
62
62
  export declare function cmdShellInit(a: ParsedArgs, io: Io): Promise<number>;
63
63
  export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
64
+ /**
65
+ * `sideby update [--yes] [--json]` (spec §3.23): asks the registry now, shows what an Update runs, and runs it with
66
+ * `--yes` (exit 10 before). An install sideby cannot update gets the command to run and exit 1.
67
+ */
68
+ export declare function cmdUpdate(a: ParsedArgs, io: Io): Promise<number>;
69
+ /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
70
+ export declare function cmdUpdateCheck(_a: ParsedArgs, _io: Io): Promise<number>;
64
71
  export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
65
72
  /**
66
73
  * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
@@ -17,8 +17,22 @@ import { suggestAlias, suggestName } from '../panel/page-logic.js';
17
17
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
18
18
  import { UsageError } from './args.js';
19
19
  import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
20
+ /** Runs `fn` while a record under the state directory says a Host runs (also for `login`), so an Auto Update waits (spec §3.23). */
21
+ async function whileRunning(rt, fn) {
22
+ const { markRunning } = await import('../core/update.js');
23
+ const done = await markRunning(rt.paths);
24
+ try {
25
+ return await fn();
26
+ }
27
+ finally {
28
+ await done().catch(() => { });
29
+ }
30
+ }
20
31
  /** Runs the Account with Auto Handoff when it applies (spec §3.17), else the Host as usual. */
21
32
  async function runAccount(rt, ref, args, io) {
33
+ return whileRunning(rt, () => runAccountNow(rt, ref, args, io));
34
+ }
35
+ async function runAccountNow(rt, ref, args, io) {
22
36
  const handed = await runWithHandoff(rt, ref, args, {
23
37
  err: (l) => io.err(c.dim(l)),
24
38
  stdin: io.stdin ?? process.stdin,
@@ -208,7 +222,7 @@ export async function cmdRun(a, io, command) {
208
222
  const prepared = await rt.prepareLaunch(ref, [...extra, ...a.rest], command);
209
223
  if (prepared.notice)
210
224
  io.err(c.cyan(prepared.notice));
211
- return runHost(prepared);
225
+ return whileRunning(rt, () => runHost(prepared));
212
226
  }
213
227
  /**
214
228
  * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
@@ -224,7 +238,7 @@ export async function cmdResume(a, io) {
224
238
  io.err(c.dim(`sideby: session ${route.sessionId} was never saved; starting a new session in ${route.account.ref}`));
225
239
  else if (route.account)
226
240
  io.err(c.dim(`sideby: session ${route.sessionId} is in ${route.account.ref}; starting it there`));
227
- return runHost(route.launch);
241
+ return whileRunning(rt, () => runHost(route.launch));
228
242
  }
229
243
  /** Quotes a Host argument for a command line the user may paste; plain words stay as they are. */
230
244
  function shellQuote(arg) {
@@ -1003,6 +1017,84 @@ export async function cmdUi(a, io) {
1003
1017
  });
1004
1018
  return 0;
1005
1019
  }
1020
+ /**
1021
+ * `sideby update [--yes] [--json]` (spec §3.23): asks the registry now, shows what an Update runs, and runs it with
1022
+ * `--yes` (exit 10 before). An install sideby cannot update gets the command to run and exit 1.
1023
+ */
1024
+ export async function cmdUpdate(a, io) {
1025
+ if (a.positionals.length)
1026
+ throw new UsageError('usage: sideby update [--yes] [--json]');
1027
+ const u = await import('../core/update.js');
1028
+ const { SKILL_INSTALL } = await import('../core/skill-check.js');
1029
+ const { backgroundPanelPort } = await import('../panel/background.js');
1030
+ const paths = resolvePaths(process.env);
1031
+ const asJson = a.flags.has('json');
1032
+ const plan = await u.planUpdate(paths, process.env);
1033
+ const shown = u.shownPlan(plan);
1034
+ if (!plan.available) {
1035
+ if (asJson)
1036
+ json(io, { ...shown, updated: false });
1037
+ else
1038
+ io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1039
+ return 0;
1040
+ }
1041
+ if (!plan.canRun) {
1042
+ if (asJson)
1043
+ json(io, { ...shown, updated: false });
1044
+ else {
1045
+ io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1046
+ io.err(c.yellow(`sideby cannot update itself: ${plan.method.reason}. Run it yourself:\n ${plan.command}`));
1047
+ }
1048
+ return 1;
1049
+ }
1050
+ if (!a.flags.has('yes')) {
1051
+ if (asJson)
1052
+ json(io, { ...shown, updated: false });
1053
+ else {
1054
+ io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1055
+ io.out(`\nUpdating runs:\n ${plan.command}`);
1056
+ io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1057
+ }
1058
+ return 10;
1059
+ }
1060
+ if (!asJson)
1061
+ io.out(c.dim(`$ ${plan.command}`));
1062
+ const r = await u.applyUpdate(paths, plan, process.env, {
1063
+ ...(asJson ? {} : { onOutput: (text) => process.stderr.write(text) }),
1064
+ panelPort: () => backgroundPanelPort(paths),
1065
+ });
1066
+ if (asJson) {
1067
+ json(io, {
1068
+ ...shown,
1069
+ updated: r.ok,
1070
+ message: r.message,
1071
+ restartedPanel: r.restartedPanel,
1072
+ skill: SKILL_INSTALL,
1073
+ ...(r.ok ? {} : { output: r.output }),
1074
+ });
1075
+ return r.ok ? 0 : 1;
1076
+ }
1077
+ if (!r.ok) {
1078
+ io.err(c.red(`✗ ${r.message}`));
1079
+ return 1;
1080
+ }
1081
+ io.out(`${c.green('✓')} ${r.message}.`);
1082
+ if (r.restartedPanel)
1083
+ io.out(' The background panel is restarting with the new version; open pages reload by themselves.');
1084
+ io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
1085
+ io.out(` If you use the sideby skill, update it too: ${SKILL_INSTALL}`);
1086
+ return 0;
1087
+ }
1088
+ /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
1089
+ export async function cmdUpdateCheck(_a, _io) {
1090
+ const { backgroundCheck } = await import('../core/update.js');
1091
+ const { loadConfig } = await import('../core/config.js');
1092
+ const { backgroundPanelPort } = await import('../panel/background.js');
1093
+ const paths = resolvePaths(process.env);
1094
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
1095
+ await backgroundCheck(paths, process.env, config, () => backgroundPanelPort(paths));
1096
+ return 0;
1097
+ }
1006
1098
  export async function cmdApp(a, io) {
1007
1099
  const sub = a.positionals[0];
1008
1100
  const usage = 'usage: sideby app install [--url <url>] | sideby app uninstall';
@@ -491,6 +491,31 @@ export const OUTPUT_SCHEMAS = {
491
491
  description: 'False when only the plan was shown; run again with --yes (exit 10).',
492
492
  }),
493
493
  }, { title: 'sideby desktop grok-bot:<name> --remove --json' }),
494
+ update: Type.Object({
495
+ schemaVersion: Version,
496
+ current: Type.String({ description: 'The running sideby version.' }),
497
+ latest: Type.String({ description: 'The `latest` version on the npm registry.' }),
498
+ available: Type.Boolean({ description: '`latest` is newer than `current`.' }),
499
+ method: Type.Object({
500
+ kind: Type.Union(['npm', 'pnpm', 'bun', 'volta', 'npx', 'project', 'source'].map((k) => Type.Literal(k)), { description: 'How this copy was installed.' }),
501
+ canUpdate: Type.Boolean({ description: 'sideby can run the Update itself.' }),
502
+ reason: Type.Optional(Type.String({ description: 'Why it cannot.' })),
503
+ }),
504
+ command: Type.String({
505
+ description: 'What an Update runs, or what to run by hand when `canRun` is false.',
506
+ }),
507
+ canRun: Type.Boolean(),
508
+ releaseNotes: Type.String({ description: 'The release notes of `latest`.' }),
509
+ updated: Type.Boolean({
510
+ description: 'False when only the plan was shown (exit 10 with --yes still to add) or nothing to do.',
511
+ }),
512
+ message: Type.Optional(Type.String()),
513
+ restartedPanel: Type.Optional(Type.Boolean({
514
+ description: 'A background Panel was running and starts again with the new version.',
515
+ })),
516
+ skill: Type.Optional(Type.String({ description: 'The command that updates the sideby skill; sideby never runs it.' })),
517
+ output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
518
+ }, { title: 'sideby update --json' }),
494
519
  error: Type.Object({
495
520
  schemaVersion: Version,
496
521
  error: Type.String({ description: 'What went wrong and what to do next.' }),
@@ -58,10 +58,11 @@ Usage:
58
58
  sideby shell-init [zsh|bash] --write
59
59
  rewrite the config's shellInitFile with them
60
60
  sideby plugins list loaded plugins and load errors
61
+ sideby update [--yes] update sideby to the newest version (shows the command first)
61
62
 
62
63
  Options:
63
64
  --json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
64
- families, plugins, app, desktop)
65
+ families, plugins, app, desktop, update)
65
66
  -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
66
67
  -v, --version show the version
67
68
 
@@ -73,7 +74,10 @@ For AI agents:
73
74
  Setup steps and the full contract: https://github.com/Adonis0123/sideby/blob/main/llms.txt
74
75
  Run everything else yourself, but hand the user these as one line each: \`sideby login <account>\`
75
76
  (browser sign-in), filling in proxy.env, and \`sideby run\` or \`sideby next\` (interactive sessions).
76
- Exit 10 means a change was shown: ask the user, then run it again with --yes.`;
77
+ Exit 10 means a change was shown: ask the user, then run it again with --yes.
78
+
79
+ Updates: once a day sideby asks the npm registry for its newest version and says so after a command.
80
+ Turn it off with NO_UPDATE_NOTIFIER=1 or config "update": { "check": false }; "auto": true installs by itself.`;
77
81
  /** `sideby <command> --help` for the commands agents use to set up and repair accounts (spec §3.16). */
78
82
  const COMMAND_HELP = {
79
83
  new: `sideby new <family> <name> [--api] [--alias <short>] [--json]
@@ -203,7 +207,54 @@ const COMMANDS = {
203
207
  'shell-init': { flags: ['write'] },
204
208
  plugins: { flags: ['json'] },
205
209
  families: { flags: ['json'] },
210
+ update: { flags: ['json', 'yes'] },
211
+ 'update-check': { flags: [] },
206
212
  };
213
+ /** Commands that never print an Update Notice: their output is read by a shell or an agent, or they are internal. */
214
+ function noticeAllowed(cmd, parsed) {
215
+ if (cmd === 'shell-init' || cmd === 'update' || cmd === 'update-check')
216
+ return false;
217
+ if (cmd === 'ui' && parsed.flags.has('serve-detached'))
218
+ return false;
219
+ if (cmd === 'handoff' && parsed.positionals[0] === 'ready')
220
+ return false;
221
+ return true;
222
+ }
223
+ /**
224
+ * After a command (spec §3.23): print the Update Notice and the result of an Auto Update, each at most once, and
225
+ * start the next Update Check detached when the last one is a day old. Only on a terminal; never fails the command.
226
+ */
227
+ async function afterCommand() {
228
+ if (!process.stderr.isTTY)
229
+ return;
230
+ try {
231
+ const u = await import('../core/update.js');
232
+ const { resolvePaths } = await import('../core/paths.js');
233
+ const { loadConfig } = await import('../core/config.js');
234
+ const { packageVersion } = await import('../core/version.js');
235
+ const paths = resolvePaths(process.env);
236
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
237
+ const cache = await u.readUpdateCache(paths);
238
+ const plan = u.afterCommandPlan(cache, packageVersion(), config, process.env, new Date());
239
+ const dim = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[2m${t}\x1b[22m`;
240
+ for (const l of plan.lines)
241
+ console.error(dim(l));
242
+ if (plan.record)
243
+ await u.updateUpdateCache(paths, plan.record);
244
+ if (plan.startCheck) {
245
+ const { spawn } = await import('node:child_process');
246
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
247
+ detached: true,
248
+ stdio: 'ignore',
249
+ });
250
+ child.on('error', () => { });
251
+ child.unref();
252
+ }
253
+ }
254
+ catch {
255
+ // an Update Notice is never worth failing a command
256
+ }
257
+ }
207
258
  export async function main(argv) {
208
259
  const [first, ...rest] = argv;
209
260
  // Claude Code runs this on every status-line refresh. It must still launch the original command when Node is
@@ -274,47 +325,10 @@ export async function main(argv) {
274
325
  return 0;
275
326
  }
276
327
  const cmds = await import('./commands.js');
277
- switch (cmd) {
278
- case 'list':
279
- return await cmds.cmdList(parsed, io);
280
- case 'run':
281
- return await cmds.cmdRun(parsed, io, 'run');
282
- case 'login':
283
- return await cmds.cmdRun(parsed, io, 'login');
284
- case 'resume':
285
- return await cmds.cmdResume(parsed, io);
286
- case 'next':
287
- return await cmds.cmdNext(parsed, io);
288
- case 'new':
289
- return await cmds.cmdNew(parsed, io);
290
- case 'alias':
291
- return await cmds.cmdAlias(parsed, io);
292
- case 'doctor':
293
- return await cmds.cmdDoctor(parsed, io);
294
- case 'quota':
295
- return await cmds.cmdQuota(parsed, io);
296
- case 'tune':
297
- return await cmds.cmdTune(parsed, io);
298
- case 'model':
299
- return await cmds.cmdModel(parsed, io);
300
- case 'remote-control':
301
- return await cmds.cmdRemoteControl(parsed, io);
302
- case 'handoff':
303
- return await cmds.cmdHandoff(parsed, io);
304
- case 'ui':
305
- return await cmds.cmdUi(parsed, io);
306
- case 'app':
307
- return await cmds.cmdApp(parsed, io);
308
- case 'desktop':
309
- return await cmds.cmdDesktop(parsed, io);
310
- case 'shell-init':
311
- return await cmds.cmdShellInit(parsed, io);
312
- case 'plugins':
313
- return await cmds.cmdPlugins(parsed, io);
314
- case 'families':
315
- return await cmds.cmdFamilies(parsed, io);
316
- }
317
- return 2;
328
+ const code = await dispatch(cmd, parsed, io, cmds);
329
+ if (!wantsJson && noticeAllowed(cmd, parsed))
330
+ await afterCommand();
331
+ return code;
318
332
  }
319
333
  catch (err) {
320
334
  const usage = err instanceof UsageError;
@@ -331,6 +345,53 @@ export async function main(argv) {
331
345
  return usage ? 2 : 1;
332
346
  }
333
347
  }
348
+ async function dispatch(cmd, parsed, io, cmds) {
349
+ switch (cmd) {
350
+ case 'list':
351
+ return await cmds.cmdList(parsed, io);
352
+ case 'run':
353
+ return await cmds.cmdRun(parsed, io, 'run');
354
+ case 'login':
355
+ return await cmds.cmdRun(parsed, io, 'login');
356
+ case 'resume':
357
+ return await cmds.cmdResume(parsed, io);
358
+ case 'next':
359
+ return await cmds.cmdNext(parsed, io);
360
+ case 'new':
361
+ return await cmds.cmdNew(parsed, io);
362
+ case 'alias':
363
+ return await cmds.cmdAlias(parsed, io);
364
+ case 'doctor':
365
+ return await cmds.cmdDoctor(parsed, io);
366
+ case 'quota':
367
+ return await cmds.cmdQuota(parsed, io);
368
+ case 'tune':
369
+ return await cmds.cmdTune(parsed, io);
370
+ case 'model':
371
+ return await cmds.cmdModel(parsed, io);
372
+ case 'remote-control':
373
+ return await cmds.cmdRemoteControl(parsed, io);
374
+ case 'handoff':
375
+ return await cmds.cmdHandoff(parsed, io);
376
+ case 'ui':
377
+ return await cmds.cmdUi(parsed, io);
378
+ case 'app':
379
+ return await cmds.cmdApp(parsed, io);
380
+ case 'desktop':
381
+ return await cmds.cmdDesktop(parsed, io);
382
+ case 'shell-init':
383
+ return await cmds.cmdShellInit(parsed, io);
384
+ case 'plugins':
385
+ return await cmds.cmdPlugins(parsed, io);
386
+ case 'families':
387
+ return await cmds.cmdFamilies(parsed, io);
388
+ case 'update':
389
+ return await cmds.cmdUpdate(parsed, io);
390
+ case 'update-check':
391
+ return await cmds.cmdUpdateCheck(parsed, io);
392
+ }
393
+ return 2;
394
+ }
334
395
  // Run when executed directly, also through symlinks (npm's bin shim, a user alias like `sb`).
335
396
  function isEntry() {
336
397
  const arg = process.argv[1];
@@ -1,29 +1,83 @@
1
- export type RoundedRect = readonly [x: number, y: number, w: number, h: number, r: number];
2
- export declare const MARK: {
3
- readonly back: readonly [12, 82, 128, 104, 44];
4
- readonly front: readonly [116, 70, 128, 116, 46];
5
- /** Cut out of the back face (and its eyes) so the two faces never touch. */
6
- readonly gap: readonly [104, 58, 152, 140, 58];
7
- readonly backEyes: readonly [readonly [45, 119, 14, 26, 7], readonly [83, 119, 14, 26, 7]];
8
- readonly frontEyes: readonly [readonly [155, 111, 14, 26, 7], readonly [193, 111, 14, 26, 7]];
1
+ /** An outlined shape of one buddy, as SVG path data on its 64-unit grid. */
2
+ export interface BuddyPath {
3
+ d: string;
4
+ /** `body`, `shade`: the buddy's own colours; `none`: an open line. */
5
+ fill: 'body' | 'shade' | 'none';
6
+ /** Line colour; `chevron` is the buddy's accent colour. Default ink. */
7
+ stroke?: 'ink' | 'chevron';
8
+ /** Line width on the 64-unit grid, before the buddy's scale. Default: the outline width. */
9
+ width?: number;
10
+ }
11
+ /** A filled ellipse without outline: the eyes and their highlights. */
12
+ export interface BuddyDot {
13
+ cx: number;
14
+ cy: number;
15
+ rx: number;
16
+ ry: number;
17
+ fill: 'ink' | 'light';
18
+ }
19
+ /** Back to front: feet, arms, body, then the face. */
20
+ export declare const BUDDY: {
21
+ paths: readonly BuddyPath[];
22
+ eyes: readonly BuddyDot[];
9
23
  };
24
+ /** The eye highlights sit towards where the buddy looks: `1` right, `-1` left. */
25
+ export declare function highlights(look: 1 | -1): BuddyDot[];
26
+ export interface BuddyColors {
27
+ body: string;
28
+ shade: string;
29
+ chevron: string;
30
+ }
10
31
  export declare const MARK_COLORS: {
11
- readonly front: '#3f8fc4';
12
- readonly back: '#f2b35e';
13
- readonly eye: '#1e2a44';
32
+ readonly blue: {
33
+ readonly body: '#3f8fc4';
34
+ readonly shade: '#29658e';
35
+ readonly chevron: '#f2b35e';
36
+ };
37
+ readonly amber: {
38
+ readonly body: '#f2b35e';
39
+ readonly shade: '#c98a35';
40
+ readonly chevron: '#3f8fc4';
41
+ };
42
+ readonly ink: '#1e2a44';
43
+ readonly light: '#f6f0e5';
44
+ };
45
+ export interface PlacedBuddy {
46
+ colors: BuddyColors;
47
+ /** Where the buddy looks (and which way its eye highlights sit). */
48
+ look: 1 | -1;
49
+ /** 64-unit grid → mark units: translate by (x, y), then scale. */
50
+ x: number;
51
+ y: number;
52
+ scale: number;
53
+ }
54
+ /** The two buddies in mark units, back first. The blue one in front is the one a Portal's `logoColor` recolours. */
55
+ export declare const MARK: {
56
+ line: number;
57
+ placedLine: number;
58
+ back: PlacedBuddy;
59
+ front: PlacedBuddy;
60
+ };
61
+ /** The square the pair is drawn in, in mark units (the Panel's 40 px tile and the app icon tile). */
62
+ export declare const MARK_VIEWBOX: {
63
+ readonly x: -2.5;
64
+ readonly y: 3;
65
+ readonly size: 69;
14
66
  };
15
- /** The square the mark is drawn in when it sits on a tile (about 78% of the tile is mark). */
16
- export declare const MARK_TILE_VIEWBOX: {
17
- readonly x: -20.5;
18
- readonly y: -20.5;
19
- readonly size: 297;
67
+ /** The square around one buddy at full size, for the small cut (favicon). */
68
+ export declare const BUDDY_VIEWBOX: {
69
+ readonly x: 3;
70
+ readonly y: 10.5;
71
+ readonly size: 58;
20
72
  };
21
- export declare function roundedRectPath([x, y, w, h, r]: RoundedRect): string;
22
73
  /**
23
- * The mark as SVG children (no outer <svg>). The front face has no fill of its own, so it takes the surrounding
24
- * `fill` (the Panel's `--logo-fg`, which Portals can set with `logoColor`); the back face and the eyes are fixed.
25
- * `id` names the gap mask; keep it unique per document.
74
+ * The mark as SVG children (no outer <svg>). With `front` omitted the blue buddy's body has no fill of its own, so it
75
+ * takes the surrounding `fill` (the Panel's `--logo-fg`, which Portals set with `logoColor`); everything else is fixed.
26
76
  */
27
- export declare function markSvgBody(id: string, front?: string): string;
77
+ export declare function markSvgBody(front?: string): string;
28
78
  /** The Panel's header logo: the mark sized for its 40 px tile. */
29
79
  export declare const MARK_SVG: string;
80
+ /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
81
+ export declare const ICON_SVG: string;
82
+ /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
83
+ export declare const FAVICON_SVG: string;
@@ -1,42 +1,88 @@
1
- // The sideby mark: two friendly faces side by side, the amber one behind and the blue one in front, cut apart by a
2
- // gap that follows the front face (ADR-0020). One source for the Panel header, the empty state and the app icon.
3
- // Geometry is on a 256-unit grid centred on (128, 128); rounded rectangles are [x, y, width, height, radius].
4
- export const MARK = {
5
- back: [12, 82, 128, 104, 44],
6
- front: [116, 70, 128, 116, 46],
7
- /** Cut out of the back face (and its eyes) so the two faces never touch. */
8
- gap: [104, 58, 152, 140, 58],
9
- backEyes: [
10
- [45, 119, 14, 26, 7],
11
- [83, 119, 14, 26, 7],
1
+ // The sideby mark: two buddies side by side, the amber one behind on the left and the blue one in front on the right,
2
+ // looking at each other (ADR-0022, the characters of the intro film). One source for the Panel header, the empty
3
+ // state, the Portal icon, the favicon and the app icon.
4
+ // One buddy is drawn on a 64-unit grid; the mark places two of them with a translate and a scale.
5
+ /** Back to front: feet, arms, body, then the face. */
6
+ export const BUDDY = {
7
+ paths: [
8
+ { d: 'M17 48 Q15 58 22 57 Q27 57 27 50 M37 50 Q37 57 42 57 Q49 58 47 48', fill: 'shade' },
9
+ { d: 'M14 37 C4 32 4 41 14 43 M50 37 C60 32 60 41 50 43', fill: 'body' },
10
+ {
11
+ d: 'M12 37 C12 27 17 23 25 23 H39 C47 23 52 27 52 37 C52 48 45 53 32 53 C19 53 12 48 12 37 Z',
12
+ fill: 'body',
13
+ },
14
+ { d: 'M30 27 L34 29.31 L30 31.62', fill: 'none', stroke: 'chevron', width: 2.5 },
15
+ { d: 'M29 46 Q32 49 35 46', fill: 'none', width: 2.5 },
12
16
  ],
13
- frontEyes: [
14
- [155, 111, 14, 26, 7],
15
- [193, 111, 14, 26, 7],
17
+ eyes: [
18
+ { cx: 22, cy: 38.5, rx: 4.5, ry: 6, fill: 'ink' },
19
+ { cx: 42, cy: 38.5, rx: 4.5, ry: 6, fill: 'ink' },
16
20
  ],
17
21
  };
18
- export const MARK_COLORS = { front: '#3f8fc4', back: '#f2b35e', eye: '#1e2a44' };
19
- /** The square the mark is drawn in when it sits on a tile (about 78% of the tile is mark). */
20
- export const MARK_TILE_VIEWBOX = { x: -20.5, y: -20.5, size: 297 };
21
- export function roundedRectPath([x, y, w, h, r]) {
22
- return (`M${x + r} ${y}H${x + w - r}A${r} ${r} 0 0 1 ${x + w} ${y + r}V${y + h - r}A${r} ${r} 0 0 1 ${x + w - r} ${y + h}` +
23
- `H${x + r}A${r} ${r} 0 0 1 ${x} ${y + h - r}V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`);
22
+ /** The eye highlights sit towards where the buddy looks: `1` right, `-1` left. */
23
+ export function highlights(look) {
24
+ return BUDDY.eyes.map((e) => ({
25
+ cx: e.cx + 1.5 * look,
26
+ cy: e.cy + (look > 0 ? 0.5 : -2),
27
+ rx: 1.65,
28
+ ry: 2.1,
29
+ fill: 'light',
30
+ }));
31
+ }
32
+ export const MARK_COLORS = {
33
+ blue: { body: '#3f8fc4', shade: '#29658e', chevron: '#f2b35e' },
34
+ amber: { body: '#f2b35e', shade: '#c98a35', chevron: '#3f8fc4' },
35
+ ink: '#1e2a44',
36
+ light: '#f6f0e5',
37
+ };
38
+ /** The two buddies in mark units, back first. The blue one in front is the one a Portal's `logoColor` recolours. */
39
+ export const MARK = {
40
+ /** Outline width of a buddy drawn alone, on its 64-unit grid. */
41
+ line: 2.75,
42
+ /** Outline width of a placed buddy before its scale, so the outline stays about 2.5 mark units. */
43
+ placedLine: 3.6,
44
+ back: { colors: MARK_COLORS.amber, look: 1, x: -2, y: 2, scale: 0.7 },
45
+ front: { colors: MARK_COLORS.blue, look: -1, x: 22, y: 16, scale: 0.7 },
46
+ };
47
+ /** The square the pair is drawn in, in mark units (the Panel's 40 px tile and the app icon tile). */
48
+ export const MARK_VIEWBOX = { x: -2.5, y: 3, size: 69 };
49
+ /** The square around one buddy at full size, for the small cut (favicon). */
50
+ export const BUDDY_VIEWBOX = { x: 3, y: 10.5, size: 58 };
51
+ function buddySvg(b, line, bodyFill) {
52
+ const color = (fill) => {
53
+ if (fill === 'none')
54
+ return ' fill="none"';
55
+ if (fill === 'shade')
56
+ return ` fill="${b.colors.shade}"`;
57
+ return bodyFill === null ? '' : ` fill="${bodyFill ?? b.colors.body}"`;
58
+ };
59
+ const paths = BUDDY.paths
60
+ .map((p) => {
61
+ const stroke = p.stroke === 'chevron' ? ` stroke="${b.colors.chevron}"` : '';
62
+ const width = p.width ? ` stroke-width="${p.width}"` : '';
63
+ return `<path d="${p.d}"${color(p.fill)}${stroke}${width}/>`;
64
+ })
65
+ .join('');
66
+ const dots = [...BUDDY.eyes, ...highlights(b.look)]
67
+ .map((e) => `<ellipse cx="${e.cx}" cy="${e.cy}" rx="${e.rx}" ry="${e.ry}" fill="${e.fill === 'ink' ? MARK_COLORS.ink : MARK_COLORS.light}"/>`)
68
+ .join('');
69
+ const transform = b.scale === 1 && b.x === 0 && b.y === 0 ? '' : ` transform="translate(${b.x} ${b.y}) scale(${b.scale})"`;
70
+ return `<g${transform} stroke-width="${line}">${paths}<g stroke="none">${dots}</g></g>`;
24
71
  }
72
+ const OUTLINE = `stroke="${MARK_COLORS.ink}" stroke-linecap="round" stroke-linejoin="round"`;
25
73
  /**
26
- * The mark as SVG children (no outer <svg>). The front face has no fill of its own, so it takes the surrounding
27
- * `fill` (the Panel's `--logo-fg`, which Portals can set with `logoColor`); the back face and the eyes are fixed.
28
- * `id` names the gap mask; keep it unique per document.
74
+ * The mark as SVG children (no outer <svg>). With `front` omitted the blue buddy's body has no fill of its own, so it
75
+ * takes the surrounding `fill` (the Panel's `--logo-fg`, which Portals set with `logoColor`); everything else is fixed.
29
76
  */
30
- export function markSvgBody(id, front) {
31
- const eyes = (list) => list.map(roundedRectPath).join('');
32
- const frontFill = front ? ` fill="${front}"` : '';
33
- return (`<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="256" height="256">` +
34
- `<rect width="256" height="256" fill="#fff"/><path fill="#000" d="${roundedRectPath(MARK.gap)}"/></mask>` +
35
- `<g mask="url(#${id})"><path fill="${MARK_COLORS.back}" d="${roundedRectPath(MARK.back)}"/>` +
36
- `<path fill="${MARK_COLORS.eye}" d="${eyes(MARK.backEyes)}"/></g>` +
37
- `<path${frontFill} d="${roundedRectPath(MARK.front)}"/>` +
38
- `<path fill="${MARK_COLORS.eye}" d="${eyes(MARK.frontEyes)}"/>`);
77
+ export function markSvgBody(front) {
78
+ return `<g ${OUTLINE}>${buddySvg(MARK.back, MARK.placedLine)}${buddySvg(MARK.front, MARK.placedLine, front ?? null)}</g>`;
39
79
  }
40
- const V = MARK_TILE_VIEWBOX;
80
+ const viewBox = (v) => `${v.x} ${v.y} ${v.size} ${v.size}`;
41
81
  /** The Panel's header logo: the mark sized for its 40 px tile. */
42
- export const MARK_SVG = `<svg viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody('sideby-mark-gap')}</svg>`;
82
+ export const MARK_SVG = `<svg viewBox="${viewBox(MARK_VIEWBOX)}">${markSvgBody()}</svg>`;
83
+ /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
84
+ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(MARK_VIEWBOX)}" role="img" aria-label="sideby">` +
85
+ `${markSvgBody(MARK_COLORS.blue.body)}</svg>`;
86
+ /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
87
+ export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(BUDDY_VIEWBOX)}">` +
88
+ `<g ${OUTLINE}>${buddySvg({ ...MARK.front, x: 0, y: 0, scale: 1 }, MARK.line)}</g></svg>`;
@@ -62,6 +62,10 @@ export declare const ConfigSchema: Type.TObject<{
62
62
  order: Type.TOptional<Type.TArray<Type.TString>>;
63
63
  }>>>;
64
64
  }>>;
65
+ update: Type.TOptional<Type.TObject<{
66
+ check: Type.TOptional<Type.TBoolean>;
67
+ auto: Type.TOptional<Type.TBoolean>;
68
+ }>>;
65
69
  shellInitFile: Type.TOptional<Type.TObject<{
66
70
  zsh: Type.TOptional<Type.TString>;
67
71
  bash: Type.TOptional<Type.TString>;
@@ -85,6 +89,18 @@ export declare function editConfigHandoff(file: string, edit: (current: HandoffC
85
89
  before: HandoffConfig | undefined;
86
90
  after: HandoffConfig | undefined;
87
91
  }>;
92
+ export type UpdateConfig = NonNullable<Config['update']>;
93
+ /**
94
+ * The Panel's Auto Update switch (spec §3.23): changes the config `update` key with `edit` (see `editConfigKey` for
95
+ * how the file is written). With `dryRun`, shows the change without writing.
96
+ */
97
+ export declare function editConfigUpdate(file: string, edit: (current: UpdateConfig) => UpdateConfig, opts?: {
98
+ dryRun?: boolean;
99
+ }): Promise<{
100
+ changed: boolean;
101
+ before: UpdateConfig | undefined;
102
+ after: UpdateConfig | undefined;
103
+ }>;
88
104
  export type AccountsConfig = NonNullable<Config['accounts']>;
89
105
  /**
90
106
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is