sideby 0.10.0 → 0.11.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 +22 -16
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -1
- package/dist/src/cli/commands.js +50 -5
- package/dist/src/cli/json-schemas.js +31 -1
- package/dist/src/cli/main.js +15 -5
- package/dist/src/core/app-profiles.d.ts +50 -0
- package/dist/src/core/app-profiles.js +206 -0
- package/dist/src/core/brand-mark.d.ts +29 -0
- package/dist/src/core/brand-mark.js +42 -0
- package/dist/src/core/desktop-app.d.ts +18 -1
- package/dist/src/core/desktop-app.js +22 -12
- package/dist/src/core/handoff-chain.d.ts +16 -2
- package/dist/src/core/handoff-chain.js +19 -6
- package/dist/src/core/handoff-run.js +43 -18
- package/dist/src/core/handoff-select.d.ts +4 -0
- package/dist/src/core/handoff-select.js +3 -2
- package/dist/src/desktop/icon.js +33 -33
- package/dist/src/families/claude/handoff.js +10 -0
- package/dist/src/families/desktop.d.ts +25 -0
- package/dist/src/families/desktop.js +8 -2
- package/dist/src/families/grok/bot.d.ts +8 -0
- package/dist/src/families/grok/bot.js +18 -0
- package/dist/src/handoff/hook.js +21 -4
- package/dist/src/handoff/messages.d.ts +4 -0
- package/dist/src/handoff/messages.js +12 -0
- package/dist/src/panel/handler.d.ts +3 -0
- package/dist/src/panel/handler.js +37 -1
- package/dist/src/panel/i18n.d.ts +30 -0
- package/dist/src/panel/i18n.js +60 -0
- package/dist/src/panel/page-script.js +149 -5
- package/dist/src/panel/page-styles.js +15 -0
- package/dist/src/panel/page.js +2 -1
- package/dist/src/runtime.d.ts +3 -1
- package/dist/src/runtime.js +7 -0
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
- package/schemas/desktop-remove.json +33 -0
- package/schemas/desktop.json +2 -1
- package/schemas/handoff-ready.json +4 -0
- package/schemas/list.json +47 -0
- package/skills/sideby/SKILL.md +3 -1
- package/skills/sideby/references/handoff.md +1 -1
package/README.md
CHANGED
|
@@ -16,28 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
<p align="center">English | <a href="README.zh-CN.md">中文</a></p>
|
|
18
18
|
|
|
19
|
-
## Why not just an alias?
|
|
20
|
-
|
|
21
|
-
`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)
|
|
22
|
-
|
|
23
|
-
| | At the same time | Skills, hooks, settings | Quota and usage | Setup |
|
|
24
|
-
|---|---|---|---|---|
|
|
25
|
-
| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |
|
|
26
|
-
| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |
|
|
27
|
-
| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |
|
|
28
|
-
|
|
29
|
-
cc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's "open terminal" can start one provider without changing the global config.
|
|
30
|
-
|
|
31
19
|
## Quick start
|
|
32
20
|
|
|
33
21
|
Requires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) ("Set up sideby for a user") instead.
|
|
34
22
|
|
|
35
23
|
```sh
|
|
36
24
|
npm i -g sideby
|
|
37
|
-
sideby new claude work
|
|
38
|
-
sideby login claude:work
|
|
39
|
-
sideby run work
|
|
40
|
-
sideby
|
|
25
|
+
sideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks
|
|
26
|
+
sideby login claude:work # sign in once
|
|
27
|
+
sideby run work # Claude Code on the work account
|
|
28
|
+
sideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude
|
|
29
|
+
sideby ui # every account's quota at http://127.0.0.1:17420
|
|
41
30
|
```
|
|
42
31
|
|
|
43
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`.
|
|
@@ -55,6 +44,10 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
55
44
|
|
|
56
45
|
## What it looks like
|
|
57
46
|
|
|
47
|
+
The 25-second story: one account buried in tasks, then accounts side by side sharing one setup, and a task moving on when one runs out.
|
|
48
|
+
|
|
49
|
+
https://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958
|
|
50
|
+
|
|
58
51
|
<img alt="Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1280">
|
|
59
52
|
|
|
60
53
|
<picture>
|
|
@@ -63,6 +56,18 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
63
56
|
<img alt="sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png" width="1280">
|
|
64
57
|
</picture>
|
|
65
58
|
|
|
59
|
+
## Why not just an alias?
|
|
60
|
+
|
|
61
|
+
`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)
|
|
62
|
+
|
|
63
|
+
| | At the same time | Skills, hooks, settings | Quota and usage | Setup |
|
|
64
|
+
|---|---|---|---|---|
|
|
65
|
+
| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |
|
|
66
|
+
| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |
|
|
67
|
+
| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |
|
|
68
|
+
|
|
69
|
+
cc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's "open terminal" can start one provider without changing the global config.
|
|
70
|
+
|
|
66
71
|
## What else it does
|
|
67
72
|
|
|
68
73
|
| You want to | Run | Guide |
|
|
@@ -78,6 +83,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
78
83
|
| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
|
|
79
84
|
| Reopen a session from Orca and the like in the account that holds it | `"resumeRouting": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |
|
|
80
85
|
| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |
|
|
86
|
+
| Keep several Grok Bot windows open, each signed in to its own account (macOS) | `sideby desktop grok-bot:002` (or the panel) | [Grok Bot windows](docs/guide/usage.md#grok-bot-windows) |
|
|
81
87
|
| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |
|
|
82
88
|
| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |
|
|
83
89
|
| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |
|
package/dist/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"
|
|
1
|
+
{"id":"20261016T040419-834a8d"}
|
|
@@ -62,7 +62,10 @@ 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
64
|
export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
|
|
67
|
+
* a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
|
|
68
|
+
*/
|
|
66
69
|
export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
|
|
67
70
|
export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
|
|
68
71
|
/** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { aliasesByAccount } from '../core/accounts.js';
|
|
3
|
+
import { listAppProfiles, openAppProfile, parseProfileRef, removeAppProfile } from '../core/app-profiles.js';
|
|
3
4
|
import { aliasRefs } from '../core/config.js';
|
|
4
5
|
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
5
6
|
import { UserError } from '../core/errors.js';
|
|
@@ -55,11 +56,14 @@ function tilde(text, home) {
|
|
|
55
56
|
export async function cmdList(a, io) {
|
|
56
57
|
const rt = await createRuntime();
|
|
57
58
|
const accounts = await rt.accounts();
|
|
58
|
-
const statuses = await
|
|
59
|
+
const [statuses, appProfiles] = await Promise.all([
|
|
60
|
+
withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
|
|
61
|
+
listAppProfiles(rt),
|
|
62
|
+
]);
|
|
59
63
|
const hasDesktop = statuses.some((s) => s.desktopApp);
|
|
60
64
|
const families = await rt.familyInfo();
|
|
61
65
|
if (a.flags.has('json')) {
|
|
62
|
-
json(io, { families, accounts: statuses, pluginErrors: [...rt.pluginErrors] });
|
|
66
|
+
json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
|
|
63
67
|
return 0;
|
|
64
68
|
}
|
|
65
69
|
const home = rt.paths.home;
|
|
@@ -94,6 +98,25 @@ export async function cmdList(a, io) {
|
|
|
94
98
|
io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
|
|
95
99
|
io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
|
|
96
100
|
}
|
|
101
|
+
// App Profiles (ADR-0019): windows of an app that signs in apart from the Accounts above.
|
|
102
|
+
for (const app of new Set(appProfiles.map((p) => p.app))) {
|
|
103
|
+
const list = appProfiles.filter((p) => p.app === app);
|
|
104
|
+
io.out(`\n${c.bold(app)} ${c.dim('(signs in on its own, not with the accounts above)')}`);
|
|
105
|
+
io.out(table(list.map((p) => [
|
|
106
|
+
c.bold(p.ref),
|
|
107
|
+
p.running ? c.green('open') : c.dim('—'),
|
|
108
|
+
p.signedIn === true
|
|
109
|
+
? c.green('signed in')
|
|
110
|
+
: p.signedIn === false
|
|
111
|
+
? c.yellow('not signed in')
|
|
112
|
+
: c.dim('?'),
|
|
113
|
+
p.appData ? tildify(p.appData, home) : c.dim('default'),
|
|
114
|
+
]), ['WINDOW', 'STATE', 'SIGN-IN', 'DATA']));
|
|
115
|
+
if (!list[0]?.installed)
|
|
116
|
+
io.out(c.yellow(`${app} is not installed; these windows open again once it is`));
|
|
117
|
+
const id = list[0]?.ref.slice(0, list[0].ref.indexOf(':'));
|
|
118
|
+
io.out(c.dim(`sideby desktop ${id}:<name> opens one (a new name makes a new window)`));
|
|
119
|
+
}
|
|
97
120
|
for (const s of statuses)
|
|
98
121
|
for (const p of s.problems ?? [])
|
|
99
122
|
io.err(c.yellow(`${s.ref}: ${p}`));
|
|
@@ -860,7 +883,10 @@ export async function cmdHandoff(a, io) {
|
|
|
860
883
|
requested: true,
|
|
861
884
|
...(next ? { next } : {}),
|
|
862
885
|
...(request.brief ? { brief: request.brief } : {}),
|
|
886
|
+
...(request.now ? { now: true } : {}),
|
|
863
887
|
});
|
|
888
|
+
else if (request.now)
|
|
889
|
+
io.out(`This session was waiting for its quota; ${next ?? 'the next account'} takes over now.`);
|
|
864
890
|
else
|
|
865
891
|
io.out(`${next ? `${next} takes over` : 'The next account starts'} when this turn ends; finish the turn now.`);
|
|
866
892
|
return 0;
|
|
@@ -1019,13 +1045,32 @@ export async function cmdApp(a, io) {
|
|
|
1019
1045
|
}
|
|
1020
1046
|
return r.skipped.length ? 1 : 0;
|
|
1021
1047
|
}
|
|
1022
|
-
/**
|
|
1048
|
+
/**
|
|
1049
|
+
* `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
|
|
1050
|
+
* a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
|
|
1051
|
+
*/
|
|
1023
1052
|
export async function cmdDesktop(a, io) {
|
|
1024
1053
|
const [ref, ...extra] = a.positionals;
|
|
1025
1054
|
if (!ref || extra.length || a.rest.length)
|
|
1026
|
-
throw new UsageError('usage: sideby desktop <account>');
|
|
1055
|
+
throw new UsageError('usage: sideby desktop <account> | sideby desktop grok-bot:<name> [--remove [--yes]]');
|
|
1027
1056
|
const rt = await createRuntime();
|
|
1028
|
-
const
|
|
1057
|
+
const profile = parseProfileRef(ref) !== undefined;
|
|
1058
|
+
if (a.flags.has('remove')) {
|
|
1059
|
+
if (!profile)
|
|
1060
|
+
throw new UsageError('--remove works on Grok Bot windows only: sideby desktop grok-bot:<name> --remove');
|
|
1061
|
+
const r = await removeAppProfile(rt, ref, { yes: a.flags.has('yes') });
|
|
1062
|
+
if (a.flags.has('json'))
|
|
1063
|
+
json(io, r);
|
|
1064
|
+
else if (r.removed)
|
|
1065
|
+
io.out(`${c.green('✓')} removed the ${r.app} window ${c.bold(r.ref)} and its data`);
|
|
1066
|
+
else {
|
|
1067
|
+
io.out(`Remove the ${r.app} window ${c.bold(r.ref)}?`);
|
|
1068
|
+
io.out(` deletes ${tildify(r.appData, rt.paths.home)}: its sign-in, bots and local data on this Mac`);
|
|
1069
|
+
io.out('Run again with --yes to remove it.');
|
|
1070
|
+
}
|
|
1071
|
+
return r.removed ? 0 : 10;
|
|
1072
|
+
}
|
|
1073
|
+
const r = profile ? await openAppProfile(rt, ref) : await openDesktopApp(rt, ref);
|
|
1029
1074
|
if (a.flags.has('json')) {
|
|
1030
1075
|
json(io, r);
|
|
1031
1076
|
return 0;
|
|
@@ -194,11 +194,30 @@ const ShellInitFiles = Type.Array(Type.Object({
|
|
|
194
194
|
action: Type.Optional(Type.Union([Type.Literal('written'), Type.Literal('unchanged')])),
|
|
195
195
|
message: Type.Optional(Str),
|
|
196
196
|
}), { description: 'One entry per config `shellInitFile`, rewritten after the change.' });
|
|
197
|
+
const AppProfile = Type.Object({
|
|
198
|
+
ref: Type.String({ description: "`grok-bot:<name>`; `grok-bot:main` is the app's own default data." }),
|
|
199
|
+
app: Type.String({ description: 'The app, for example `Grok Bot`.' }),
|
|
200
|
+
name: Str,
|
|
201
|
+
isMain: Type.Boolean(),
|
|
202
|
+
appData: Type.Optional(Type.String({
|
|
203
|
+
description: "This window's data folder; absent for the main one, which uses the app's default.",
|
|
204
|
+
})),
|
|
205
|
+
installed: Type.Boolean(),
|
|
206
|
+
running: Type.Boolean({ description: "This window's own instance runs now." }),
|
|
207
|
+
signedIn: Type.Optional(Type.Boolean({
|
|
208
|
+
description: 'What the app last wrote about its sign-in (it keeps the last value after the app quits); absent when unknown. Which account is signed in is not known.',
|
|
209
|
+
})),
|
|
210
|
+
}, {
|
|
211
|
+
description: 'A window of a desktop app that signs in apart from every account (Grok Bot, ADR-0019). Open or create one with `sideby desktop grok-bot:<name>`.',
|
|
212
|
+
});
|
|
197
213
|
export const OUTPUT_SCHEMAS = {
|
|
198
214
|
list: Type.Object({
|
|
199
215
|
schemaVersion: Version,
|
|
200
216
|
families: Type.Array(FamilyInfo),
|
|
201
217
|
accounts: Type.Array(AccountStatus),
|
|
218
|
+
appProfiles: Type.Optional(Type.Array(AppProfile, {
|
|
219
|
+
description: 'Grok Bot windows (macOS, when Grok Bot is installed or has windows), the main one first. Not accounts.',
|
|
220
|
+
})),
|
|
202
221
|
pluginErrors: Type.Array(PluginError),
|
|
203
222
|
}, { title: 'sideby list --json' }),
|
|
204
223
|
doctor: Type.Object({
|
|
@@ -380,6 +399,8 @@ export const OUTPUT_SCHEMAS = {
|
|
|
380
399
|
requested: Type.Literal(true),
|
|
381
400
|
next: Type.Optional(Str),
|
|
382
401
|
brief: Type.Optional(Str),
|
|
402
|
+
/** The session was waiting for its quota, so the handoff starts now instead of after a turn (ADR-0018). */
|
|
403
|
+
now: Type.Optional(Type.Literal(true)),
|
|
383
404
|
}, { title: 'sideby handoff ready --json' }),
|
|
384
405
|
'handoff-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby handoff setup --json' }),
|
|
385
406
|
'handoff-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby handoff teardown --json' }),
|
|
@@ -445,7 +466,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
445
466
|
desktop: Type.Object({
|
|
446
467
|
schemaVersion: Version,
|
|
447
468
|
ref: Str,
|
|
448
|
-
family:
|
|
469
|
+
family: Type.String({ description: 'The family, or `grok-bot` for a Grok Bot window.' }),
|
|
449
470
|
app: Type.String({ description: 'The desktop app, for example `Claude Desktop`.' }),
|
|
450
471
|
appPath: Str,
|
|
451
472
|
action: Type.Union([Type.Literal('started'), Type.Literal('focused'), Type.Literal('already-running')], {
|
|
@@ -461,6 +482,15 @@ export const OUTPUT_SCHEMAS = {
|
|
|
461
482
|
command: Type.Optional(Type.Array(Str, { description: 'The `open` command sideby ran.' })),
|
|
462
483
|
notices: Type.Array(Str),
|
|
463
484
|
}, { title: 'sideby desktop --json' }),
|
|
485
|
+
'desktop-remove': Type.Object({
|
|
486
|
+
schemaVersion: Version,
|
|
487
|
+
ref: Str,
|
|
488
|
+
app: Str,
|
|
489
|
+
appData: Type.String({ description: 'The folder that is (or would be) deleted.' }),
|
|
490
|
+
removed: Type.Boolean({
|
|
491
|
+
description: 'False when only the plan was shown; run again with --yes (exit 10).',
|
|
492
|
+
}),
|
|
493
|
+
}, { title: 'sideby desktop grok-bot:<name> --remove --json' }),
|
|
464
494
|
error: Type.Object({
|
|
465
495
|
schemaVersion: Version,
|
|
466
496
|
error: Type.String({ description: 'What went wrong and what to do next.' }),
|
package/dist/src/cli/main.js
CHANGED
|
@@ -51,6 +51,7 @@ Usage:
|
|
|
51
51
|
sideby ui --stop stop the background panel
|
|
52
52
|
sideby desktop <account> open the desktop app (Claude Desktop, the Codex app) as this
|
|
53
53
|
account, next to the others (macOS)
|
|
54
|
+
sideby desktop grok-bot:<name> open a Grok Bot window, a new one for a new name (macOS)
|
|
54
55
|
sideby app install [--url <url>] add a sideby app that opens the panel (macOS, Linux)
|
|
55
56
|
sideby app uninstall remove that app
|
|
56
57
|
sideby shell-init zsh|bash print shell functions, e.g. eval "$(sideby shell-init zsh)"
|
|
@@ -133,6 +134,7 @@ Examples:
|
|
|
133
134
|
sideby doctor claude:008 --fix
|
|
134
135
|
Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
|
|
135
136
|
desktop: `sideby desktop <account> [--json]
|
|
137
|
+
sideby desktop grok-bot:<name> [--remove [--yes]] [--json]
|
|
136
138
|
|
|
137
139
|
Open the host's desktop app as one account: Claude Desktop for claude accounts, the Codex app (ChatGPT.app) for codex
|
|
138
140
|
accounts. Each account gets its own window, side by side with the others; macOS only for now.
|
|
@@ -142,14 +144,22 @@ directory (CLAUDE_CONFIG_DIR, CODEX_HOME) and its own app data folder,
|
|
|
142
144
|
~/Library/Application Support/sideby/<family>/<name>/. When that account's window is already open, sideby brings it
|
|
143
145
|
to the front instead of starting another one. API accounts are refused: the apps sign in with a subscription only.
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
Grok Bot signs in with a Cursor account, apart from every account above, so its windows have names of their own:
|
|
148
|
+
grok-bot:<name> opens that window, and makes it first when the name is new (a new window, signed out). grok-bot or
|
|
149
|
+
grok-bot:main is Grok Bot's own default window. --remove deletes a window you made, which signs it out and deletes
|
|
150
|
+
its data on this Mac; it asks for --yes first (exit 10) and refuses while that window is open. \`sideby list\` shows
|
|
151
|
+
the Grok Bot windows.
|
|
152
|
+
|
|
153
|
+
sideby never copies or changes the app, and never closes a window. The first time, sign in inside the new window;
|
|
154
|
+
close the app's other windows while you do, because the sign-in may come back to one of them.
|
|
147
155
|
|
|
148
156
|
Examples:
|
|
149
157
|
sideby desktop claude:work
|
|
150
158
|
sideby desktop cc008 --json
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
sideby desktop grok-bot:002
|
|
160
|
+
sideby desktop grok-bot:002 --remove --yes
|
|
161
|
+
Exit: 0 opened, brought to the front or removed; 1 not macOS, no desktop app for that host, API account, app not
|
|
162
|
+
installed, a bad window name, or the app did not open; 10 --remove without --yes; 2 malformed command line.`,
|
|
153
163
|
families: `sideby families [family] [--json]
|
|
154
164
|
|
|
155
165
|
For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
|
|
@@ -189,7 +199,7 @@ const COMMANDS = {
|
|
|
189
199
|
},
|
|
190
200
|
ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
|
|
191
201
|
app: { flags: ['json'], valued: ['url'] },
|
|
192
|
-
desktop: { flags: ['json'] },
|
|
202
|
+
desktop: { flags: ['json', 'remove', 'yes'] },
|
|
193
203
|
'shell-init': { flags: ['write'] },
|
|
194
204
|
plugins: { flags: ['json'] },
|
|
195
205
|
families: { flags: ['json'] },
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ProfileAppDef } from '../families/desktop.ts';
|
|
2
|
+
import type { Runtime } from '../runtime.ts';
|
|
3
|
+
import { type DesktopOpenResult } from './desktop-app.ts';
|
|
4
|
+
export interface AppProfileStatus {
|
|
5
|
+
/** `grok-bot:<name>`; the Main Profile is `grok-bot:main`. */
|
|
6
|
+
ref: string;
|
|
7
|
+
/** The app as messages name it, for example `Grok Bot`. */
|
|
8
|
+
app: string;
|
|
9
|
+
name: string;
|
|
10
|
+
isMain: boolean;
|
|
11
|
+
/** The Profile's app data folder; absent for the Main Profile, which uses the app's default. */
|
|
12
|
+
appData?: string;
|
|
13
|
+
installed: boolean;
|
|
14
|
+
/** That Profile's own window runs now. */
|
|
15
|
+
running: boolean;
|
|
16
|
+
/** What the app last wrote about its sign-in; absent when unknown. */
|
|
17
|
+
signedIn?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface AppProfileRemoveResult {
|
|
20
|
+
ref: string;
|
|
21
|
+
app: string;
|
|
22
|
+
appData: string;
|
|
23
|
+
/** False when only the plan was shown (no `--yes`). */
|
|
24
|
+
removed: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** The app and Profile name a ref names, or undefined when it is not an App Profile ref. */
|
|
27
|
+
export declare function parseProfileRef(ref: string): {
|
|
28
|
+
def: ProfileAppDef;
|
|
29
|
+
name: string;
|
|
30
|
+
} | undefined;
|
|
31
|
+
/** Where the App Profiles of an app live: `~/Library/Application Support/sideby/<app>`. */
|
|
32
|
+
export declare function profilesRoot(home: string, def: ProfileAppDef): string;
|
|
33
|
+
/**
|
|
34
|
+
* Every App Profile with its state, for `sideby list` and the Panel: only on macOS, and per app only when it is
|
|
35
|
+
* installed or has Profiles. The Main Profile first. Never throws.
|
|
36
|
+
*/
|
|
37
|
+
export declare function listAppProfiles(rt: Runtime): Promise<AppProfileStatus[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Opens an App Profile's window, creating its folder first when it is new, or brings its running window to the front.
|
|
40
|
+
* Never starts a second window for the same Profile and never ends a process.
|
|
41
|
+
*/
|
|
42
|
+
export declare function openAppProfile(rt: Runtime, ref: string): Promise<DesktopOpenResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Removes a non-main App Profile's folder, which signs that window out and deletes its local data. Without `yes` it
|
|
45
|
+
* only returns what would go. Refuses the Main Profile, a running window, a missing folder and anything that is not a
|
|
46
|
+
* real folder inside sideby's own; never follows a link.
|
|
47
|
+
*/
|
|
48
|
+
export declare function removeAppProfile(rt: Runtime, ref: string, opts: {
|
|
49
|
+
yes: boolean;
|
|
50
|
+
}): Promise<AppProfileRemoveResult>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// App Profiles (spec §3.20 "Grok Bot 窗口", ADR-0019): named windows of a desktop app that signs in apart from every
|
|
2
|
+
// Account, each with its own App Data under ~/Library/Application Support/sideby/<app>/<name>/. Opening and focusing
|
|
3
|
+
// work as for a Family's Desktop App (core/desktop-app.ts); every program still goes through `DesktopDeps.exec`.
|
|
4
|
+
import { chmod, mkdir, readdir, readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { PROFILE_APPS } from '../families/desktop.js';
|
|
7
|
+
import { ACCOUNT_NAME } from './account-name.js';
|
|
8
|
+
import { activate, cleanEnv, depsFor, findApp, instanceOf, mainArgs, notInstalled, notMac, processes, } from './desktop-app.js';
|
|
9
|
+
import { UserError } from './errors.js';
|
|
10
|
+
import { lstatOrNull } from './fs-safe.js';
|
|
11
|
+
const MAIN = 'main';
|
|
12
|
+
/** The app and Profile name a ref names, or undefined when it is not an App Profile ref. */
|
|
13
|
+
export function parseProfileRef(ref) {
|
|
14
|
+
for (const def of PROFILE_APPS) {
|
|
15
|
+
if (ref === def.id)
|
|
16
|
+
return { def, name: MAIN };
|
|
17
|
+
if (ref.startsWith(`${def.id}:`))
|
|
18
|
+
return { def, name: ref.slice(def.id.length + 1) };
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Where the App Profiles of an app live: `~/Library/Application Support/sideby/<app>`. */
|
|
23
|
+
export function profilesRoot(home, def) {
|
|
24
|
+
return join(home, 'Library', 'Application Support', 'sideby', def.id);
|
|
25
|
+
}
|
|
26
|
+
function appDataOf(home, def, name) {
|
|
27
|
+
return name === MAIN ? null : join(profilesRoot(home, def), name);
|
|
28
|
+
}
|
|
29
|
+
/** `signedIn` from the app's own status file: a boolean next to a version, a pid and a start time, not a credential. */
|
|
30
|
+
async function signedIn(file) {
|
|
31
|
+
try {
|
|
32
|
+
const v = JSON.parse(await readFile(file, 'utf8'));
|
|
33
|
+
const s = v && typeof v === 'object' ? v.signedIn : undefined;
|
|
34
|
+
return typeof s === 'boolean' ? s : undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Profile names: the real folders under the app's root with a valid name, sorted. Links and files are skipped. */
|
|
41
|
+
async function profileNames(root) {
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = await readdir(root);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const e of entries)
|
|
51
|
+
if (e !== MAIN && ACCOUNT_NAME.test(e) && (await lstatOrNull(join(root, e)))?.isDirectory())
|
|
52
|
+
out.push(e);
|
|
53
|
+
return out.sort();
|
|
54
|
+
}
|
|
55
|
+
function checkName(def, name) {
|
|
56
|
+
if (name !== MAIN && !ACCOUNT_NAME.test(name))
|
|
57
|
+
throw new UserError(`"${name}" is not a valid ${def.title} window name; use lowercase letters, digits and dashes, starting with a letter or digit (for example \`sideby desktop ${def.id}:work\`)`, { code: 'invalid-name' });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Every App Profile with its state, for `sideby list` and the Panel: only on macOS, and per app only when it is
|
|
61
|
+
* installed or has Profiles. The Main Profile first. Never throws.
|
|
62
|
+
*/
|
|
63
|
+
export async function listAppProfiles(rt) {
|
|
64
|
+
const deps = depsFor(rt);
|
|
65
|
+
if (deps.platform !== 'darwin')
|
|
66
|
+
return [];
|
|
67
|
+
const home = rt.paths.home;
|
|
68
|
+
const out = [];
|
|
69
|
+
try {
|
|
70
|
+
let procs = null;
|
|
71
|
+
for (const def of PROFILE_APPS) {
|
|
72
|
+
const app = await findApp(def, deps);
|
|
73
|
+
const names = await profileNames(profilesRoot(home, def));
|
|
74
|
+
if (!app && !names.length)
|
|
75
|
+
continue;
|
|
76
|
+
if (app && !procs)
|
|
77
|
+
procs = (await processes(deps)) ?? [];
|
|
78
|
+
for (const name of [MAIN, ...names]) {
|
|
79
|
+
const appData = appDataOf(home, def, name);
|
|
80
|
+
const status = join(appData ?? join(home, def.mainUserData), def.statusFile);
|
|
81
|
+
const s = await signedIn(status);
|
|
82
|
+
out.push({
|
|
83
|
+
ref: `${def.id}:${name}`,
|
|
84
|
+
app: def.title,
|
|
85
|
+
name,
|
|
86
|
+
isMain: name === MAIN,
|
|
87
|
+
...(appData ? { appData } : {}),
|
|
88
|
+
installed: app !== null,
|
|
89
|
+
running: app ? Boolean(instanceOf(app, appData, procs ?? [])) : false,
|
|
90
|
+
...(s === undefined ? {} : { signedIn: s }),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A display nicety: a failure shows no Profiles.
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
async function located(rt, ref) {
|
|
101
|
+
const parsed = parseProfileRef(ref);
|
|
102
|
+
if (!parsed)
|
|
103
|
+
throw new UserError(`${ref} is not an app window; use \`sideby desktop grok-bot:<name>\``);
|
|
104
|
+
const { def, name } = parsed;
|
|
105
|
+
checkName(def, name);
|
|
106
|
+
const deps = depsFor(rt);
|
|
107
|
+
if (deps.platform !== 'darwin')
|
|
108
|
+
throw notMac(deps.platform);
|
|
109
|
+
const app = await findApp(def, deps, { spotlight: true });
|
|
110
|
+
if (!app)
|
|
111
|
+
throw notInstalled(def, deps);
|
|
112
|
+
return { deps, def, name, app, appData: appDataOf(rt.paths.home, def, name) };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Opens an App Profile's window, creating its folder first when it is new, or brings its running window to the front.
|
|
116
|
+
* Never starts a second window for the same Profile and never ends a process.
|
|
117
|
+
*/
|
|
118
|
+
export async function openAppProfile(rt, ref) {
|
|
119
|
+
const { deps, def, name, app, appData } = await located(rt, ref);
|
|
120
|
+
const listed = await processes(deps);
|
|
121
|
+
const procs = listed ?? [];
|
|
122
|
+
const base = {
|
|
123
|
+
ref: `${def.id}:${name}`,
|
|
124
|
+
family: def.id,
|
|
125
|
+
app: def.title,
|
|
126
|
+
appPath: app.path,
|
|
127
|
+
...(appData ? { appData } : {}),
|
|
128
|
+
createdAppData: false,
|
|
129
|
+
linked: [],
|
|
130
|
+
notices: [],
|
|
131
|
+
};
|
|
132
|
+
const running = instanceOf(app, appData, procs);
|
|
133
|
+
if (running) {
|
|
134
|
+
const focused = await activate(deps, running.pid);
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
action: focused ? 'focused' : 'already-running',
|
|
138
|
+
notices: focused
|
|
139
|
+
? []
|
|
140
|
+
: [
|
|
141
|
+
`${def.title} is already open as ${base.ref}; switch to it from the Dock (sideby does not start a second one)`,
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let created = false;
|
|
146
|
+
const notices = [];
|
|
147
|
+
if (appData) {
|
|
148
|
+
const st = await lstatOrNull(appData);
|
|
149
|
+
if (st && !st.isDirectory())
|
|
150
|
+
throw new UserError(`${appData} is not a folder; move it away, then try again (sideby keeps the ${def.title} window ${base.ref} there)`, { code: 'app-data-invalid' });
|
|
151
|
+
if (!st) {
|
|
152
|
+
await mkdir(appData, { recursive: true, mode: 0o700 });
|
|
153
|
+
await chmod(appData, 0o700);
|
|
154
|
+
created = true;
|
|
155
|
+
}
|
|
156
|
+
if (created || !(await readdir(appData)).length) {
|
|
157
|
+
const others = procs.some((p) => mainArgs(app, p) !== null);
|
|
158
|
+
notices.push(`New ${def.title} window ${base.ref}: sign in inside it with the account you want for this window.${others ? ` Close the other ${def.title} windows while you sign in, because the browser may send the sign-in back to one of them.` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// As for a Desktop App: `-n` also for the Main Profile, unless a running one cannot be ruled out.
|
|
162
|
+
const args = appData ? def.openArgs(app.path, appData) : listed ? ['-n', '-a', app.path] : ['-a', app.path];
|
|
163
|
+
const r = await deps.exec('/usr/bin/open', args, cleanEnv(rt.env, def.clearVars));
|
|
164
|
+
if (r.code !== 0)
|
|
165
|
+
throw new UserError(`could not open ${def.title} as ${base.ref}: ${r.stderr.trim() || `open exited with ${r.code}`}; try opening ${app.path} once by hand, then try again`, { code: 'open-failed' });
|
|
166
|
+
return { ...base, action: 'started', createdAppData: created, command: ['/usr/bin/open', ...args], notices };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Removes a non-main App Profile's folder, which signs that window out and deletes its local data. Without `yes` it
|
|
170
|
+
* only returns what would go. Refuses the Main Profile, a running window, a missing folder and anything that is not a
|
|
171
|
+
* real folder inside sideby's own; never follows a link.
|
|
172
|
+
*/
|
|
173
|
+
export async function removeAppProfile(rt, ref, opts) {
|
|
174
|
+
const parsed = parseProfileRef(ref);
|
|
175
|
+
if (!parsed)
|
|
176
|
+
throw new UserError(`${ref} is not an app window; use \`sideby desktop grok-bot:<name> --remove\``);
|
|
177
|
+
const { def, name } = parsed;
|
|
178
|
+
checkName(def, name);
|
|
179
|
+
const full = `${def.id}:${name}`;
|
|
180
|
+
if (name === MAIN)
|
|
181
|
+
throw new UserError(`${full} is ${def.title}'s own data, which sideby does not remove; sign out inside ${def.title} instead`, { code: 'main-profile' });
|
|
182
|
+
const deps = depsFor(rt);
|
|
183
|
+
if (deps.platform !== 'darwin')
|
|
184
|
+
throw notMac(deps.platform);
|
|
185
|
+
const appData = appDataOf(rt.paths.home, def, name);
|
|
186
|
+
const st = await lstatOrNull(appData);
|
|
187
|
+
if (!st)
|
|
188
|
+
throw new UserError(`there is no ${def.title} window ${full}; \`sideby list\` shows the ones there are`, {
|
|
189
|
+
code: 'no-profile',
|
|
190
|
+
});
|
|
191
|
+
if (!st.isDirectory())
|
|
192
|
+
throw new UserError(`${appData} is not a folder sideby made; it was left alone, move it away by hand`, {
|
|
193
|
+
code: 'app-data-invalid',
|
|
194
|
+
});
|
|
195
|
+
const app = await findApp(def, deps, { spotlight: true });
|
|
196
|
+
// Without a process list an open window cannot be ruled out, and deleting under a running app breaks it.
|
|
197
|
+
const procs = app ? await processes(deps) : [];
|
|
198
|
+
if (procs === null || (app && instanceOf(app, appData, procs)))
|
|
199
|
+
throw new UserError(procs === null
|
|
200
|
+
? `could not check whether ${def.title} is open as ${full}; quit that window, then try again`
|
|
201
|
+
: `${def.title} is open as ${full}; quit that window first, then remove it`, { code: 'app-running' });
|
|
202
|
+
if (!opts.yes)
|
|
203
|
+
return { ref: full, app: def.title, appData, removed: false };
|
|
204
|
+
await rm(appData, { recursive: true, force: true });
|
|
205
|
+
return { ref: full, app: def.title, appData, removed: true };
|
|
206
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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]];
|
|
9
|
+
};
|
|
10
|
+
export declare const MARK_COLORS: {
|
|
11
|
+
readonly front: '#3f8fc4';
|
|
12
|
+
readonly back: '#f2b35e';
|
|
13
|
+
readonly eye: '#1e2a44';
|
|
14
|
+
};
|
|
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;
|
|
20
|
+
};
|
|
21
|
+
export declare function roundedRectPath([x, y, w, h, r]: RoundedRect): string;
|
|
22
|
+
/**
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export declare function markSvgBody(id: string, front?: string): string;
|
|
28
|
+
/** The Panel's header logo: the mark sized for its 40 px tile. */
|
|
29
|
+
export declare const MARK_SVG: string;
|
|
@@ -0,0 +1,42 @@
|
|
|
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],
|
|
12
|
+
],
|
|
13
|
+
frontEyes: [
|
|
14
|
+
[155, 111, 14, 26, 7],
|
|
15
|
+
[193, 111, 14, 26, 7],
|
|
16
|
+
],
|
|
17
|
+
};
|
|
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`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
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.
|
|
29
|
+
*/
|
|
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)}"/>`);
|
|
39
|
+
}
|
|
40
|
+
const V = MARK_TILE_VIEWBOX;
|
|
41
|
+
/** 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>`;
|