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.
- package/README.md +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- package/systemd/README.md +11 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
// Appearance: the theme (a folder of CSS the server lists, `themes.ts`), the colors (Omarchy palettes as CSS variables,
|
|
2
|
+
// a theme folder's own, or "Follow desktop": the Omarchy theme this machine runs), tiled windows, motion, text size,
|
|
3
|
+
// density, and the sidebar's shape. Saved per browser. index.html applies the last look before the first paint from
|
|
4
|
+
// `pocket.themeCache`, the theme's stylesheets included, so a reload never flashes another look.
|
|
5
|
+
import { THEMES } from "./themes.js";
|
|
6
|
+
import { onSafeModeEnd, safeMode } from "./safe-mode.js";
|
|
7
|
+
import { api, store } from "./store.js";
|
|
8
|
+
|
|
9
|
+
const KEY = "pocket.appearance";
|
|
10
|
+
const CACHE = "pocket.themeCache";
|
|
11
|
+
const DESKTOP_CACHE = "pocket.desktopTheme";
|
|
12
|
+
const PINNED = "pocket.pinned";
|
|
13
|
+
const FALLBACK = "tokyo-night";
|
|
14
|
+
|
|
15
|
+
/** The app's own look, which every theme builds on: it loads no stylesheet. */
|
|
16
|
+
export const BASE_LOOK = "pocket";
|
|
17
|
+
|
|
18
|
+
/** The conversation's text size in pixels: anything from MIN_TEXT to MAX_TEXT, in steps of STEP_TEXT. */
|
|
19
|
+
export const MIN_TEXT = 10;
|
|
20
|
+
export const MAX_TEXT = 32;
|
|
21
|
+
export const STEP_TEXT = 0.5;
|
|
22
|
+
|
|
23
|
+
/** A text size someone typed or saved, kept in range and on a step; the default for one that is not a number. */
|
|
24
|
+
export function textSizeOf(value) {
|
|
25
|
+
const size = Number(value);
|
|
26
|
+
|
|
27
|
+
if (value === null || value === "" || !Number.isFinite(size)) {
|
|
28
|
+
return 15;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Math.min(MAX_TEXT, Math.max(MIN_TEXT, Math.round(size / STEP_TEXT) * STEP_TEXT));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
/**
|
|
36
|
+
* The colors: "desktop" follows the Omarchy theme of the machine Pi Pocket runs on; otherwise a key of THEMES, or the
|
|
37
|
+
* id of a theme folder with a colors.toml. (Its name is from before themes were folders.)
|
|
38
|
+
*/
|
|
39
|
+
theme: "desktop",
|
|
40
|
+
/** The theme: a theme folder's id, or "pocket", the app's own look. */
|
|
41
|
+
look: BASE_LOOK,
|
|
42
|
+
/** Wide screens: the sidebar and the conversation as Hyprland windows, with gaps and borders. */
|
|
43
|
+
tiling: true,
|
|
44
|
+
/** The desktop's wallpaper in the gaps, when following the desktop. */
|
|
45
|
+
wallpaper: false,
|
|
46
|
+
/**
|
|
47
|
+
* "auto" follows the system's reduced-motion setting; "full" is the app's usual motion, "reduced" none, and "fluid"
|
|
48
|
+
* the most: springs, staggered entrances, and layout changes that morph (`motionLevel`).
|
|
49
|
+
*/
|
|
50
|
+
motion: "auto",
|
|
51
|
+
/** The conversation's text in pixels, MIN_TEXT to MAX_TEXT; the rest of the app keeps its own sizes. */
|
|
52
|
+
text: 15,
|
|
53
|
+
/** How much room the spacing takes: "comfortable", "compact", or "tight". Font sizes stay. */
|
|
54
|
+
density: "comfortable",
|
|
55
|
+
/**
|
|
56
|
+
* How wide the conversation and the message box are on a wide screen: "comfortable" (the theme's width, which grows
|
|
57
|
+
* with the pane), "wide" (most of the pane), or "full" (all of it).
|
|
58
|
+
*/
|
|
59
|
+
width: "comfortable",
|
|
60
|
+
/** The sidebar on wide screens: "open", or "rail" (icons and numbered sessions only). */
|
|
61
|
+
sidebar: "open",
|
|
62
|
+
/** The open sidebar's width, its borders, the rail, and the list's: a 254px list. */
|
|
63
|
+
sidebarWidth: 312,
|
|
64
|
+
/** How the session list groups: "priority" (by what needs you), "recent" (by day), or "folder". */
|
|
65
|
+
group: "recent",
|
|
66
|
+
/** Other sessions' live work as peek tiles beside the session (a strip on narrow screens). Off until turned on. */
|
|
67
|
+
peeks: false,
|
|
68
|
+
/** How fast the last answer came, in the status line under the message box. Off until turned on. */
|
|
69
|
+
speed: false,
|
|
70
|
+
/** Pi's current thought above the live line, and its finished thoughts open. Off until turned on. */
|
|
71
|
+
showThinking: false,
|
|
72
|
+
/**
|
|
73
|
+
* Pi's thinking and tool calls stay folded into their one line until opened, also while they run and in the newest
|
|
74
|
+
* turn; off, the newest turn's groups open as they come. A group opened or closed by hand keeps that choice, and a
|
|
75
|
+
* thought alone in its group still follows Show thinking. Off until turned on.
|
|
76
|
+
*/
|
|
77
|
+
foldActivity: false,
|
|
78
|
+
/** This browser's own layout of each slot (`slots.js`): `{ [slot]: { order: [ids], hidden: { [id]: boolean } } }`. */
|
|
79
|
+
layout: {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function readJson(key, fallback) {
|
|
83
|
+
try {
|
|
84
|
+
const value = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
85
|
+
|
|
86
|
+
return value ?? fallback;
|
|
87
|
+
} catch {
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reduceQuery = matchMedia("(prefers-reduced-motion: reduce)");
|
|
93
|
+
/** The system's light or dark, which a theme's pair of palettes follows. */
|
|
94
|
+
const lightQuery = matchMedia("(prefers-color-scheme: light)");
|
|
95
|
+
|
|
96
|
+
/** Set once this browser's saved look has the sidebar's width for the rail beside the list. */
|
|
97
|
+
const RAIL_KEY = "pocket.railBeside";
|
|
98
|
+
/** Set once this browser's saved look has the soft tiles' text size and sidebar. */
|
|
99
|
+
const SOFT_KEY = "pocket.softTiles";
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What this browser saved. The sidebar's usual width was 300 before the rail went beside the list, then 324, and is
|
|
103
|
+
* 312 with soft tiles. The text size was the whole app's, 12 to 16; it is now the conversation's, a step larger.
|
|
104
|
+
*/
|
|
105
|
+
function savedPrefs() {
|
|
106
|
+
const saved = readJson(KEY, {});
|
|
107
|
+
|
|
108
|
+
if (localStorage.getItem(SOFT_KEY) !== null) {
|
|
109
|
+
return saved;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Either usual width, the one from before the rail went beside the list too.
|
|
113
|
+
const usual =
|
|
114
|
+
saved.sidebarWidth === 324 ||
|
|
115
|
+
(saved.sidebarWidth === 300 && !localStorage.getItem(RAIL_KEY));
|
|
116
|
+
const next = {
|
|
117
|
+
...saved,
|
|
118
|
+
...(usual && { sidebarWidth: DEFAULTS.sidebarWidth }),
|
|
119
|
+
...(typeof saved.text === "number" && { text: saved.text + 1 }),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
localStorage.setItem(RAIL_KEY, "1");
|
|
123
|
+
localStorage.setItem(SOFT_KEY, "1");
|
|
124
|
+
localStorage.setItem(KEY, JSON.stringify(next));
|
|
125
|
+
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Appearance's Width, past Comfortable: the column's width and the message box's, set inline so they win over a theme's.
|
|
131
|
+
* Each grows with the pane (a percentage of it) and has a floor of today's column, so phones and narrow windows stay as
|
|
132
|
+
* they are.
|
|
133
|
+
*/
|
|
134
|
+
const WIDTHS = {
|
|
135
|
+
wide: {
|
|
136
|
+
"--transcript-width": "clamp(720px, 92%, 1600px)",
|
|
137
|
+
"--composer-width": "calc(clamp(720px, 92%, 1600px) + 48px)",
|
|
138
|
+
},
|
|
139
|
+
full: { "--transcript-width": "100%", "--composer-width": "100%" },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The open sidebar's width as it shows: the rail and its borders, and a list 204–464px wide. */
|
|
143
|
+
const sidebarWidth = (p) => `${Math.min(524, Math.max(264, p.sidebarWidth))}px`;
|
|
144
|
+
|
|
145
|
+
/** This browser's own choices: what it saved, over the defaults. */
|
|
146
|
+
let own = { ...DEFAULTS, ...savedPrefs() };
|
|
147
|
+
|
|
148
|
+
/** Which of the theme (`look`) and the colors (`theme`) this browser's person chose; the rest follow the owner's default. */
|
|
149
|
+
const CHOSEN = "pocket.themeChosen";
|
|
150
|
+
const chosen = readJson(CHOSEN, null) ?? {
|
|
151
|
+
// From before there were defaults: colors other than the desktop's were someone's choice.
|
|
152
|
+
theme: own.theme !== DEFAULTS.theme,
|
|
153
|
+
look: own.look !== DEFAULTS.look,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** The owner's default as the last `hello` said it: it stands until the next one, so a reload paints it from the start. */
|
|
157
|
+
const DEFAULT_CACHE = "pocket.themeDefault";
|
|
158
|
+
|
|
159
|
+
/** The owner's default theme and colors: the server's, or, before it said, the one it said last time. */
|
|
160
|
+
const ownerDefault = (state = store.state) =>
|
|
161
|
+
state.server ? (state.server.themeDefault ?? null) : readJson(DEFAULT_CACHE, null);
|
|
162
|
+
|
|
163
|
+
/** The look in force: this browser's own, with the owner's default theme and colors where its person chose none. */
|
|
164
|
+
function inForce() {
|
|
165
|
+
const fallback = ownerDefault();
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...own,
|
|
169
|
+
...(fallback && !chosen.look ? { look: fallback.look } : {}),
|
|
170
|
+
...(fallback && !chosen.theme ? { theme: fallback.colors } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
store.set({
|
|
175
|
+
appearance: inForce(),
|
|
176
|
+
desktopTheme: readJson(DESKTOP_CACHE, null),
|
|
177
|
+
pinned: readJson(PINNED, []),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const prefs = () => store.state.appearance;
|
|
181
|
+
|
|
182
|
+
/** The settings that move the windows: with Fluid motion, changing one morphs the layout (`morph`). */
|
|
183
|
+
const MORPHS = new Set(["sidebar", "width", "tiling"]);
|
|
184
|
+
let morphing = false;
|
|
185
|
+
let morphs = 0;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Make `change` a view transition in which the windows slide and resize into their new places (style.css,
|
|
189
|
+
* `layout-vt`), and say whether it did: only with Fluid motion, in a browser that has view transitions, on a page in
|
|
190
|
+
* view, with nothing over the windows. The change itself comes a frame later, once the old layout is captured.
|
|
191
|
+
*/
|
|
192
|
+
function morph(change) {
|
|
193
|
+
if (
|
|
194
|
+
morphing ||
|
|
195
|
+
motionLevel() !== "fluid" ||
|
|
196
|
+
typeof document.startViewTransition !== "function" ||
|
|
197
|
+
document.visibilityState !== "visible" ||
|
|
198
|
+
// The windows would draw over a sheet or the launcher while they move: with one open, change at once.
|
|
199
|
+
document.querySelector(".overlay, .launcher-overlay")
|
|
200
|
+
) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const root = document.documentElement;
|
|
205
|
+
// A second change while one runs skips the first; only the newest one takes the class off when it ends.
|
|
206
|
+
const mine = ++morphs;
|
|
207
|
+
|
|
208
|
+
root.classList.add("layout-vt");
|
|
209
|
+
const run = document.startViewTransition(async () => {
|
|
210
|
+
morphing = true;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
change();
|
|
214
|
+
} finally {
|
|
215
|
+
morphing = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The store renders in a microtask; let it finish before the new layout is captured.
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
settleTransition(run, () => mine === morphs && root.classList.remove("layout-vt"));
|
|
223
|
+
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function setPrefs(patch) {
|
|
228
|
+
const moved = Object.keys(patch).some((key) => MORPHS.has(key) && patch[key] !== prefs()[key]);
|
|
229
|
+
|
|
230
|
+
if (moved && morph(() => setPrefs(patch))) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
own = { ...own, ...patch };
|
|
235
|
+
|
|
236
|
+
// Choosing the theme or the colors makes them this browser's own: the owner's default no longer moves them.
|
|
237
|
+
for (const key of ["look", "theme"]) {
|
|
238
|
+
if (key in patch) {
|
|
239
|
+
chosen[key] = true;
|
|
240
|
+
localStorage.setItem(CHOSEN, JSON.stringify(chosen));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
localStorage.setItem(KEY, JSON.stringify(own));
|
|
245
|
+
store.set({ appearance: inForce() });
|
|
246
|
+
apply();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The owner's default for browsers that have not chosen (`hello`), or null. */
|
|
250
|
+
export const themeDefault = () => store.state.server?.themeDefault ?? null;
|
|
251
|
+
|
|
252
|
+
// ─── Pinned sessions (this browser) ──────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/** The pinned ids as a set, made again only when the list changes: rows ask for each session as they draw. */
|
|
255
|
+
let pinnedSet = { list: null, set: new Set() };
|
|
256
|
+
|
|
257
|
+
export function isPinned(id) {
|
|
258
|
+
const list = store.state.pinned;
|
|
259
|
+
|
|
260
|
+
if (pinnedSet.list !== list) {
|
|
261
|
+
pinnedSet = { list, set: new Set(list) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return pinnedSet.set.has(id);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function togglePin(id) {
|
|
268
|
+
const pinned = isPinned(id)
|
|
269
|
+
? store.state.pinned.filter((each) => each !== id)
|
|
270
|
+
: [...store.state.pinned, id];
|
|
271
|
+
|
|
272
|
+
localStorage.setItem(PINNED, JSON.stringify(pinned));
|
|
273
|
+
store.set({ pinned });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function movePin(id, last) {
|
|
277
|
+
if (!isPinned(id)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const others = store.state.pinned.filter((each) => each !== id);
|
|
282
|
+
const pinned = last ? [...others, id] : [id, ...others];
|
|
283
|
+
|
|
284
|
+
localStorage.setItem(PINNED, JSON.stringify(pinned));
|
|
285
|
+
store.set({ pinned });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Palettes ───────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** The palette a theme id means: a bundled theme, or the desktop's (falling back when there is none). */
|
|
291
|
+
export function paletteOf(id = prefs().theme) {
|
|
292
|
+
if (id === "desktop") {
|
|
293
|
+
const desktop = store.state.desktopTheme;
|
|
294
|
+
|
|
295
|
+
if (desktop?.colors) {
|
|
296
|
+
return {
|
|
297
|
+
id: "desktop",
|
|
298
|
+
name:
|
|
299
|
+
(Object.hasOwn(THEMES, desktop.name) && THEMES[desktop.name].name) ||
|
|
300
|
+
desktop.name,
|
|
301
|
+
colors: desktop.colors,
|
|
302
|
+
desktop: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { id: FALLBACK, ...THEMES[FALLBACK] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Own ids only: a folder named "constructor" is no palette of the app's.
|
|
310
|
+
if (Object.hasOwn(THEMES, id)) {
|
|
311
|
+
return { id, ...THEMES[id] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const folder = folderColors(id);
|
|
315
|
+
|
|
316
|
+
return folder ?? { id: FALLBACK, ...THEMES[FALLBACK] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A theme folder's palette, when the server lists one by that id that can be used. */
|
|
320
|
+
function folderColors(id) {
|
|
321
|
+
const [base, half] = id.split("@");
|
|
322
|
+
const folder = store.state.themeList?.find(
|
|
323
|
+
(each) => each.id === base && each.usable && each.colors,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (!folder) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A pair (colors.toml and colors-light.toml): the half asked for, or the one the system's light or dark asks for.
|
|
331
|
+
const light =
|
|
332
|
+
folder.colorsLight !== undefined &&
|
|
333
|
+
(half === "light" || (half === undefined && lightQuery.matches));
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
id,
|
|
337
|
+
name: folder.colorsLight && half ? `${folder.name} · ${half}` : folder.name,
|
|
338
|
+
colors: light ? folder.colorsLight : folder.colors,
|
|
339
|
+
folder: true,
|
|
340
|
+
pair: folder.colorsLight !== undefined && half === undefined,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether the palette `id` names is known here yet: one that comes with the app, the desktop's, or any once the server
|
|
346
|
+
* listed its theme folders. Until then, the palette index.html put on the page from the cache stays.
|
|
347
|
+
*/
|
|
348
|
+
const paletteKnown = (id) => id === "desktop" || Object.hasOwn(THEMES, id) || themesListed();
|
|
349
|
+
|
|
350
|
+
/** The server listed its theme folders (`hello`). */
|
|
351
|
+
const themesListed = () => Array.isArray(store.state.themeList);
|
|
352
|
+
|
|
353
|
+
// ─── Theme folders ──────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/** The theme folder this browser uses: the one it chose, if the server offers it; else the app's own look. */
|
|
356
|
+
function lookOf(id = prefs().look) {
|
|
357
|
+
if (safeMode() || id === BASE_LOOK) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
store.state.themeList?.find(
|
|
363
|
+
(each) => each.id === id && each.usable && each.css.length > 0,
|
|
364
|
+
) ?? null
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The stylesheets of the theme this tab shows, or null while the server has not listed them yet. */
|
|
369
|
+
function themeSheets() {
|
|
370
|
+
if (safeMode() || prefs().look === BASE_LOOK) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return themesListed() ? (lookOf()?.css ?? []) : null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Settles once the newest theme's stylesheets loaded (or failed to). */
|
|
378
|
+
let sheetsLoaded = Promise.resolve();
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Put a theme's stylesheets in the theme layer: the new ones load beside the old, which go once they did, so nothing
|
|
382
|
+
* shows the app without a theme between two. The element holding them is `#pocket-theme`, as index.html names the one
|
|
383
|
+
* it writes before the first paint.
|
|
384
|
+
*/
|
|
385
|
+
function loadSheets(urls) {
|
|
386
|
+
const key = JSON.stringify(urls);
|
|
387
|
+
const current = document.getElementById("pocket-theme");
|
|
388
|
+
|
|
389
|
+
if ((current?.dataset.urls ?? "[]") === key) {
|
|
390
|
+
return sheetsLoaded;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const next = document.createElement("style");
|
|
394
|
+
|
|
395
|
+
next.dataset.urls = key;
|
|
396
|
+
next.textContent = urls.map((url) => `@import url("${url}") layer(theme);`).join("\n");
|
|
397
|
+
current?.removeAttribute("id");
|
|
398
|
+
next.id = "pocket-theme";
|
|
399
|
+
|
|
400
|
+
const old = () => {
|
|
401
|
+
for (const each of document.querySelectorAll("style[data-urls]")) {
|
|
402
|
+
if (each !== document.getElementById("pocket-theme")) {
|
|
403
|
+
each.remove();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (urls.length === 0) {
|
|
409
|
+
document.head.append(next);
|
|
410
|
+
old();
|
|
411
|
+
requestAnimationFrame(() => document.dispatchEvent(new Event("pocket:theme")));
|
|
412
|
+
sheetsLoaded = Promise.resolve();
|
|
413
|
+
|
|
414
|
+
return sheetsLoaded;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
sheetsLoaded = new Promise((resolve) => {
|
|
418
|
+
next.addEventListener("load", resolve, { once: true });
|
|
419
|
+
next.addEventListener("error", resolve, { once: true });
|
|
420
|
+
// A server that does not answer: show what there is.
|
|
421
|
+
setTimeout(resolve, 4000);
|
|
422
|
+
}).then(() => {
|
|
423
|
+
old();
|
|
424
|
+
// What reads where things are laid out (`useSide`) looks again.
|
|
425
|
+
document.dispatchEvent(new Event("pocket:theme"));
|
|
426
|
+
});
|
|
427
|
+
document.head.append(next);
|
|
428
|
+
|
|
429
|
+
return sheetsLoaded;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Switch to the theme folder `id` (or "pocket"), with the reveal; a theme with colors of its own brings them too. */
|
|
433
|
+
export function chooseLook(id, event) {
|
|
434
|
+
const at =
|
|
435
|
+
event && "clientX" in event && (event.clientX || event.clientY)
|
|
436
|
+
? { x: event.clientX, y: event.clientY }
|
|
437
|
+
: undefined;
|
|
438
|
+
|
|
439
|
+
if (id === prefs().look) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const look = store.state.themeList?.find((each) => each.id === id);
|
|
444
|
+
|
|
445
|
+
transition(() => {
|
|
446
|
+
setPrefs({ look: id, ...(look?.colors ? { theme: id } : {}) });
|
|
447
|
+
|
|
448
|
+
return sheetsLoaded;
|
|
449
|
+
}, at);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The look in use, in words: the theme's name, and the colors' when they are not the theme's own ("Lantern", "Pocket ·
|
|
454
|
+
* Gruvbox"). For the menu's Appearance row and the home screen.
|
|
455
|
+
*/
|
|
456
|
+
export function lookLabel() {
|
|
457
|
+
const look = lookOf();
|
|
458
|
+
const palette = paletteOf();
|
|
459
|
+
const colors = palette.desktop ? `${palette.name} (desktop)` : palette.name;
|
|
460
|
+
|
|
461
|
+
return look && prefs().theme.split("@")[0] === look.id
|
|
462
|
+
? look.name
|
|
463
|
+
: `${look?.name ?? "Pocket"} · ${colors}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Theme folders that change the look, in the order the server lists them: the app's own first. */
|
|
467
|
+
export function lookIds() {
|
|
468
|
+
return [
|
|
469
|
+
BASE_LOOK,
|
|
470
|
+
...(store.state.themeList ?? [])
|
|
471
|
+
.filter(
|
|
472
|
+
(each) =>
|
|
473
|
+
each.id !== BASE_LOOK &&
|
|
474
|
+
(each.css.length > 0 || !each.usable) &&
|
|
475
|
+
!colorsOnly(each),
|
|
476
|
+
)
|
|
477
|
+
.map((each) => each.id),
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A theme folder with colors and no CSS: it shows under Colors only. */
|
|
482
|
+
export const colorsOnly = (each) =>
|
|
483
|
+
each.css.length === 0 && each.colors !== undefined && each.usable;
|
|
484
|
+
|
|
485
|
+
/** Theme folders' palettes, after the app's own: their ids. */
|
|
486
|
+
export function folderPaletteIds() {
|
|
487
|
+
return (store.state.themeList ?? [])
|
|
488
|
+
.filter((each) => each.usable && each.colors && each.id !== BASE_LOOK)
|
|
489
|
+
.flatMap((each) =>
|
|
490
|
+
each.colorsLight ? [each.id, `${each.id}@dark`, `${each.id}@light`] : [each.id],
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** CSS variables for a palette. Everything else in style.css mixes from these. */
|
|
495
|
+
export function themeVars(palette) {
|
|
496
|
+
const c = palette.colors;
|
|
497
|
+
const pick = (...keys) =>
|
|
498
|
+
keys
|
|
499
|
+
.map((key) => c[key])
|
|
500
|
+
.find((value) => typeof value === "string" && /^#[0-9a-f]{3,8}$/i.test(value));
|
|
501
|
+
const bg = pick("background") ?? "#1a1b26";
|
|
502
|
+
const fg = pick("foreground") ?? "#a9b1d6";
|
|
503
|
+
const accent = pick("accent", "blue") ?? fg;
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
"--o-bg": bg,
|
|
507
|
+
"--o-bg-dark": pick("dark_background") ?? bg,
|
|
508
|
+
"--o-bg-darker": pick("darker_background", "dark_background") ?? bg,
|
|
509
|
+
"--o-bg-light": pick("lighter_background") ?? bg,
|
|
510
|
+
"--o-fg": fg,
|
|
511
|
+
"--o-fg-dim": pick("dark_foreground", "muted") ?? fg,
|
|
512
|
+
"--o-fg-light": pick("light_foreground") ?? fg,
|
|
513
|
+
"--o-fg-bright": pick("bright_foreground") ?? fg,
|
|
514
|
+
"--o-accent": accent,
|
|
515
|
+
"--o-selection": pick("selection", "lighter_background") ?? accent,
|
|
516
|
+
"--o-red": pick("red") ?? "#f7768e",
|
|
517
|
+
"--o-yellow": pick("yellow", "bright_yellow") ?? "#e0af68",
|
|
518
|
+
"--o-orange": pick("orange", "bright_yellow", "yellow") ?? "#ff9e64",
|
|
519
|
+
"--o-green": pick("green") ?? "#9ece6a",
|
|
520
|
+
"--o-cyan": pick("cyan") ?? "#7dcfff",
|
|
521
|
+
"--o-blue": pick("blue", "accent") ?? accent,
|
|
522
|
+
"--o-magenta": pick("magenta") ?? "#bb9af7",
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Variables as an inline style. */
|
|
527
|
+
export const varsStyle = (vars) =>
|
|
528
|
+
Object.entries(vars)
|
|
529
|
+
.map(([name, value]) => `${name}:${value}`)
|
|
530
|
+
.join(";");
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Put a palette's variables in the palette layer (index.html declares the layers): under the theme, which may set them
|
|
534
|
+
* again, and over the app's defaults. Inline they would beat the theme. Variables an older version of this page left
|
|
535
|
+
* inline go.
|
|
536
|
+
*/
|
|
537
|
+
function setPalette(vars) {
|
|
538
|
+
const root = document.documentElement;
|
|
539
|
+
let style = document.getElementById("pocket-palette");
|
|
540
|
+
|
|
541
|
+
if (style === null) {
|
|
542
|
+
style = document.createElement("style");
|
|
543
|
+
style.id = "pocket-palette";
|
|
544
|
+
document.head.append(style);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const text = `@layer palette{:root{${varsStyle(vars)}}}`;
|
|
548
|
+
|
|
549
|
+
if (style.textContent !== text) {
|
|
550
|
+
style.textContent = text;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const name of Object.keys(vars)) {
|
|
554
|
+
root.style.removeProperty(name);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export const modeOf = (palette) => (palette.colors.mode === "light" ? "light" : "dark");
|
|
559
|
+
|
|
560
|
+
/** The motion on the page, from Appearance's choice: "reduced", "full", or "fluid" (chosen only, never by Auto). */
|
|
561
|
+
export function motionLevel() {
|
|
562
|
+
const motion = prefs().motion;
|
|
563
|
+
|
|
564
|
+
if (motion === "reduced" || (motion === "auto" && reduceQuery.matches)) {
|
|
565
|
+
return "reduced";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return motion === "fluid" ? "fluid" : "full";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const reducedMotion = () => motionLevel() === "reduced";
|
|
572
|
+
|
|
573
|
+
/** Put the current look on the page: variables, mode, and the switches style.css reads from data attributes. */
|
|
574
|
+
export function apply() {
|
|
575
|
+
const root = document.documentElement;
|
|
576
|
+
const p = prefs();
|
|
577
|
+
const cached = readJson(CACHE, {});
|
|
578
|
+
// Colors of a theme folder before the server listed them: keep what index.html put on the page from the cache.
|
|
579
|
+
const palette = paletteKnown(p.theme) ? paletteOf() : null;
|
|
580
|
+
const vars = palette ? themeVars(palette) : (cached.palette ?? themeVars(paletteOf(FALLBACK)));
|
|
581
|
+
|
|
582
|
+
if (palette) {
|
|
583
|
+
setPalette(vars);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const sheets = themeSheets();
|
|
587
|
+
|
|
588
|
+
if (sheets !== null) {
|
|
589
|
+
loadSheets(sheets);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const desktop = store.state.desktopTheme;
|
|
593
|
+
// The desktop's wallpaper is the person's choice, so it stays inline and wins; without it, the app's default (none)
|
|
594
|
+
// or the theme's own shows.
|
|
595
|
+
const wallpaper =
|
|
596
|
+
p.theme === "desktop" && p.wallpaper && p.tiling && desktop?.wallpaper
|
|
597
|
+
? `url("/api/theme/wallpaper?v=${encodeURIComponent(desktop.stamp)}")`
|
|
598
|
+
: null;
|
|
599
|
+
// Width other than Comfortable is the person's choice, inline over the theme's; Comfortable leaves it to the theme.
|
|
600
|
+
const width = WIDTHS[p.width];
|
|
601
|
+
const inline = {
|
|
602
|
+
"--text-size": `${textSizeOf(p.text)}px`,
|
|
603
|
+
"--sidebar-w": sidebarWidth(p),
|
|
604
|
+
...(wallpaper === null ? {} : { "--wallpaper": wallpaper }),
|
|
605
|
+
...(width ?? {}),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
if (wallpaper === null) {
|
|
609
|
+
root.style.removeProperty("--wallpaper");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!width) {
|
|
613
|
+
root.style.removeProperty("--transcript-width");
|
|
614
|
+
root.style.removeProperty("--composer-width");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
for (const [name, value] of Object.entries(inline)) {
|
|
618
|
+
root.style.setProperty(name, value);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const attrs = {
|
|
622
|
+
mode: palette ? modeOf(palette) : (cached.attrs?.mode ?? "dark"),
|
|
623
|
+
// Before the list, the theme index.html painted from the cache stays named, as its stylesheets stay.
|
|
624
|
+
theme: sheets === null ? (cached.attrs?.theme ?? BASE_LOOK) : (lookOf()?.id ?? BASE_LOOK),
|
|
625
|
+
colors: palette?.id ?? cached.attrs?.colors ?? p.theme,
|
|
626
|
+
tiling: p.tiling ? "on" : "off",
|
|
627
|
+
motion: motionLevel(),
|
|
628
|
+
rail: p.sidebar === "rail" ? "on" : "off",
|
|
629
|
+
density: p.density,
|
|
630
|
+
width: WIDTHS[p.width] ? p.width : "comfortable",
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
Object.assign(root.dataset, attrs);
|
|
634
|
+
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", vars["--o-bg"]);
|
|
635
|
+
document.querySelector('meta[name="color-scheme"]')?.setAttribute("content", attrs.mode);
|
|
636
|
+
// In safe mode, or before the list, the theme's stylesheets stay as the cache has them, for the next load.
|
|
637
|
+
const css = sheets === null || safeMode() ? (cached.css ?? []) : sheets;
|
|
638
|
+
const fonts = sheets === null || safeMode() ? (cached.fonts ?? []) : (lookOf()?.fonts ?? []);
|
|
639
|
+
// A pair of palettes: both halves, so the first paint takes the one the system's light or dark asks for.
|
|
640
|
+
const pair = palette?.pair
|
|
641
|
+
? Object.fromEntries(
|
|
642
|
+
["dark", "light"].map((half) => {
|
|
643
|
+
const each = themeVars(paletteOf(`${palette.id}@${half}`));
|
|
644
|
+
|
|
645
|
+
return [half, { palette: each, color: each["--o-bg"] }];
|
|
646
|
+
}),
|
|
647
|
+
)
|
|
648
|
+
: palette
|
|
649
|
+
? undefined
|
|
650
|
+
: cached.pair;
|
|
651
|
+
|
|
652
|
+
localStorage.setItem(
|
|
653
|
+
CACHE,
|
|
654
|
+
JSON.stringify({
|
|
655
|
+
palette: vars,
|
|
656
|
+
vars: inline,
|
|
657
|
+
attrs,
|
|
658
|
+
color: vars["--o-bg"],
|
|
659
|
+
css,
|
|
660
|
+
fonts,
|
|
661
|
+
...(pair ? { pair } : {}),
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Change the look with a circle that grows from `at` (a click's place) over the old one, as a view transition. Without
|
|
668
|
+
* view transitions, or with reduced motion, it changes at once.
|
|
669
|
+
*/
|
|
670
|
+
let transitions = 0;
|
|
671
|
+
|
|
672
|
+
function transition(change, at) {
|
|
673
|
+
const root = document.documentElement;
|
|
674
|
+
|
|
675
|
+
if (
|
|
676
|
+
typeof document.startViewTransition !== "function" ||
|
|
677
|
+
reducedMotion() ||
|
|
678
|
+
document.visibilityState !== "visible"
|
|
679
|
+
) {
|
|
680
|
+
change();
|
|
681
|
+
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const x = at?.x ?? innerWidth / 2;
|
|
686
|
+
const y = at?.y ?? innerHeight / 2;
|
|
687
|
+
|
|
688
|
+
root.style.setProperty("--vt-x", `${x}px`);
|
|
689
|
+
root.style.setProperty("--vt-y", `${y}px`);
|
|
690
|
+
root.style.setProperty(
|
|
691
|
+
"--vt-r",
|
|
692
|
+
`${Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))}px`,
|
|
693
|
+
);
|
|
694
|
+
root.classList.add("theme-vt");
|
|
695
|
+
// A second change while one runs skips the first; only the newest one takes the class off when it ends.
|
|
696
|
+
const mine = ++transitions;
|
|
697
|
+
const run = document.startViewTransition(async () => {
|
|
698
|
+
await change();
|
|
699
|
+
// The store renders in a microtask; let it finish before the new look is captured.
|
|
700
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
settleTransition(run, () => mine === transitions && root.classList.remove("theme-vt"));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A rapid change can skip a transition: handle each promise, and clean up even when it was skipped. */
|
|
707
|
+
function settleTransition(run, clean) {
|
|
708
|
+
run.ready.catch(() => {});
|
|
709
|
+
run.updateCallbackDone.catch(() => {});
|
|
710
|
+
run.finished.then(clean, clean);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Switch theme, with the reveal starting from where the person clicked. */
|
|
714
|
+
export function chooseTheme(id, event) {
|
|
715
|
+
const at =
|
|
716
|
+
event && "clientX" in event && (event.clientX || event.clientY)
|
|
717
|
+
? { x: event.clientX, y: event.clientY }
|
|
718
|
+
: undefined;
|
|
719
|
+
|
|
720
|
+
if (id === prefs().theme) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
transition(() => setPrefs({ theme: id }), at);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Look at a theme without choosing it, as the launcher does while arrowing through themes. null goes back. */
|
|
728
|
+
let previewing = null;
|
|
729
|
+
|
|
730
|
+
export function preview(id) {
|
|
731
|
+
if (id === previewing) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
previewing = id;
|
|
736
|
+
const palette = id === null ? paletteOf() : paletteOf(id);
|
|
737
|
+
|
|
738
|
+
setPalette(themeVars(palette));
|
|
739
|
+
document.documentElement.dataset.mode = modeOf(palette);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ─── Follow desktop ─────────────────────────────────────────────────────────────
|
|
743
|
+
|
|
744
|
+
let checking = false;
|
|
745
|
+
let started = false;
|
|
746
|
+
|
|
747
|
+
/** Ask the server for the desktop's theme; animate to it when it changed since the last look. */
|
|
748
|
+
async function checkDesktop({ animate = true } = {}) {
|
|
749
|
+
if (checking || store.state.me === null) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
checking = true;
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
const { theme } = await api("theme");
|
|
757
|
+
const before = store.state.desktopTheme;
|
|
758
|
+
|
|
759
|
+
if ((theme?.stamp ?? null) === (before?.stamp ?? null)) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
localStorage.setItem(DESKTOP_CACHE, JSON.stringify(theme));
|
|
764
|
+
|
|
765
|
+
const change = () => {
|
|
766
|
+
store.set({ desktopTheme: theme });
|
|
767
|
+
apply();
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
if (animate && before !== null && prefs().theme === "desktop") {
|
|
771
|
+
transition(change);
|
|
772
|
+
} else {
|
|
773
|
+
change();
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
// Signed out, or an older server without the route: keep what we have.
|
|
777
|
+
} finally {
|
|
778
|
+
checking = false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const DESKTOP_EVERY_MS = 6000;
|
|
783
|
+
|
|
784
|
+
/** Start following the desktop's theme: now, whenever the page comes back, and every few seconds while it shows. */
|
|
785
|
+
export function startTheme() {
|
|
786
|
+
apply();
|
|
787
|
+
|
|
788
|
+
if (started) {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
started = true;
|
|
793
|
+
reduceQuery.addEventListener?.("change", apply);
|
|
794
|
+
lightQuery.addEventListener?.("change", apply);
|
|
795
|
+
onSafeModeEnd(apply);
|
|
796
|
+
// The server's list of theme folders came or changed (a theme's new version, or one that went), or the owner's
|
|
797
|
+
// default did.
|
|
798
|
+
let list = store.state.themeList;
|
|
799
|
+
let fallback = JSON.stringify(ownerDefault());
|
|
800
|
+
|
|
801
|
+
store.subscribe((state) => {
|
|
802
|
+
const next = JSON.stringify(ownerDefault(state));
|
|
803
|
+
|
|
804
|
+
if (state.server && localStorage.getItem(DEFAULT_CACHE) !== next) {
|
|
805
|
+
localStorage.setItem(DEFAULT_CACHE, next);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (next !== fallback) {
|
|
809
|
+
fallback = next;
|
|
810
|
+
store.set({ appearance: inForce() });
|
|
811
|
+
apply();
|
|
812
|
+
} else if (state.themeList !== list) {
|
|
813
|
+
list = state.themeList;
|
|
814
|
+
apply();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
const look = () =>
|
|
818
|
+
document.visibilityState === "visible" && prefs().theme === "desktop" && checkDesktop();
|
|
819
|
+
|
|
820
|
+
document.addEventListener("visibilitychange", look);
|
|
821
|
+
addEventListener("focus", look);
|
|
822
|
+
// A server without an Omarchy desktop answers null: no need to keep asking every few seconds.
|
|
823
|
+
setInterval(() => store.state.desktopTheme !== null && look(), DESKTOP_EVERY_MS);
|
|
824
|
+
// Once signed in: the route needs a person.
|
|
825
|
+
const stop = store.subscribe((state) => {
|
|
826
|
+
if (!state.me) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
stop();
|
|
831
|
+
checkDesktop({ animate: false });
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Theme ids in the order the picker shows them: dark themes, then light ones. */
|
|
836
|
+
export function themeIds() {
|
|
837
|
+
const ids = Object.keys(THEMES);
|
|
838
|
+
|
|
839
|
+
return [
|
|
840
|
+
...ids.filter((id) => THEMES[id].colors.mode !== "light"),
|
|
841
|
+
...ids.filter((id) => THEMES[id].colors.mode === "light"),
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
export { THEMES };
|