bom-pi 0.0.0-stage → 0.0.2

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.
Files changed (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,820 @@
1
+ // Appearance: the theme and the colors, and how the app looks and moves.
2
+ import { useEffect, useRef, useState } from "preact/hooks";
3
+ import {
4
+ actions,
5
+ api,
6
+ attempt,
7
+ closeSheet,
8
+ drafts,
9
+ navigate,
10
+ notify,
11
+ openSheet,
12
+ store,
13
+ } from "../store.js";
14
+ import {
15
+ BASE_LOOK,
16
+ chooseLook,
17
+ chooseTheme,
18
+ colorsOnly,
19
+ folderPaletteIds,
20
+ lookIds,
21
+ paletteOf,
22
+ prefs,
23
+ preview,
24
+ setPrefs,
25
+ themeDefault,
26
+ themeIds,
27
+ textSizeOf,
28
+ themeVars,
29
+ varsStyle,
30
+ MAX_TEXT,
31
+ MIN_TEXT,
32
+ STEP_TEXT,
33
+ } from "../theme.js";
34
+ import { arranged, move, resetSlot, setShown, SLOTS } from "../slots.js";
35
+ import { html, Icon, Sheet, Switch } from "../ui.js";
36
+ import { galleryAvailable } from "./gallery.js";
37
+
38
+ // Key hints are for people with a keyboard: a touch screen has none.
39
+ const touch = matchMedia("(pointer: coarse)").matches;
40
+
41
+ /** A theme as a card: a tiny Hyprland desktop drawn in its own colors. */
42
+ function ThemeCard({ id, desktop = false }) {
43
+ const palette = paletteOf(id);
44
+ const vars = varsStyle(themeVars(palette));
45
+ // "Desktop" on a server without an Omarchy desktop shows Tokyo Night.
46
+ const on =
47
+ prefs().theme === id ||
48
+ (id === "tokyo-night" && prefs().theme === "desktop" && !store.state.desktopTheme);
49
+ const dots = ["--o-red", "--o-yellow", "--o-green", "--o-cyan", "--o-blue", "--o-magenta"];
50
+ const desk = html`<div class="tc-desk">
51
+ <div class="tc-win"><i></i><i></i><i class="lit"></i></div>
52
+ <div class="tc-win"><i></i><i></i><i class="lit"></i></div>
53
+ </div>`;
54
+ const name = html`<div class="tc-name">
55
+ ${desktop ? `Desktop · ${palette.name}` : palette.name}
56
+ ${palette.pair ? html`<small>light and dark</small>` : palette.colors.mode === "light" && html`<small>light</small>`}
57
+ </div>`;
58
+
59
+ return html`<button
60
+ class=${`theme-card ${on ? "on" : ""} ${desktop ? "theme-desktop" : ""}`}
61
+ aria-pressed=${on}
62
+ style=${vars}
63
+ title=${desktop ? "Follow the Omarchy theme of the machine Pi Pocket runs on" : palette.name}
64
+ onMouseEnter=${() => matchMedia("(hover: hover)").matches && preview(id)}
65
+ onMouseLeave=${() => preview(null)}
66
+ onFocus=${() => preview(id)}
67
+ onBlur=${() => preview(null)}
68
+ onClick=${(event) => chooseTheme(id, event)}
69
+ >
70
+ ${desk}
71
+ ${
72
+ desktop
73
+ ? html`<div class="tc-text">
74
+ ${name}
75
+ <div class="tc-note">
76
+ Follows your desktop: change the Omarchy theme and Pi Pocket changes with it.
77
+ </div>
78
+ <div class="tc-dots">
79
+ ${dots.map((dot) => html`<i style=${`background:var(${dot})`}></i>`)}
80
+ </div>
81
+ </div>`
82
+ : html`${name}
83
+ <div class="tc-dots">
84
+ ${dots.map((dot) => html`<i style=${`background:var(${dot})`}></i>`)}
85
+ </div>`
86
+ }
87
+ </button>`;
88
+ }
89
+
90
+ /** A theme folder as the server lists it, or the app's own look. */
91
+ const lookInfo = (id) =>
92
+ store.state.themeList?.find((each) => each.id === id) ??
93
+ (id === BASE_LOOK ? { id, name: "Pocket", builtIn: true, usable: true, css: [] } : undefined);
94
+
95
+ /** A theme as a card: its name, what it says it is, and why it cannot be used, if it cannot. */
96
+ function LookCard({ id }) {
97
+ const look = lookInfo(id);
98
+ const on = (prefs().look ?? BASE_LOOK) === id;
99
+
100
+ if (!look) {
101
+ return null;
102
+ }
103
+
104
+ return html`<button
105
+ class=${`look-card ${on ? "on" : ""} ${look.usable ? "" : "broken"}`}
106
+ aria-pressed=${on}
107
+ disabled=${!look.usable}
108
+ onClick=${(event) => chooseLook(id, event)}
109
+ >
110
+ <span class="look-name">
111
+ ${look.name}
112
+ ${look.builtIn && html`<small>built in</small>`}
113
+ ${look.colors && html`<small>own colors</small>`}
114
+ </span>
115
+ ${look.description && html`<span class="look-detail">${look.description}</span>`}
116
+ ${look.problem && html`<span class="look-problem">${look.problem}</span>`}
117
+ </button>`;
118
+ }
119
+
120
+ /** Settles once the server's list of themes has `id` in it, or after two seconds. */
121
+ function listed(id) {
122
+ return new Promise((resolve) => {
123
+ const done = () => {
124
+ clearTimeout(timer);
125
+ stop();
126
+ resolve();
127
+ };
128
+
129
+ const timer = setTimeout(done, 2000);
130
+ const stop = store.subscribe((state) => {
131
+ if (state.themeList?.some((each) => each.id === id)) {
132
+ done();
133
+ }
134
+ });
135
+
136
+ if (store.state.themeList?.some((each) => each.id === id)) {
137
+ done();
138
+ }
139
+ });
140
+ }
141
+
142
+ /** Ask for a new theme's name, and make it from `from`: its id, or a palette's. */
143
+ function Duplicate({ from, label, colors = false, onDone }) {
144
+ const [name, setName] = useState(`My ${label}`);
145
+ const [busy, setBusy] = useState(false);
146
+ const field = useRef(null);
147
+
148
+ // Ready to type a name each time (`autofocus` works once a page load), where a keyboard is at hand: on a touch
149
+ // screen, one would pop up over the sheet.
150
+ useEffect(() => {
151
+ if (!matchMedia("(pointer: coarse)").matches) {
152
+ field.current?.focus();
153
+ field.current?.select();
154
+ }
155
+ }, []);
156
+
157
+ const make = async (event) => {
158
+ event.preventDefault();
159
+ setBusy(true);
160
+ const made = await attempt(() => api("themes/duplicate", { from, name, colors }));
161
+
162
+ setBusy(false);
163
+
164
+ if (made) {
165
+ // The server lists it a moment after it answers: choose it once this tab has heard.
166
+ await listed(made.id);
167
+ notify(
168
+ "info",
169
+ `Made “${name}”: its folder is in the data folder's themes/. Edit with Pi changes it.`,
170
+ );
171
+ onDone(made.id);
172
+ }
173
+ };
174
+
175
+ return html`<form class="look-duplicate" onSubmit=${make}>
176
+ <input
177
+ ref=${field}
178
+ value=${name}
179
+ aria-label="The new theme's name"
180
+ maxlength="60"
181
+ onInput=${(event) => setName(event.currentTarget.value)}
182
+ />
183
+ <button class="button primary" disabled=${busy || name.trim() === ""}>Make</button>
184
+ <button class="button ghost" type="button" onClick=${() => onDone(null)}>Cancel</button>
185
+ </form>`;
186
+ }
187
+
188
+ /** Start a session in one of the owner's themes, with a message that says where its guide is. */
189
+ async function editWithPi(id) {
190
+ const { cwd, draft } = await api(`themes/${encodeURIComponent(id)}/edit`);
191
+ const { id: conversationId } = await actions.createSession(cwd);
192
+
193
+ drafts.set(conversationId, draft);
194
+ closeSheet();
195
+ navigate(conversationId);
196
+ }
197
+
198
+ /** The owner's tools for the theme in use: copy it, change it with Pi, or remove it. */
199
+ function LookTools() {
200
+ const [naming, setNaming] = useState(false);
201
+ const id = prefs().look ?? BASE_LOOK;
202
+ const look = lookInfo(id);
203
+
204
+ if (naming) {
205
+ return html`<${Duplicate}
206
+ from=${id}
207
+ label=${look?.name ?? "theme"}
208
+ onDone=${(made) => {
209
+ setNaming(false);
210
+
211
+ if (made) {
212
+ chooseLook(made);
213
+ }
214
+ }}
215
+ />`;
216
+ }
217
+
218
+ const remove = () =>
219
+ confirm(
220
+ `Remove the theme “${look.name}”?\n\nIts folder moves to themes/.removed/ in the data folder, so you can get it back from there.`,
221
+ ) &&
222
+ attempt(async () => {
223
+ await api(`themes/${encodeURIComponent(id)}`, undefined, { method: "DELETE" });
224
+ setPrefs({ look: BASE_LOOK });
225
+ });
226
+
227
+ return html`<div class="look-tools">
228
+ <button class="button" disabled=${!look?.usable} onClick=${() => setNaming(true)}>
229
+ <${Icon} name="copy" size=${14} /> Duplicate
230
+ </button>
231
+ ${
232
+ look && !look.builtIn
233
+ ? html`<button class="button" onClick=${() => attempt(() => editWithPi(id))}>
234
+ <${Icon} name="sparkle" size=${14} /> Edit with Pi
235
+ </button>
236
+ ${
237
+ galleryAvailable() &&
238
+ html`<button
239
+ class="button"
240
+ onClick=${() => openSheet({ type: "gallery-share", id, theme: true })}
241
+ >
242
+ Share…
243
+ </button>`
244
+ }
245
+ <button class="button ghost" onClick=${remove}>Remove</button>`
246
+ : html`<span class="muted small">Duplicate it to make a theme of your own.</span>`
247
+ }
248
+ </div>`;
249
+ }
250
+
251
+ /**
252
+ * The conversation's text size: a few usual sizes, and a field for any other, MIN_TEXT to MAX_TEXT in half pixels. The
253
+ * field takes a size once it is one (14 on the way to 145 shows at once); Enter or leaving it keeps it in range.
254
+ */
255
+ function TextSize({ size }) {
256
+ const [draft, setDraft] = useState(null);
257
+
258
+ // A size set another way (a button, another tab) shows at once, over what was being typed; what is being typed
259
+ // stays while it means this size.
260
+ useEffect(() => {
261
+ if (draft !== null && textSizeOf(draft) !== size) {
262
+ setDraft(null);
263
+ }
264
+ }, [size]);
265
+
266
+ const commit = (value) => {
267
+ setDraft(null);
268
+ setPrefs({ text: textSizeOf(value) });
269
+ };
270
+
271
+ return html`<div class="text-size" role="group" aria-label="Text size">
272
+ <div class="segmented">
273
+ ${[13, 15, 17, 20].map(
274
+ (each) => html`<button
275
+ class=${size === each ? "on" : ""}
276
+ aria-pressed=${size === each}
277
+ onClick=${() => commit(each)}
278
+ >
279
+ ${each}
280
+ </button>`,
281
+ )}
282
+ </div>
283
+ <label class="text-size-field">
284
+ <input
285
+ type="number"
286
+ inputmode="decimal"
287
+ min=${MIN_TEXT}
288
+ max=${MAX_TEXT}
289
+ step=${STEP_TEXT}
290
+ aria-label="Text size in pixels"
291
+ value=${draft ?? size}
292
+ onInput=${(event) => {
293
+ const value = event.currentTarget.value;
294
+ const number = Number(value);
295
+
296
+ setDraft(value);
297
+
298
+ if (value !== "" && number >= MIN_TEXT && number <= MAX_TEXT) {
299
+ setPrefs({ text: textSizeOf(number) });
300
+ }
301
+ }}
302
+ onChange=${(event) =>
303
+ commit(event.currentTarget.value === "" ? size : event.currentTarget.value)}
304
+ onBlur=${() => setDraft(null)}
305
+ onKeyDown=${(event) => event.key === "Enter" && event.currentTarget.blur()}
306
+ />
307
+ <span>px</span>
308
+ </label>
309
+ </div>`;
310
+ }
311
+
312
+ /** The fonts Appearance sets in a theme (themes.ts THEME_FONTS): the family, and its name here. */
313
+ const TEXT_FONTS = [
314
+ ["Pocket Sans", "Geist"],
315
+ ["Pocket Inter", "Inter"],
316
+ ["system-ui", "System Sans"],
317
+ ["Pocket Mono", "JetBrains Mono"],
318
+ ["Pocket Fira Code", "Fira Code"],
319
+ ["Pocket IBM Plex Mono", "IBM Plex Mono"],
320
+ ["Pocket Iosevka", "Iosevka"],
321
+ ["ui-monospace", "System Mono"],
322
+ ];
323
+ const CODE_FONTS = TEXT_FONTS.slice(3);
324
+
325
+ /** A font's name here, by the family a stylesheet names first; the app's own for none. */
326
+ const fontName = (fonts, family) =>
327
+ (fonts.find(([each]) => each === family) ?? (family ? [family, family] : fonts[0]))[1];
328
+
329
+ /**
330
+ * The fonts of the theme in use: for words, for code, and ligatures, with a preview. The owner sets them on a theme of
331
+ * their own, saved into it (its `fonts.css`), so everyone who uses it sees them; a theme that comes with the app shows
332
+ * its own, with a way to make a copy that can change.
333
+ */
334
+ function FontTools() {
335
+ const [naming, setNaming] = useState(false);
336
+ const id = prefs().look ?? BASE_LOOK;
337
+ const look = lookInfo(id);
338
+
339
+ if (!look?.usable) {
340
+ return null;
341
+ }
342
+
343
+ const set = look.typography ?? { ligatures: false };
344
+ const own = set.own ?? {};
345
+ // Only what changed: the server keeps the rest as it was saved, and null goes back to the theme's own.
346
+ const save = (patch) =>
347
+ attempt(() => api(`themes/${encodeURIComponent(id)}/fonts`, patch, { method: "PUT" }));
348
+ // A family of the theme's own, not one of these, shows as itself, as "": choosing it goes back to it.
349
+ const custom = (fonts, key) =>
350
+ [set[key], own[key]].find((family) => family && !fonts.some(([each]) => each === family));
351
+
352
+ const menu = (label, fonts, key) => {
353
+ const theirs = custom(fonts, key);
354
+ const value = theirs !== undefined && set[key] === theirs ? "" : (set[key] ?? fonts[0][0]);
355
+
356
+ return look.builtIn
357
+ ? html`<div class="font-menu">
358
+ <span>${label}</span>
359
+ <strong>${fontName(fonts, set[key])}</strong>
360
+ </div>`
361
+ : html`<label class="font-menu">
362
+ <span>${label}</span>
363
+ <select
364
+ value=${value}
365
+ onChange=${(event) => save({ [key]: event.currentTarget.value || null })}
366
+ >
367
+ ${fonts.map(
368
+ ([family, name]) => html`<option value=${family}>${name}</option>`,
369
+ )}
370
+ ${theirs && html`<option value="">${theirs}</option>`}
371
+ </select>
372
+ </label>`;
373
+ };
374
+
375
+ return html`<section class="font-tools" aria-label=${`Fonts for ${look.name}`}>
376
+ <h4 class="font-title">Fonts for <em>${look.name}</em></h4>
377
+ ${menu("Text font", TEXT_FONTS, "sans")}
378
+ ${menu("Code font", CODE_FONTS, "mono")}
379
+ <div class="font-menu">
380
+ <span>Ligatures</span>
381
+ ${
382
+ look.builtIn
383
+ ? html`<strong>${set.ligatures ? "On" : "Off"}</strong>`
384
+ : html`<div class="font-ligatures">
385
+ <${Switch}
386
+ on=${set.ligatures}
387
+ label="Ligatures"
388
+ onChange=${() => save({ ligatures: !set.ligatures })}
389
+ />
390
+ <button
391
+ class="button small ghost"
392
+ onClick=${() => save({ ligatures: null })}
393
+ >
394
+ Theme's own
395
+ </button>
396
+ </div>`
397
+ }
398
+ </div>
399
+ <div class="font-preview" role="group" aria-label="Font preview">
400
+ The quick brown fox jumps over the lazy dog. <em>Emphasis.</em>
401
+ <code>const ok = (x) => x !== 42; <span class="hl-com">// a comment</span></code>
402
+ </div>
403
+ ${
404
+ look.builtIn &&
405
+ (naming
406
+ ? html`<div class="font-duplicate">
407
+ <${Duplicate}
408
+ from=${id}
409
+ label=${look.name}
410
+ onDone=${(made) => {
411
+ setNaming(false);
412
+
413
+ if (made) {
414
+ chooseLook(made);
415
+ }
416
+ }}
417
+ />
418
+ </div>`
419
+ : html`<div class="font-duplicate">
420
+ <button class="button" onClick=${() => setNaming(true)}>
421
+ <${Icon} name="copy" size=${14} /> Duplicate to change
422
+ </button>
423
+ <span class="muted small">
424
+ ${look.name} comes with the app: its fonts stay as they are.
425
+ </span>
426
+ </div>`)
427
+ }
428
+ </section>`;
429
+ }
430
+
431
+ /**
432
+ * The owner's tools for the colors in use: a copy of them as a theme folder of colors, and for colors of their own, Edit
433
+ * with Pi and Remove.
434
+ */
435
+ function ColorsTools() {
436
+ const [naming, setNaming] = useState(false);
437
+ const id = prefs().theme;
438
+ const palette = paletteOf(id);
439
+ // The owner's own folder of colors alone (a theme with colors of its own has these in the Theme row).
440
+ const own = palette.folder
441
+ ? store.state.themeList?.find(
442
+ (each) => each.id === palette.id.split("@")[0] && colorsOnly(each) && !each.builtIn,
443
+ )
444
+ : undefined;
445
+
446
+ if (naming) {
447
+ return html`<${Duplicate}
448
+ from=${palette.id}
449
+ label=${palette.name}
450
+ colors=${true}
451
+ onDone=${(made) => {
452
+ setNaming(false);
453
+
454
+ if (made) {
455
+ chooseTheme(made);
456
+ }
457
+ }}
458
+ />`;
459
+ }
460
+
461
+ const remove = () =>
462
+ confirm(
463
+ `Remove the colors “${own.name}”?\n\nTheir folder moves to themes/.removed/ in the data folder, so you can get them back from there.`,
464
+ ) &&
465
+ attempt(async () => {
466
+ await api(`themes/${encodeURIComponent(own.id)}`, undefined, { method: "DELETE" });
467
+ setPrefs({ theme: "desktop" });
468
+ });
469
+
470
+ return html`<div class="look-tools">
471
+ <button class="button" onClick=${() => setNaming(true)}>
472
+ <${Icon} name="copy" size=${14} /> Duplicate colors
473
+ </button>
474
+ ${
475
+ own &&
476
+ html`<button class="button" onClick=${() => attempt(() => editWithPi(own.id))}>
477
+ <${Icon} name="sparkle" size=${14} /> Edit with Pi
478
+ </button>
479
+ <button class="button ghost" onClick=${remove}>Remove</button>`
480
+ }
481
+ </div>`;
482
+ }
483
+
484
+ /**
485
+ * The owner's default: the theme and colors of every browser whose person has not chosen their own, guests' included.
486
+ * It takes the ones in use here.
487
+ */
488
+ function DefaultTools() {
489
+ const current = themeDefault();
490
+ const p = prefs();
491
+ // The theme and colors in use as the server knows them, as Appearance shows them: a theme that is gone or broken
492
+ // shows Pocket, and "desktop" colors only where there is a desktop's.
493
+ const look = store.state.themeList?.some((each) => each.id === p.look && each.usable)
494
+ ? p.look
495
+ : BASE_LOOK;
496
+ const colors = paletteOf(p.theme).id;
497
+ // What new browsers get already: the default, or with none, Pocket and the desktop's colors.
498
+ const same = current
499
+ ? current.look === look && current.colors === colors
500
+ : look === BASE_LOOK && p.theme === "desktop";
501
+ // A theme with its own colors is named once: "My Lantern", not "My Lantern with My Lantern". The desktop's colors
502
+ // are Tokyo Night where the server has no desktop.
503
+ const name = (look, colors) =>
504
+ colors.split("@")[0] === look
505
+ ? (lookInfo(look)?.name ?? look)
506
+ : `${lookInfo(look)?.name ?? look} with ${paletteOf(colors).desktop ? "the desktop's colors" : paletteOf(colors).name}`;
507
+ const set = (value) =>
508
+ attempt(async () => {
509
+ await api("themes/default", { default: value }, { method: "PUT" });
510
+ notify(
511
+ "info",
512
+ value
513
+ ? `Browsers that have not chosen start with ${name(value.look, value.colors)}.`
514
+ : "Browsers that have not chosen start with Pocket.",
515
+ );
516
+ });
517
+
518
+ return html`<div class="look-tools">
519
+ <span class="muted small grow">
520
+ ${current ? `For new browsers: ${name(current.look, current.colors)}.` : `New browsers start with ${name(BASE_LOOK, "desktop")}.`}
521
+ </span>
522
+ ${
523
+ !same &&
524
+ html`<button class="button" onClick=${() => set({ look, colors })}>
525
+ Use these for new browsers
526
+ </button>`
527
+ }
528
+ ${current && html`<button class="button ghost" onClick=${() => set(null)}>Reset</button>`}
529
+ </div>`;
530
+ }
531
+
532
+ /** A new density, with the choice tapped kept where it was: the rows above it grow or shrink with the change. */
533
+ function chooseDensity(density, event) {
534
+ const button = event.currentTarget;
535
+ const before = button.getBoundingClientRect().top;
536
+
537
+ setPrefs({ density });
538
+ requestAnimationFrame(() => {
539
+ const body = button.closest(".sheet-body");
540
+
541
+ if (body) {
542
+ body.scrollTop += button.getBoundingClientRect().top - before;
543
+ }
544
+ });
545
+ }
546
+
547
+ function SettingRow({ title, detail, children }) {
548
+ return html`<div class="setting-row">
549
+ <div class="setting-text">
550
+ <span>${title}</span>
551
+ ${detail && html`<span>${detail}</span>`}
552
+ </div>
553
+ ${children}
554
+ </div>`;
555
+ }
556
+
557
+ /**
558
+ * One slot's items, as Layout lists them: each with a switch to show or hide it and arrows to move it, the app's own and
559
+ * extensions' alike. Fixed ones (the menu, Lancet Guard) always show, in their place. Saved in this browser.
560
+ */
561
+ function SlotLayout({ slot }) {
562
+ const list = arranged(slot);
563
+ const movable = list.filter(({ item }) => !item.fixed).map(({ item }) => item.id);
564
+ const changed = Object.keys(prefs().layout?.[slot] ?? {}).length > 0;
565
+
566
+ return html`<div class="label slot-label">
567
+ <span>${SLOTS[slot].label}</span>
568
+ ${
569
+ changed &&
570
+ html`<button class="button ghost small" onClick=${() => resetSlot(slot)}>
571
+ Reset
572
+ </button>`
573
+ }
574
+ </div>
575
+ <div class="sheet-list slot-layout" data-slot=${slot}>
576
+ ${list.map(({ item, hidden, hiddenBy }) => {
577
+ const at = movable.indexOf(item.id);
578
+ const detail = [
579
+ item.module && `From ${item.module}`,
580
+ hiddenBy && `Hidden by ${hiddenBy}`,
581
+ item.fixed && "Always shown",
582
+ ]
583
+ .filter(Boolean)
584
+ .join(" · ");
585
+
586
+ return html`<${SettingRow} key=${item.id} title=${item.label} detail=${detail}>
587
+ ${
588
+ !item.fixed &&
589
+ html`<div class="slot-moves">
590
+ <button
591
+ class="icon-button"
592
+ aria-label=${`Move ${item.label} earlier`}
593
+ disabled=${at <= 0}
594
+ onClick=${() => move(slot, item.id, -1)}
595
+ >
596
+ <${Icon} name="chevron" size=${16} class="slot-earlier" />
597
+ </button>
598
+ <button
599
+ class="icon-button"
600
+ aria-label=${`Move ${item.label} later`}
601
+ disabled=${at === movable.length - 1}
602
+ onClick=${() => move(slot, item.id, 1)}
603
+ >
604
+ <${Icon} name="chevron" size=${16} />
605
+ </button>
606
+ </div>
607
+ <${Switch}
608
+ on=${!hidden}
609
+ label=${`Show ${item.label}`}
610
+ onChange=${() => setShown(slot, item.id, hidden)}
611
+ />`
612
+ }
613
+ <//>`;
614
+ })}
615
+ </div>`;
616
+ }
617
+
618
+ export function AppearanceSheet() {
619
+ const p = prefs();
620
+ const desktop = store.state.desktopTheme;
621
+
622
+ useEffect(() => () => preview(null), []);
623
+
624
+ return html`<${Sheet} title="Appearance" onClose=${closeSheet} wide=${true}>
625
+ <div class="label">Theme</div>
626
+ <div class="look-grid">
627
+ ${lookIds().map((id) => html`<${LookCard} key=${id} id=${id} />`)}
628
+ </div>
629
+ ${store.state.me?.role === "owner" && html`<${LookTools} />`}
630
+ ${store.state.me?.role === "owner" && html`<${FontTools} />`}
631
+ <div class="label">Colors</div>
632
+ <div class="theme-grid">
633
+ ${desktop && html`<${ThemeCard} id="desktop" desktop=${true} />`}
634
+ ${[...themeIds(), ...folderPaletteIds()].map(
635
+ (id) => html`<${ThemeCard} key=${id} id=${id} />`,
636
+ )}
637
+ </div>
638
+ ${store.state.me?.role === "owner" && html`<${ColorsTools} />`}
639
+ ${store.state.me?.role === "owner" && html`<${DefaultTools} />`}
640
+ ${
641
+ !desktop &&
642
+ html`<p class="muted small">
643
+ Running Pi Pocket on an Omarchy desktop adds “Desktop”: the app follows the theme the desktop uses.
644
+ </p>`
645
+ }
646
+ <div class="label">Layout and motion</div>
647
+ <div class="sheet-list">
648
+ <${SettingRow}
649
+ title="Tiled windows"
650
+ detail="Wide screens: the sidebar and the session as Hyprland windows, with gaps and the active border"
651
+ >
652
+ <${Switch}
653
+ on=${p.tiling}
654
+ label="Tiled windows"
655
+ onChange=${() => setPrefs({ tiling: !p.tiling })}
656
+ />
657
+ <//>
658
+ <${SettingRow}
659
+ title="Peek tiles"
660
+ detail=${`Other sessions beside this one, live while on screen: working, waiting for you, done since you looked, and pinned. Also the tiles button in the top bar${touch ? "" : ", or Alt+P"}`}
661
+ >
662
+ <${Switch}
663
+ on=${p.peeks}
664
+ label="Peek tiles"
665
+ onChange=${() => setPrefs({ peeks: !p.peeks })}
666
+ />
667
+ <//>
668
+ ${
669
+ desktop?.wallpaper &&
670
+ html`<${SettingRow}
671
+ title="Desktop wallpaper"
672
+ detail="Your wallpaper in the gaps between windows, while following the desktop"
673
+ >
674
+ <${Switch}
675
+ on=${p.wallpaper}
676
+ disabled=${!p.tiling || p.theme !== "desktop"}
677
+ label="Desktop wallpaper"
678
+ onChange=${() => setPrefs({ wallpaper: !p.wallpaper })}
679
+ />
680
+ <//>`
681
+ }
682
+ <${SettingRow}
683
+ title="Sidebar"
684
+ detail="Folded, it shows numbered sessions like workspaces"
685
+ >
686
+ <div class="segmented">
687
+ ${[
688
+ ["open", "Open"],
689
+ ["rail", "Folded"],
690
+ ].map(
691
+ ([value, label]) =>
692
+ html`<button
693
+ class=${p.sidebar === value ? "on" : ""}
694
+ aria-pressed=${p.sidebar === value}
695
+ onClick=${() => setPrefs({ sidebar: value })}
696
+ >
697
+ ${label}
698
+ </button>`,
699
+ )}
700
+ </div>
701
+ <//>
702
+ <${SettingRow}
703
+ title="Motion"
704
+ detail="Auto follows your system's reduced-motion setting. Fluid adds springs, staggered entrances, and layout changes that morph"
705
+ >
706
+ <div class="segmented">
707
+ ${[
708
+ ["auto", "Auto"],
709
+ ["full", "Full"],
710
+ ["fluid", "Fluid"],
711
+ ["reduced", "Reduced"],
712
+ ].map(
713
+ ([value, label]) =>
714
+ html`<button
715
+ class=${p.motion === value ? "on" : ""}
716
+ aria-pressed=${p.motion === value}
717
+ onClick=${() => setPrefs({ motion: value })}
718
+ >
719
+ ${label}
720
+ </button>`,
721
+ )}
722
+ </div>
723
+ <//>
724
+ <${SettingRow}
725
+ title="Text size"
726
+ detail=${`The conversation's, in pixels: any from ${MIN_TEXT} to ${MAX_TEXT}`}
727
+ >
728
+ <${TextSize} size=${textSizeOf(p.text)} />
729
+ <//>
730
+ <${SettingRow}
731
+ title="Width"
732
+ detail="Wide screens: how much of the window the conversation and the message box take. Comfortable grows with the window"
733
+ >
734
+ <div class="segmented">
735
+ ${[
736
+ ["comfortable", "Comfortable"],
737
+ ["wide", "Wide"],
738
+ ["full", "Full"],
739
+ ].map(
740
+ ([value, label]) =>
741
+ html`<button
742
+ class=${(p.width ?? "comfortable") === value ? "on" : ""}
743
+ aria-pressed=${(p.width ?? "comfortable") === value}
744
+ onClick=${() => setPrefs({ width: value })}
745
+ >
746
+ ${label}
747
+ </button>`,
748
+ )}
749
+ </div>
750
+ <//>
751
+ <${SettingRow}
752
+ title="Density"
753
+ detail="Less room around messages, rows, and the message box, to fit more on a screen"
754
+ >
755
+ <div class="segmented">
756
+ ${[
757
+ ["comfortable", "Comfortable"],
758
+ ["compact", "Compact"],
759
+ ["tight", "Tight"],
760
+ ].map(
761
+ ([value, label]) =>
762
+ html`<button
763
+ class=${p.density === value ? "on" : ""}
764
+ aria-pressed=${p.density === value}
765
+ onClick=${(event) => chooseDensity(value, event)}
766
+ >
767
+ ${label}
768
+ </button>`,
769
+ )}
770
+ </div>
771
+ <//>
772
+ </div>
773
+ <div class="label">Conversation</div>
774
+ <div class="sheet-list">
775
+ <${SettingRow}
776
+ title="Show thinking"
777
+ detail=${`Shows Pi's current thought above the live line as it comes, and its finished thoughts. The chevron before the line folds the live one${touch ? "" : ". Also Alt+T"}`}
778
+ >
779
+ <${Switch}
780
+ on=${p.showThinking}
781
+ label="Show thinking"
782
+ onChange=${() => setPrefs({ showThinking: !p.showThinking })}
783
+ />
784
+ <//>
785
+ <${SettingRow}
786
+ title="Keep activity folded"
787
+ detail="Pi's thinking and tool calls stay on their one line until you open them, also while it works and in the newest turn"
788
+ >
789
+ <${Switch}
790
+ on=${p.foldActivity}
791
+ label="Keep activity folded"
792
+ onChange=${() => setPrefs({ foldActivity: !p.foldActivity })}
793
+ />
794
+ <//>
795
+ </div>
796
+ <div class="label">Status line</div>
797
+ <div class="sheet-list">
798
+ <${SettingRow}
799
+ title="Reply speed"
800
+ detail="Under the message box: how fast the last answer came, in tokens a second, and how long its first token took"
801
+ >
802
+ <${Switch}
803
+ on=${p.speed}
804
+ label="Reply speed"
805
+ onChange=${() => setPrefs({ speed: !p.speed })}
806
+ />
807
+ <//>
808
+ </div>
809
+ ${Object.keys(SLOTS).map((slot) => html`<${SlotLayout} key=${slot} slot=${slot} />`)}
810
+ ${
811
+ !touch &&
812
+ html`<button
813
+ class="button ghost wide sheet-footer"
814
+ onClick=${() => openSheet({ type: "shortcuts" })}
815
+ >
816
+ <${Icon} name="keyboard" size=${16} /> Keyboard shortcuts
817
+ </button>`
818
+ }
819
+ <//>`;
820
+ }