@legendapp/create-spark 0.0.0-stage → 0.0.1-next.6

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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/dist/index.js +174697 -0
  4. package/dist/npm-bin/npm +6 -0
  5. package/dist/npm-bin/npm.cmd +1 -0
  6. package/dist/release.json +42 -0
  7. package/package.json +30 -4
  8. package/templates/blank-typescript/.claude/skills/spark/SKILL.md +9 -0
  9. package/templates/blank-typescript/AGENTS.md +4 -0
  10. package/templates/blank-typescript/App.tsx +23 -0
  11. package/templates/blank-typescript/CLAUDE.md +4 -0
  12. package/templates/blank-typescript/README.md +15 -0
  13. package/templates/blank-typescript/app.json +11 -0
  14. package/templates/blank-typescript/assets/icon.png +0 -0
  15. package/templates/blank-typescript/desktop.config.json +21 -0
  16. package/templates/blank-typescript/gitignore +8 -0
  17. package/templates/blank-typescript/index.ts +5 -0
  18. package/templates/blank-typescript/metro.config.js +3 -0
  19. package/templates/blank-typescript/package.json +101 -0
  20. package/templates/blank-typescript/tsconfig.json +10 -0
  21. package/templates/diff-lite/App.tsx +34 -0
  22. package/templates/diff-lite/Git.macos.tsx +13 -0
  23. package/templates/diff-lite/Git.tsx +1 -0
  24. package/templates/diff-lite/compare.ts +12 -0
  25. package/templates/document-editor/App.tsx +34 -0
  26. package/templates/document-editor/Integration.macos.tsx +59 -0
  27. package/templates/document-editor/Integration.tsx +13 -0
  28. package/templates/document-editor/Integration.web.tsx +14 -0
  29. package/templates/document-editor/Integration.windows.tsx +59 -0
  30. package/templates/document-editor/document.ts +58 -0
  31. package/templates/document-editor/io.desktop.ts +27 -0
  32. package/templates/document-editor/io.macos.ts +7 -0
  33. package/templates/document-editor/io.ts +31 -0
  34. package/templates/document-editor/io.web.ts +21 -0
  35. package/templates/document-editor/io.windows.ts +1 -0
  36. package/templates/document-editor/sessions.ts +8 -0
  37. package/templates/document-editor/useDocument.ts +6 -0
  38. package/templates/example-shared/Lifecycle.desktop.tsx +25 -0
  39. package/templates/example-shared/Lifecycle.macos.tsx +1 -0
  40. package/templates/example-shared/Lifecycle.tsx +14 -0
  41. package/templates/example-shared/Lifecycle.web.tsx +20 -0
  42. package/templates/example-shared/Lifecycle.windows.tsx +1 -0
  43. package/templates/example-shared/OpenFiles.desktop.tsx +23 -0
  44. package/templates/example-shared/OpenFiles.macos.tsx +1 -0
  45. package/templates/example-shared/OpenFiles.tsx +2 -0
  46. package/templates/example-shared/OpenFiles.windows.tsx +1 -0
  47. package/templates/example-shared/document.ts +58 -0
  48. package/templates/example-shared/io.desktop.ts +27 -0
  49. package/templates/example-shared/io.macos.ts +7 -0
  50. package/templates/example-shared/io.ts +31 -0
  51. package/templates/example-shared/io.web.ts +21 -0
  52. package/templates/example-shared/io.windows.ts +1 -0
  53. package/templates/example-shared/lifecycle-types.ts +10 -0
  54. package/templates/example-shared/lifetime.ts +23 -0
  55. package/templates/example-shared/records.ts +51 -0
  56. package/templates/example-shared/storage.ts +6 -0
  57. package/templates/example-shared/useModel.ts +6 -0
  58. package/templates/music-lite/Activation.desktop.tsx +4 -0
  59. package/templates/music-lite/Activation.macos.tsx +1 -0
  60. package/templates/music-lite/Activation.tsx +1 -0
  61. package/templates/music-lite/Activation.windows.tsx +1 -0
  62. package/templates/music-lite/App.tsx +38 -0
  63. package/templates/music-lite/assets.desktop.ts +7 -0
  64. package/templates/music-lite/assets.macos.ts +1 -0
  65. package/templates/music-lite/assets.ts +18 -0
  66. package/templates/music-lite/assets.web.ts +37 -0
  67. package/templates/music-lite/assets.windows.ts +1 -0
  68. package/templates/music-lite/model.ts +91 -0
  69. package/templates/music-lite/store.ts +10 -0
  70. package/templates/notes-lite/Activation.desktop.tsx +5 -0
  71. package/templates/notes-lite/Activation.macos.tsx +1 -0
  72. package/templates/notes-lite/Activation.tsx +1 -0
  73. package/templates/notes-lite/Activation.windows.tsx +1 -0
  74. package/templates/notes-lite/App.tsx +92 -0
  75. package/templates/notes-lite/Desktop.desktop.ts +47 -0
  76. package/templates/notes-lite/Desktop.macos.ts +1 -0
  77. package/templates/notes-lite/Desktop.ts +7 -0
  78. package/templates/notes-lite/Desktop.windows.ts +1 -0
  79. package/templates/notes-lite/Theme.tsx +17 -0
  80. package/templates/notes-lite/model.ts +61 -0
  81. package/templates/notes-lite/search.ts +3 -0
  82. package/templates/notes-lite/session.ts +15 -0
  83. package/templates/notes-lite/store.ts +4 -0
  84. package/templates/notes-lite/window-session.ts +63 -0
  85. package/templates/universal/.claude/skills/spark/SKILL.md +9 -0
  86. package/templates/universal/AGENTS.md +4 -0
  87. package/templates/universal/App.tsx +57 -0
  88. package/templates/universal/CLAUDE.md +4 -0
  89. package/templates/universal/README.md +15 -0
  90. package/templates/universal/app.json +15 -0
  91. package/templates/universal/assets/icon.png +0 -0
  92. package/templates/universal/desktop.config.json +64 -0
  93. package/templates/universal/gitignore +10 -0
  94. package/templates/universal/global.css +31 -0
  95. package/templates/universal/index.ts +4 -0
  96. package/templates/universal/metro.config.js +7 -0
  97. package/templates/universal/package.json +115 -0
  98. package/templates/universal/react-native.config.js +2 -0
  99. package/templates/universal/tsconfig.json +10 -0
  100. package/templates/universal/uniwind-types.d.ts +10 -0
  101. package/templates/windows/.claude/skills/spark/SKILL.md +9 -0
  102. package/templates/windows/AGENTS.md +4 -0
  103. package/templates/windows/App.tsx +16 -0
  104. package/templates/windows/CLAUDE.md +4 -0
  105. package/templates/windows/README.md +15 -0
  106. package/templates/windows/app.json +11 -0
  107. package/templates/windows/assets/icon.png +0 -0
  108. package/templates/windows/desktop.config.json +10 -0
  109. package/templates/windows/gitignore +8 -0
  110. package/templates/windows/index.ts +3 -0
  111. package/templates/windows/metro.config.js +3 -0
  112. package/templates/windows/package.json +102 -0
  113. package/templates/windows/react-native.config.js +2 -0
  114. package/templates/windows/tsconfig.json +10 -0
@@ -0,0 +1,18 @@
1
+ import * as Picker from "expo-document-picker";
2
+ import * as Files from "expo-file-system/legacy";
3
+ import type { Track } from "./model";
4
+ export async function importTracks(): Promise<Track[]> {
5
+ const result = await Picker.getDocumentAsync({ type: "audio/*", multiple: true, copyToCacheDirectory: true });
6
+ if (result.canceled) return [];
7
+ if (!Files.documentDirectory) throw new Error("App document storage is unavailable");
8
+ const directory = `${Files.documentDirectory}music/`;
9
+ await Files.makeDirectoryAsync(directory, { intermediates: true });
10
+ const tracks: Track[] = [];
11
+ for (const asset of result.assets) {
12
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
13
+ const uri = `${directory}${id}-${asset.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
14
+ await Files.copyAsync({ from: asset.uri, to: uri }); tracks.push({ id, name: asset.name, uri });
15
+ }
16
+ return tracks;
17
+ }
18
+ export const resolveTrack = async (track: Track) => ({ uri: track.uri, release() {} });
@@ -0,0 +1,37 @@
1
+ import { projectId } from "./shared/identity";
2
+ import type { Track } from "./model";
3
+ async function database() {
4
+ return new Promise<IDBDatabase>((resolve, reject) => {
5
+ const request = indexedDB.open(`spark-music-${projectId}`, 1);
6
+ request.onupgradeneeded = () => request.result.createObjectStore("audio");
7
+ request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);
8
+ });
9
+ }
10
+ async function store(id: string, file: File) {
11
+ const db = await database();
12
+ try { await new Promise<void>((resolve, reject) => {
13
+ const transaction = db.transaction("audio", "readwrite"); transaction.objectStore("audio").put(file, id);
14
+ transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error ?? new Error("Audio import was aborted"));
15
+ }); } finally { db.close(); }
16
+ }
17
+ export async function importTracks(): Promise<Track[]> {
18
+ const files = await new Promise<File[]>(resolve => {
19
+ const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.accept = "audio/*";
20
+ input.oncancel = () => resolve([]); input.onchange = () => resolve(Array.from(input.files ?? [])); input.click();
21
+ });
22
+ const tracks: Track[] = [];
23
+ for (const file of files) {
24
+ const id = crypto.randomUUID(); await store(id, file); tracks.push({ id, uri: `library:${id}`, name: file.name });
25
+ }
26
+ return tracks;
27
+ }
28
+ export async function resolveTrack(track: Track) {
29
+ const db = await database();
30
+ try {
31
+ const blob = await new Promise<Blob>((resolve, reject) => {
32
+ const request = db.transaction("audio").objectStore("audio").get(track.id);
33
+ request.onsuccess = () => request.result instanceof Blob ? resolve(request.result) : reject(new Error("Audio is missing from browser storage. Import the file again.")); request.onerror = () => reject(request.error);
34
+ });
35
+ const uri = URL.createObjectURL(blob); return { uri, release() { URL.revokeObjectURL(uri); } };
36
+ } finally { db.close(); }
37
+ }
@@ -0,0 +1 @@
1
+ export * from "./assets.desktop";
@@ -0,0 +1,91 @@
1
+ import type { AudioPlayer, AudioStatus } from "@legendapp/spark/audio";
2
+ export type Track = { id: string; name: string; uri: string };
3
+ export type Library = { version: 1; tracks: Track[]; currentId: string | null; position: number };
4
+ export function decodeLibrary(value: unknown): Library {
5
+ const data = value as Library;
6
+ if (data?.version !== 1 || !Array.isArray(data.tracks) || !data.tracks.every(track => track && typeof track.id === "string" && typeof track.name === "string" && typeof track.uri === "string") || !Number.isFinite(data.position) || data.position < 0 || (data.currentId !== null && typeof data.currentId !== "string")) throw new Error("Unsupported music library format");
7
+ return data;
8
+ }
9
+ const stopped: AudioStatus = { playing: false, currentTime: 0, duration: 0, didJustFinish: false, error: null, volume: 1 };
10
+ export class MusicModel {
11
+ private state = { version: 1 as const, tracks: [] as Track[], currentId: null as string | null, position: 0, status: stopped, ready: false, busy: false, dirty: false, error: null as string | null };
12
+ private listeners = new Set<() => void>();
13
+ private player?: AudioPlayer;
14
+ private release?: () => void;
15
+ private loading?: Promise<void>;
16
+ private polling = false;
17
+ private lastSave = 0;
18
+ private finishedPlayback = false;
19
+ private revision = 0;
20
+ private activeOperation?: Promise<void>;
21
+ private disposing?: Promise<void>;
22
+ constructor(private records: { load(): Promise<{ value: Library | null; recovered: boolean }>; save(value: Library): Promise<void> }, private createPlayer: (track: Track) => Promise<{ player: AudioPlayer; release(): void }>) {}
23
+ getSnapshot = () => this.state;
24
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
25
+ private update(patch: Partial<typeof this.state>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
26
+ private changed(patch: Partial<typeof this.state>) { this.revision++; this.update({ ...patch, dirty: true }); }
27
+ load = () => this.loading ??= (async () => {
28
+ try { const saved = await this.records.load(); this.update({ ...saved.value, ready: true, error: saved.recovered ? "Recovered the previous saved queue." : null }); }
29
+ catch (error) { this.update({ error: String(error) }); }
30
+ })();
31
+ add = async (tracks: Track[]) => { if (!this.state.ready) return; const ids = new Set(this.state.tracks.map(track => track.id)); this.changed({ tracks: [...this.state.tracks, ...tracks.filter(track => !ids.has(track.id) && !!ids.add(track.id))] }); await this.flush(); };
32
+ private async operation(action: () => Promise<void>) {
33
+ if (this.disposing) await this.disposing;
34
+ if (this.state.busy || !this.state.ready) return;
35
+ this.update({ busy: true, error: null });
36
+ const pending = action(); this.activeOperation = pending;
37
+ try { await pending; } catch (error) { this.update({ error: String(error) }); }
38
+ finally { this.activeOperation = undefined; this.update({ busy: false }); }
39
+ }
40
+ play = (id = this.state.currentId ?? this.state.tracks[0]?.id) => this.operation(async () => {
41
+ const track = this.state.tracks.find(track => track.id === id); if (!track) return;
42
+ if (!this.player || id !== this.state.currentId) {
43
+ const next = await this.createPlayer(track);
44
+ try { if (this.player) await this.player.remove(); } catch (error) { await next.player.remove(); next.release(); throw error; }
45
+ this.release?.(); this.player = next.player; this.release = next.release;
46
+ const position = id === this.state.currentId ? this.state.position : 0;
47
+ this.changed({ currentId: id!, position, status: stopped });
48
+ if (position > 0) await this.player.seekTo(position);
49
+ }
50
+ if (this.state.status.didJustFinish) await this.player.seekTo(0);
51
+ this.finishedPlayback = false;
52
+ await this.player.play(); await this.tick(); await this.flush();
53
+ });
54
+ pause = () => this.operation(async () => { await this.player?.pause(); await this.tick(); await this.flush(); });
55
+ seek = (seconds: number) => this.operation(async () => { if (this.player) { await this.player.seekTo(Math.max(0, seconds)); await this.tick(); await this.flush(); } });
56
+ next = () => { const index = this.state.tracks.findIndex(track => track.id === this.state.currentId); const track = this.state.tracks[index + 1]; return track ? this.play(track.id) : this.pause(); };
57
+ tick = async () => {
58
+ if (!this.player || this.polling) return;
59
+ this.polling = true; const player = this.player;
60
+ try {
61
+ const status = await player.getStatus(); if (player !== this.player) return;
62
+ if (status.currentTime !== this.state.position) this.changed({ position: status.currentTime });
63
+ this.update({ status, ...(status.error ? { error: status.error } : {}) });
64
+ if (status.didJustFinish && !this.state.busy && !this.finishedPlayback) { this.finishedPlayback = true; void this.next(); }
65
+ if (Date.now() - this.lastSave > 5000) { this.lastSave = Date.now(); await this.flush(); }
66
+ } catch (error) { this.update({ error: String(error) }); }
67
+ finally { this.polling = false; }
68
+ };
69
+ flush = async () => {
70
+ if (!this.state.ready) return false;
71
+ if (!this.state.dirty) return true;
72
+ try {
73
+ while (this.state.dirty) {
74
+ const revision = this.revision;
75
+ const { tracks, currentId, position } = this.state;
76
+ await this.records.save({ version: 1, tracks, currentId, position });
77
+ if (revision === this.revision) this.update({ dirty: false });
78
+ }
79
+ return true;
80
+ }
81
+ catch (error) { this.update({ error: String(error) }); return false; }
82
+ };
83
+ dispose = () => this.disposing ??= (async () => {
84
+ try {
85
+ await this.activeOperation?.catch(() => {}); await this.flush();
86
+ const player = this.player, release = this.release;
87
+ this.player = undefined; this.release = undefined;
88
+ try { await player?.remove(); } finally { release?.(); this.update({ status: stopped }); }
89
+ } finally { this.disposing = undefined; }
90
+ })();
91
+ }
@@ -0,0 +1,10 @@
1
+ import { createAudioPlayer } from "@legendapp/spark/audio";
2
+ import { records } from "./shared/storage";
3
+ import { resolveTrack } from "./assets";
4
+ import { MusicModel, decodeLibrary } from "./model";
5
+ export const music = new MusicModel(records("music", decodeLibrary), async track => {
6
+ const source = await resolveTrack(track);
7
+ try { return { player: await createAudioPlayer({ uri: source.uri, title: track.name }), release: source.release }; }
8
+ catch (error) { source.release(); throw error; }
9
+ });
10
+ export const dirty = () => music.getSnapshot().dirty;
@@ -0,0 +1,5 @@
1
+ import { readText } from "@legendapp/spark/files";
2
+ import { OpenFiles } from "./shared/OpenFiles";
3
+ import { notes } from "./store";
4
+ const open = async (path: string) => { await notes.load(); if (!notes.getSnapshot().ready) throw new Error("Notes storage is unavailable"); const text = await readText(path); notes.create(text); };
5
+ export function Activation({ windowId, onError }: { windowId: string; onError(message: string): void }) { return <OpenFiles windowId={windowId} onFile={open} onError={onError} />; }
@@ -0,0 +1 @@
1
+ export { Activation } from "./Activation.desktop";
@@ -0,0 +1 @@
1
+ export function Activation(_props: { windowId: string; onError(message: string): void }) { return null; }
@@ -0,0 +1 @@
1
+ export { Activation } from "./Activation.desktop";
@@ -0,0 +1,92 @@
1
+ import { Activation } from "./Activation";
2
+ import { memo, useEffect, useMemo, useRef, useState, useContext } from "react";
3
+ import { FlatList, Pressable, SafeAreaView, StyleSheet, TextInput, useWindowDimensions, View } from "react-native";
4
+ import { Button } from "@legendapp/spark/ui";
5
+ import { io } from "./shared/io";
6
+ import { Lifecycle } from "./shared/Lifecycle";
7
+ import { useModel } from "./shared/useModel";
8
+ import { notes, dirty } from "./store";
9
+ import { title, type Note } from "./model";
10
+ import { desktop, openNote, openSettings, showNotebook, restoreSession, watchSession, flushSession } from "./Desktop";
11
+ import { requestSearch, onSearch } from "./search";
12
+ import { Text, ThemeContext, usePalette } from "./Theme";
13
+ const Row = memo(function Row({ note, selected }: { note: Note; selected: boolean }) {
14
+ const palette = useContext(ThemeContext);
15
+ return <Pressable accessibilityRole="button" accessibilityState={{ selected }} onPress={() => notes.select(note.id)} style={[styles.row, { borderColor: palette.border }, selected && { backgroundColor: palette.selected }]}>
16
+ <Text numberOfLines={1} style={styles.noteTitle}>{title(note)}</Text>
17
+ <Text numberOfLines={1}>{note.text.split("\n").slice(1).join(" ") || "Empty note"}</Text>
18
+ </Pressable>;
19
+ });
20
+ export default function App({ windowId = "main", windowProps }: { windowId?: string; windowProps?: { noteId?: string; settings?: boolean } }) {
21
+ const state = useModel(notes);
22
+ const palette = usePalette(state.theme ?? "system");
23
+ const { width } = useWindowDimensions();
24
+ const search = useRef<TextInput>(null);
25
+ const [query, setQuery] = useState("");
26
+ const [trash, setTrash] = useState(false);
27
+ const [settings, setSettings] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [searchRequested, setSearchRequested] = useState(0);
30
+ const selected = state.notes.find(note => note.id === (windowProps?.noteId ?? state.selectedId) && !note.deleted);
31
+ const isSettings = windowProps?.settings || settings;
32
+ const visible = useMemo(() => state.notes.filter(note => note.deleted === trash && note.text.toLowerCase().includes(query.toLowerCase())), [state.notes, query, trash]);
33
+ // Native menu/shortcut registrations retain these callbacks; read current model at invocation.
34
+ const commands = useMemo(() => {
35
+ const run = (action: () => void | Promise<unknown>) => () => { setError(null); void Promise.resolve().then(action).catch(error => setError(String(error))); };
36
+ const current = () => windowProps?.settings ? undefined : notes.getSnapshot().notes.find(note => note.id === (windowProps?.noteId ?? notes.getSnapshot().selectedId) && !note.deleted);
37
+ return [
38
+ { id: "new", title: "New note", key: "N", run: run(async () => { setSettings(false); setTrash(false); setQuery(""); const id = notes.create(); if (id && (windowProps?.noteId || windowProps?.settings)) await openNote(id); }) },
39
+ { id: "save", title: "Save notes", key: "S", run: run(async () => { await notes.flush(); }) },
40
+ { id: "open", title: "Import text…", key: "O", run: run(async () => { const file = await io.open(); if (file) { const id = notes.create(file.text); if (id && (windowProps?.noteId || windowProps?.settings)) await openNote(id); } }) },
41
+ { id: "search", title: "Search notes", key: "F", run: run(async () => { if (desktop) await showNotebook(); requestSearch(); }) },
42
+ { id: "delete", title: "Delete note", key: "Shift+Backspace", run: run(() => { const note = current(); if (note) notes.setDeleted(note.id, true); }) },
43
+ ...(desktop ? [{ id: "window", title: "Open in new window", key: "Shift+N", run: run(async () => { const note = current(); if (note) await openNote(note.id); }) }] : []),
44
+ { id: "settings", title: "Settings…", key: ",", run: run(async () => { if (desktop) await openSettings(); else setSettings(value => !value); }) },
45
+ ];
46
+ }, [windowProps?.noteId, windowProps?.settings]);
47
+ useEffect(() => { void notes.load(); }, []);
48
+ useEffect(() => {
49
+ if (windowId !== "main") return;
50
+ return onSearch(() => { setSettings(false); setTrash(false); notes.select(null); setSearchRequested(value => value + 1); });
51
+ }, [windowId]);
52
+ useEffect(() => {
53
+ if (!desktop || windowId !== "main") return;
54
+ void restoreSession().catch(error => setError(String(error)));
55
+ const subscription = watchSession(setError);
56
+ return () => subscription.remove();
57
+ }, [windowId]);
58
+ useEffect(() => { if (searchRequested) search.current?.focus(); }, [searchRequested]);
59
+ const sidebar = !windowProps?.noteId && (width >= 720 || !selected);
60
+ const fieldStyle = { color: palette.text, backgroundColor: palette.surface, borderColor: palette.border };
61
+ return <ThemeContext.Provider value={palette}><SafeAreaView style={[styles.root, { backgroundColor: palette.background }]}>
62
+ <Activation windowId={windowId} onError={setError} />
63
+ <Lifecycle title={isSettings ? "Notes Settings" : selected ? `${title(selected)} — Notes` : "Notes"} windowId={windowId} flush={notes.flush} quit={desktop ? flushSession : notes.flush} dirty={dirty} commands={commands} onError={setError} />
64
+ <View style={styles.header}><Text style={styles.heading}>{isSettings ? "Settings" : "Notes"}</Text><Text accessibilityLiveRegion="polite">{state.saving ? "Saving…" : state.dirty ? "Changes pending" : state.ready ? "Saved on this device" : "Opening notes…"}</Text>{!isSettings ? <Button onPress={commands.find(command => command.id === "settings")!.run}>Settings</Button> : null}</View>
65
+ {state.recovered ? <Text style={styles.notice}>Recovered the previous saved snapshot after an incomplete or unreadable write.</Text> : null}
66
+ {error ? <View style={styles.notice}><Text accessibilityRole="alert">{error}</Text><Button onPress={() => setError(null)}>Dismiss</Button></View> : null}
67
+ {state.error ? <View style={styles.notice}><Text accessibilityRole="alert">{state.error}</Text>{state.ready ? <Button onPress={() => { void notes.flush(); }}>Retry save</Button> : <Text>Storage could not be opened. Restart after resolving the storage problem.</Text>}</View> : null}
68
+ {isSettings ? <View style={styles.editorPane}><Text style={styles.noteTitle}>Appearance</Text><Text>Choose a theme for every Notes window.</Text><View style={styles.toolbar}>{(["system", "light", "dark"] as const).map(theme => <Button key={theme} disabled={!state.ready || (state.theme ?? "system") === theme} onPress={() => notes.setTheme(theme)}>{`${theme === "system" ? "System" : theme === "light" ? "Light" : "Dark"}${(state.theme ?? "system") === theme ? " ✓" : ""}`}</Button>)}</View>{!desktop ? <Button onPress={() => setSettings(false)}>Back to notes</Button> : null}</View> : <View style={styles.body}>
69
+ {sidebar ? <View style={[styles.sidebar, { borderColor: palette.border }, width < 720 && { width: "100%" }]}>
70
+ <TextInput ref={search} accessibilityLabel="Search notes" placeholder="Search notes" placeholderTextColor={palette.text} value={query} onChangeText={setQuery} style={[styles.search, fieldStyle]} />
71
+ <View style={styles.toolbar}><Button disabled={!state.ready} onPress={commands[0]!.run}>New note</Button><Button disabled={!state.ready} onPress={commands[2]!.run}>Import text</Button><Button onPress={commands.find(command => command.id === "search")!.run}>Search</Button><Button onPress={() => setTrash(value => !value)}>{trash ? "Show notes" : "Recently deleted"}</Button></View>
72
+ <FlatList data={visible} keyExtractor={note => note.id} extraData={state.selectedId} renderItem={({ item }) => trash ? <View style={styles.row}><Text>{title(item)}</Text><Button onPress={() => notes.setDeleted(item.id, false)}>Restore</Button></View> : <Row note={item} selected={item.id === state.selectedId} />} ListEmptyComponent={<Text style={styles.notice}>{query ? "No matching notes" : trash ? "No deleted notes" : "Create your first note"}</Text>} />
73
+ </View> : null}
74
+ {(!sidebar || width >= 720) ? selected ? <View style={styles.editorPane}>
75
+ <View style={styles.toolbar}>
76
+ {width < 720 && !windowProps?.noteId ? <Button onPress={() => notes.select(null)}>Back to notes</Button> : null}
77
+ <Button onPress={() => { void io.save({ name: `${title(selected).replace(/[^a-zA-Z0-9 _-]/g, "_")}.md` }, selected.text, "", true).catch(error => setError(String(error))); }}>Export text</Button>
78
+ <Button onPress={commands.find(command => command.id === "delete")!.run}>Delete note</Button>
79
+ {desktop ? <Button onPress={commands.find(command => command.id === "window")!.run}>Open in new window</Button> : null}
80
+ </View>
81
+ <TextInput key={selected.id} accessibilityLabel="Note text" testID="note-text" multiline value={selected.text} onChangeText={text => notes.edit(selected.id, text)} textAlignVertical="top" style={[styles.editor, fieldStyle]} autoFocus />
82
+ </View> : <View style={styles.editorPane}><Text>{windowProps?.noteId ? "This note was deleted. Restore it from Recently deleted in the main window." : "Select a note to begin."}</Text></View> : null}
83
+ </View>}
84
+ </SafeAreaView></ThemeContext.Provider>;
85
+ }
86
+ const styles = StyleSheet.create({
87
+ root: { flex: 1 }, header: { padding: 20, gap: 6 }, heading: { fontSize: 28, fontWeight: "600" },
88
+ body: { flex: 1, flexDirection: "row" }, sidebar: { width: 320, borderRightWidth: 1 },
89
+ toolbar: { flexDirection: "row", flexWrap: "wrap", gap: 8, padding: 12 }, search: { padding: 12, margin: 12, borderWidth: 1, borderRadius: 6 },
90
+ row: { padding: 16, gap: 6, borderBottomWidth: 1 }, noteTitle: { fontSize: 16, fontWeight: "600" },
91
+ editorPane: { flex: 1, padding: 12 }, editor: { flex: 1, minHeight: 180, padding: 16, fontSize: 17, borderWidth: 1, borderRadius: 6 }, notice: { padding: 12 },
92
+ });
@@ -0,0 +1,47 @@
1
+ import { addWindowListener, getDisplays, listWindows, openWindow, setWindowBounds, showWindow } from "@legendapp/spark/windows";
2
+ import type { AsyncRegistration } from "@legendapp/spark/contracts";
3
+ import { notes } from "./store";
4
+ import { WindowSession } from "./window-session";
5
+ export const desktop = true;
6
+ const session = new WindowSession(notes, {
7
+ list: async () => (await listWindows()).map(window => ({ id: window.id, frame: window.bounds })),
8
+ workAreas: async () => (await getDisplays()).sort((a, b) => Number(b.primary) - Number(a.primary)).map(display => ({ ...display.workArea, displayId: display.id })),
9
+ show: showWindow,
10
+ frame: setWindowBounds,
11
+ open: window => openWindow({ id: window.id, component: "main", title: window.id === "settings" ? "Notes Settings" : "Notes", size: { width: window.frame.width, height: window.frame.height }, position: { displayId: window.frame.displayId, x: window.frame.x, y: window.frame.y }, props: { windowId: window.id, windowProps: window.id === "settings" ? { settings: true } : { noteId: window.noteId } } }),
12
+ });
13
+ const watchers = new Set<(message: string) => void>();
14
+ const registrations = new Map<string, AsyncRegistration[]>();
15
+ let generation = 0, timer: ReturnType<typeof setTimeout> | undefined;
16
+ function report(error: unknown) { for (const watcher of watchers) watcher(String(error)); }
17
+ function changed() { clearTimeout(timer); timer = setTimeout(() => { void session.capture().catch(report); }, 150); }
18
+ let pending: Promise<void> = Promise.resolve();
19
+ function observeWindows() {
20
+ const epoch = generation;
21
+ const task = pending.then(async () => {
22
+ if (!watchers.size || epoch !== generation) return;
23
+ for (const window of await listWindows()) {
24
+ if (!session.known(window.id) || registrations.has(window.id)) continue;
25
+ const owned: AsyncRegistration[] = [];
26
+ try {
27
+ owned.push(await addWindowListener(window.id, "boundsChanged", changed));
28
+ owned.push(await addWindowListener(window.id, "closed", () => { registrations.delete(window.id); for (const value of owned) void value.remove().catch(report); changed(); }));
29
+ if (watchers.size && epoch === generation) registrations.set(window.id, owned);
30
+ else for (const value of owned) await value.remove();
31
+ } catch (error) { for (const value of owned) await value.remove(); throw error; }
32
+ }
33
+ });
34
+ pending = task.catch(() => {}); return task;
35
+ }
36
+ export async function openNote(noteId: string) { await session.openNote(noteId); await observeWindows(); }
37
+ export async function openSettings() { await session.openSettings(); await observeWindows(); }
38
+ export const showNotebook = () => showWindow("main");
39
+ export async function restoreSession() { await session.restore(); await observeWindows(); }
40
+ export const flushSession = session.quit;
41
+ export function watchSession(onError: (message: string) => void) {
42
+ watchers.add(onError); void observeWindows().catch(report);
43
+ return { remove() {
44
+ watchers.delete(onError);
45
+ if (!watchers.size) { generation++; clearTimeout(timer); for (const owned of registrations.values()) for (const value of owned) void value.remove().catch(console.error); registrations.clear(); }
46
+ } };
47
+ }
@@ -0,0 +1 @@
1
+ export * from "./Desktop.desktop";
@@ -0,0 +1,7 @@
1
+ export const desktop = false;
2
+ export async function openNote(_noteId: string) {}
3
+ export async function showNotebook() {}
4
+ export async function openSettings() {}
5
+ export async function restoreSession() {}
6
+ export async function flushSession() { return true; }
7
+ export function watchSession(_onError: (message: string) => void) { return { remove() {} }; }
@@ -0,0 +1 @@
1
+ export * from "./Desktop.desktop";
@@ -0,0 +1,17 @@
1
+ import { createContext, useContext, useEffect, type ComponentProps } from "react";
2
+ import { Appearance, Platform, Text as NativeText, useColorScheme } from "react-native";
3
+ import type { Theme } from "./session";
4
+ const light = { background: "#faf9f6", surface: "#ffffff", text: "#222222", border: "#d8d5cf", selected: "#dce9ef" };
5
+ const dark = { background: "#191919", surface: "#252525", text: "#eeeeee", border: "#505050", selected: "#29475b" };
6
+ export const ThemeContext = createContext(light);
7
+ export function usePalette(theme: Theme) {
8
+ const system = useColorScheme();
9
+ useEffect(() => {
10
+ if (Platform.OS !== "web") Appearance.setColorScheme(theme === "system" ? null : theme);
11
+ }, [theme]);
12
+ return (theme === "system" ? system : theme) === "dark" ? dark : light;
13
+ }
14
+ export function Text(props: ComponentProps<typeof NativeText>) {
15
+ const palette = useContext(ThemeContext);
16
+ return <NativeText {...props} style={[{ color: palette.text }, props.style]} />;
17
+ }
@@ -0,0 +1,61 @@
1
+ import { decodeWindows, type SavedWindow, type Theme } from "./session";
2
+ export type Note = { id: string; text: string; updatedAt: number; deleted: boolean };
3
+ export type Notebook = { version: 1; notes: Note[]; selectedId: string | null; theme?: Theme; windows?: SavedWindow[] };
4
+ export function decodeNotebook(value: unknown): Notebook {
5
+ const data = value as Notebook;
6
+ if (data?.version !== 1 || !Array.isArray(data.notes) || !data.notes.every(note => note && typeof note.id === "string" && typeof note.text === "string" && Number.isFinite(note.updatedAt) && typeof note.deleted === "boolean") || new Set(data.notes.map(note => note.id)).size !== data.notes.length || (data.selectedId !== null && typeof data.selectedId !== "string")) throw new Error("Unsupported notes format");
7
+ if (data.theme !== undefined && !["system", "light", "dark"].includes(data.theme)) throw new Error("Unsupported theme");
8
+ decodeWindows(data.windows);
9
+ return data;
10
+ }
11
+ export type NotesState = Notebook & { ready: boolean; dirty: boolean; saving: boolean; error: string | null; recovered: boolean };
12
+ export class NotesModel {
13
+ private state: NotesState = { version: 1, notes: [], selectedId: null, ready: false, dirty: false, saving: false, error: null, recovered: false };
14
+ private listeners = new Set<() => void>();
15
+ private timer: ReturnType<typeof setTimeout> | undefined;
16
+ private revision = 0;
17
+ private loading?: Promise<void>;
18
+ private saving?: Promise<boolean>;
19
+ constructor(private records: { load(): Promise<{ value: Notebook | null; recovered: boolean }>; save(value: Notebook): Promise<void> }) {}
20
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
21
+ getSnapshot = () => this.state;
22
+ private update(patch: Partial<NotesState>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
23
+ load = () => this.loading ??= (async () => {
24
+ try { const saved = await this.records.load(); this.update({ ...saved.value, ready: true, recovered: saved.recovered }); }
25
+ catch (error) { this.update({ error: String(error) }); }
26
+ })();
27
+ private change(patch: Partial<Notebook>) {
28
+ if (!this.state.ready) return;
29
+ this.revision++; this.update({ ...patch, dirty: true });
30
+ clearTimeout(this.timer); this.timer = setTimeout(() => { void this.flush(); }, 250);
31
+ }
32
+ setTheme = (theme: Theme) => { if (theme !== (this.state.theme ?? "system")) this.change({ theme }); };
33
+ setWindows = (windows: SavedWindow[]) => { if (JSON.stringify(windows) !== JSON.stringify(this.state.windows ?? [])) this.change({ windows }); };
34
+ create = (text = "") => {
35
+ if (!this.state.ready) return;
36
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
37
+ this.change({ notes: [{ id, text, updatedAt: Date.now(), deleted: false }, ...this.state.notes], selectedId: id });
38
+ return id;
39
+ };
40
+ select = (selectedId: string | null) => { if (selectedId !== this.state.selectedId) this.change({ selectedId }); };
41
+ edit = (id: string, text: string) => {
42
+ const note = this.state.notes.find(item => item.id === id);
43
+ if (!note || note.text === text || note.deleted) return;
44
+ this.change({ notes: this.state.notes.map(item => item.id === id ? { ...item, text, updatedAt: Date.now() } : item) });
45
+ };
46
+ setDeleted = (id: string, deleted: boolean) => this.change({ notes: this.state.notes.map(note => note.id === id ? { ...note, deleted, updatedAt: Date.now() } : note), selectedId: this.state.selectedId === id && deleted ? null : this.state.selectedId });
47
+ flush = (): Promise<boolean> => {
48
+ clearTimeout(this.timer);
49
+ if (this.saving) return this.saving.then(ok => ok ? this.flush() : false);
50
+ if (!this.state.ready) return Promise.resolve(false);
51
+ if (!this.state.dirty) return Promise.resolve(true);
52
+ const revision = this.revision;
53
+ const { notes, selectedId, theme, windows } = this.state;
54
+ this.update({ saving: true, error: null });
55
+ this.saving = this.records.save({ version: 1, notes, selectedId, theme, windows }).then(() => {
56
+ this.update({ dirty: revision !== this.revision }); return true;
57
+ }, error => { this.update({ error: String(error) }); return false; }).finally(() => { this.saving = undefined; this.update({ saving: false }); });
58
+ return this.saving.then(ok => ok && this.state.dirty ? this.flush() : ok);
59
+ };
60
+ }
61
+ export const title = (note: Note) => note.text.split("\n").find(line => line.trim())?.replace(/^#+\s*/, "").slice(0, 80) || "Untitled note";
@@ -0,0 +1,3 @@
1
+ const listeners = new Set<() => void>();
2
+ export function requestSearch() { for (const listener of listeners) listener(); }
3
+ export function onSearch(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
@@ -0,0 +1,15 @@
1
+ export type Theme = "system" | "light" | "dark";
2
+ export type Frame = { displayId: string; x: number; y: number; width: number; height: number };
3
+ export type SavedWindow = { id: string; noteId?: string; frame: Frame };
4
+ export function decodeWindows(value: unknown): SavedWindow[] {
5
+ if (value === undefined) return [];
6
+ if (!Array.isArray(value) || !value.every(item => item && typeof item.id === "string" && /^(main|settings|note-[a-zA-Z0-9_-]+)$/.test(item.id) && (item.noteId === undefined || typeof item.noteId === "string") && item.frame && typeof item.frame.displayId === "string" && item.frame.displayId.length > 0 && ["x", "y", "width", "height"].every(key => Number.isFinite(item.frame[key])) && item.frame.width >= 100 && item.frame.height >= 100 && item.frame.width <= 20000 && item.frame.height <= 20000) || new Set(value.map(item => item.id)).size !== value.length) throw new Error("Unsupported window session");
7
+ return value;
8
+ }
9
+ /** Saved bounds and work areas use the same display-relative logical units. */
10
+ export function fitFrame(frame: Frame, workAreas: Frame[]): Frame {
11
+ if (!workAreas.length) return frame;
12
+ const area = workAreas.find(area => area.displayId === frame.displayId) ?? workAreas[0];
13
+ const width = Math.min(frame.width, area.width), height = Math.min(frame.height, area.height);
14
+ return { displayId: area.displayId, x: Math.max(area.x, Math.min(frame.x, area.x + area.width - width)), y: Math.max(area.y, Math.min(frame.y, area.y + area.height - height)), width, height };
15
+ }
@@ -0,0 +1,4 @@
1
+ import { records } from "./shared/storage";
2
+ import { decodeNotebook, NotesModel } from "./model";
3
+ export const notes = new NotesModel(records("notes", decodeNotebook));
4
+ export const dirty = () => notes.getSnapshot().dirty;
@@ -0,0 +1,63 @@
1
+ import { fitFrame, type Frame, type SavedWindow } from "./session";
2
+ import type { NotesModel } from "./model";
3
+ export type WindowHost = {
4
+ list(): Promise<{ id: string; frame: Frame }[]>;
5
+ workAreas(): Promise<Frame[]>;
6
+ open(window: SavedWindow): Promise<unknown>;
7
+ show(id: string): Promise<unknown>;
8
+ frame(id: string, frame: Frame): Promise<unknown>;
9
+ };
10
+ /** One controller per JS runtime; all React windows share its session and writer. */
11
+ export class WindowSession {
12
+ private noteIds = new Map<string, string>();
13
+ private restored?: Promise<void>;
14
+ private quitting = false;
15
+ private queue: Promise<void> = Promise.resolve();
16
+ constructor(private notes: NotesModel, private host: WindowHost) {}
17
+ known = (id: string) => id === "main" || id === "settings" || this.noteIds.has(id);
18
+ async openNote(noteId: string) {
19
+ await this.restore();
20
+ const id = `note-${noteId}`;
21
+ this.noteIds.set(id, noteId);
22
+ if ((await this.host.list()).some(window => window.id === id)) await this.host.show(id);
23
+ else await this.host.open({ id, noteId, frame: fitFrame({ displayId: "", x: 0, y: 0, width: 700, height: 600 }, await this.host.workAreas()) });
24
+ await this.capture();
25
+ }
26
+ async openSettings() {
27
+ await this.restore();
28
+ if ((await this.host.list()).some(window => window.id === "settings")) await this.host.show("settings");
29
+ else await this.host.open({ id: "settings", frame: fitFrame({ displayId: "", x: 0, y: 0, width: 460, height: 360 }, await this.host.workAreas()) });
30
+ await this.capture();
31
+ }
32
+ capture = () => {
33
+ const task = this.queue.then(async () => {
34
+ if (this.quitting) return;
35
+ const windows = await this.host.list();
36
+ this.notes.setWindows(windows.filter(window => this.known(window.id)).map(window => ({ id: window.id, noteId: this.noteIds.get(window.id), frame: window.frame })));
37
+ });
38
+ this.queue = task.catch(() => {});
39
+ return task;
40
+ };
41
+ restore = () => this.restored ??= (async () => {
42
+ await this.notes.load();
43
+ if (!this.notes.getSnapshot().ready) return;
44
+ const saved = [...(this.notes.getSnapshot().windows ?? [])];
45
+ const areas = await this.host.workAreas();
46
+ for (const window of saved) {
47
+ if (window.id !== "main" && window.id !== "settings" && !this.notes.getSnapshot().notes.some(note => note.id === window.noteId && !note.deleted)) continue;
48
+ if (window.noteId) this.noteIds.set(window.id, window.noteId);
49
+ const frame = fitFrame(window.frame, areas);
50
+ if (window.id !== "main" && !(await this.host.list()).some(item => item.id === window.id)) await this.host.open({ ...window, frame });
51
+ await this.host.frame(window.id, frame);
52
+ }
53
+ await this.capture();
54
+ })();
55
+ quit = async () => {
56
+ await this.restore();
57
+ await this.capture();
58
+ const ok = await this.notes.flush();
59
+ // Native quit may close windows individually. Keep their last complete session.
60
+ if (ok) this.quitting = true;
61
+ return ok;
62
+ };
63
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: spark
3
+ description: Develop, build, diagnose, or upgrade a Legend Spark desktop app.
4
+ ---
5
+
6
+ <!-- spark:skill:start -->
7
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
8
+ The installed Spark version owns these instructions; do not copy them here.
9
+ <!-- spark:skill:end -->
@@ -0,0 +1,4 @@
1
+ <!-- spark:skill:start -->
2
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
3
+ The installed Spark version owns these instructions; do not copy them here.
4
+ <!-- spark:skill:end -->
@@ -0,0 +1,57 @@
1
+ import { useState } from "react";
2
+ import { Uniwind } from "uniwind";
3
+ import { ScrollView, Text, View } from "react-native";
4
+ import { Button, TextInput, Select } from "@legendapp/spark/ui/uniwind";
5
+ import * as Clipboard from "@legendapp/spark/clipboard";
6
+ import * as SecureStore from "@legendapp/spark/secure-storage";
7
+ import * as Linking from "@legendapp/spark/links";
8
+
9
+ const themes = [{ label: "System", value: "system" }, { label: "Light", value: "light" }, { label: "Dark", value: "dark" }];
10
+
11
+ export default function Settings() {
12
+ const [name, setName] = useState("");
13
+ const [theme, setTheme] = useState("system");
14
+ const [status, setStatus] = useState("Preferences are held in memory for this demo.");
15
+ const [busy, setBusy] = useState(false);
16
+ async function action(work: () => Promise<string>) {
17
+ setBusy(true);
18
+ try { setStatus(await work()); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); }
19
+ finally { setBusy(false); }
20
+ }
21
+ function changeTheme(value: string) {
22
+ if (value !== "system" && value !== "light" && value !== "dark") return;
23
+ Uniwind.setTheme(value);
24
+ setTheme(value);
25
+ }
26
+ return <ScrollView contentInsetAdjustmentBehavior="always" className="flex-1 bg-background" contentContainerClassName="grow items-center p-4 sm:p-6">
27
+ <View className="w-full max-w-2xl gap-3 rounded-xl border border-border bg-surface p-4 sm:p-6" testID="settings-panel">
28
+ <Text accessibilityRole="header" className="text-3xl font-semibold text-foreground">Settings</Text>
29
+ <Text className="text-base text-muted">One screen shared across mobile, web, and desktop.</Text>
30
+ <Text className="mt-2 text-base font-semibold text-foreground">Display name</Text>
31
+ <TextInput accessibilityLabel="Display name" defaultValue="" onChangeText={setName} className="w-full" testID="settings-name" />
32
+ <Text className="mt-2 text-base font-semibold text-foreground">Appearance preference</Text>
33
+ <Select accessibilityLabel="Appearance preference" options={themes} value={theme} onValueChange={changeTheme} className="w-full" testID="settings-theme" />
34
+ <Text className="text-base text-muted">Hello {name || "there"}. Preferred appearance: {theme}.</Text>
35
+ <View className="mt-3 flex-row flex-wrap gap-3">
36
+ <Button className="w-full sm:w-56" disabled={busy} onPress={() => void action(async () => {
37
+ if (!await Clipboard.setStringAsync(JSON.stringify({ name, theme }))) throw new Error("The browser did not permit clipboard access.");
38
+ return "Copied preferences.";
39
+ })}>Copy preferences</Button>
40
+ <Button className="w-full sm:w-56" disabled={busy} onPress={() => void action(async () => {
41
+ if (!await SecureStore.isAvailableAsync()) return "Secure storage is unavailable on this platform.";
42
+ const key = `settings-demo-${Date.now()}`;
43
+ // A disposable synthetic value; no real credentials are entered or displayed.
44
+ try {
45
+ await SecureStore.setItemAsync(key, "demo");
46
+ if (await SecureStore.getItemAsync(key) !== "demo") throw new Error("Secure storage check failed.");
47
+ return "Secure storage verified; demo value removed.";
48
+ } finally { await SecureStore.deleteItemAsync(key); }
49
+ })}>Check secure storage</Button>
50
+ <Button className="w-full sm:w-56" disabled={busy} onPress={() => void action(async () => {
51
+ await Linking.openURL("https://legendapp.com"); return "Opened the Legend website.";
52
+ })}>Open Legend website</Button>
53
+ </View>
54
+ <Text accessibilityLiveRegion="polite" className="mt-3 text-sm text-foreground" testID="settings-status">{status}</Text>
55
+ </View>
56
+ </ScrollView>;
57
+ }
@@ -0,0 +1,4 @@
1
+ <!-- spark:skill:start -->
2
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
3
+ The installed Spark version owns these instructions; do not copy them here.
4
+ <!-- spark:skill:end -->
@@ -0,0 +1,15 @@
1
+ # Spark app
2
+
3
+ Run the app with `macos` using your chosen package manager:
4
+
5
+ ```sh
6
+ npm run macos
7
+ ```
8
+
9
+ macOS opens in Spark Runner. Windows requires a custom development build and is
10
+ an unverified preview. JavaScript edits use Fast Refresh. Use `npm run build` for
11
+ a standalone macOS app; native builds require Xcode and CocoaPods.
12
+
13
+ Use the equivalent `bun run`, `pnpm run`, or `yarn` commands for those managers.
14
+ See [Spark documentation](https://legend.so/spark) for configuration, upgrades,
15
+ platform prerequisites, and distribution.