create-bridgething 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,103 +1,61 @@
1
- import { settings, type ConfigField, type SettingsContext } from '@bridgething/client/settings';
2
- import { render } from 'preact';
3
- import { useEffect, useState } from 'preact/hooks';
1
+ import { ConfigForm, closeSettings, type ConfigFormHandle } from '@bridgething/client/form';
2
+ import { settings, type SettingsContext } from '@bridgething/client/settings';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { createRoot } from 'react-dom/client';
4
5
  import './style.css';
5
6
 
6
- function fieldMeta(field: ConfigField): { key: string; label: string } {
7
- return { key: field.data.key, label: field.data.label };
8
- }
9
-
10
7
  function Settings() {
8
+ const form = useRef<ConfigFormHandle>(null);
11
9
  const [ctx, setCtx] = useState<SettingsContext | null>(null);
12
- const [fields, setFields] = useState<ConfigField[]>([]);
13
- const [values, setValues] = useState<Record<string, string>>({});
14
10
  const [status, setStatus] = useState('');
15
11
 
16
12
  useEffect(() => {
17
- (async () => {
18
- try {
19
- setCtx(await settings.context());
20
- const [schema, entries] = await Promise.all([settings.config.fields(), settings.config.list()]);
21
- setFields(schema);
22
- setValues(Object.fromEntries(entries.map(e => [e.key, e.value])));
23
- } catch (err) {
24
- setStatus(err instanceof Error ? err.message : String(err));
25
- }
26
- })();
13
+ settings.context().then(setCtx, (err: unknown) => setStatus(errText(err)));
14
+ return settings.onRefresh(setCtx);
27
15
  }, []);
28
16
 
29
- async function saveConfig(event: Event) {
30
- event.preventDefault();
31
- setStatus('saving...');
17
+ async function saveToDevice() {
18
+ setStatus('writing to device...');
32
19
  try {
33
- for (const field of fields) {
34
- const { key } = fieldMeta(field);
35
- await settings.config.set(key, values[key] ?? '');
36
- }
37
- setStatus('settings saved');
20
+ const values = await form.current?.flush();
21
+ await settings.doc.set('preferences', JSON.stringify({ values, savedAt: Date.now() }));
22
+ setStatus('synced a preferences doc to the device');
38
23
  } catch (err) {
39
- setStatus(err instanceof Error ? err.message : String(err));
24
+ setStatus(errText(err));
40
25
  }
41
26
  }
42
27
 
43
- async function saveToDevice() {
44
- setStatus('writing to device...');
28
+ async function close() {
45
29
  try {
46
- await settings.doc.set('preferences', JSON.stringify({ values, savedAt: Date.now() }));
47
- setStatus('synced a preferences doc to the device');
30
+ await closeSettings(form.current);
48
31
  } catch (err) {
49
- setStatus(err instanceof Error ? err.message : String(err));
32
+ setStatus(errText(err));
50
33
  }
51
34
  }
52
35
 
53
36
  return (
54
37
  <main>
55
38
  <h1>{ctx?.name ?? '__PROJECT_NAME__'} settings</h1>
56
- <p class="hint">{ctx ? `${ctx.webappId} on ${ctx.deviceId}` : 'connecting to the companion host...'}</p>
39
+ <p className="hint">{ctx ? `${ctx.name} ${ctx.version}` : 'connecting to the companion host...'}</p>
57
40
 
58
- <form onSubmit={saveConfig}>
59
- {fields.length === 0 && <p class="hint">this webapp declares no config fields yet.</p>}
60
- {fields.map(field => {
61
- const { key, label } = fieldMeta(field);
62
- const value = values[key] ?? '';
63
- const onInput = (e: Event) => setValues({ ...values, [key]: (e.target as HTMLInputElement).value });
64
- return (
65
- <div class="field" key={key}>
66
- <label for={key}>{label}</label>
67
- {field.type === 'enum' ? (
68
- <select id={key} value={value} onInput={onInput}>
69
- {field.data.choices.map(choice => (
70
- <option value={choice} key={choice}>
71
- {choice}
72
- </option>
73
- ))}
74
- </select>
75
- ) : (
76
- <input
77
- id={key}
78
- type={field.type === 'number' ? 'number' : field.type === 'secret' ? 'password' : 'text'}
79
- value={value}
80
- onInput={onInput}
81
- />
82
- )}
83
- </div>
84
- );
85
- })}
41
+ <ConfigForm ref={form} />
86
42
 
87
- <div class="row">
88
- <button type="submit">Save settings</button>
89
- <button type="button" class="secondary" onClick={saveToDevice}>
90
- Save to device
91
- </button>
92
- <button type="button" class="secondary" onClick={() => settings.done()}>
93
- Done
94
- </button>
95
- </div>
96
- </form>
43
+ <div className="row">
44
+ <button type="button" onClick={saveToDevice}>
45
+ Save to device
46
+ </button>
47
+ <button type="button" className="secondary" onClick={close}>
48
+ Done
49
+ </button>
50
+ </div>
97
51
 
98
- <p class="status">{status}</p>
52
+ <p className="status">{status}</p>
99
53
  </main>
100
54
  );
101
55
  }
102
56
 
103
- render(<Settings />, document.getElementById('root')!);
57
+ function errText(err: unknown): string {
58
+ return err instanceof Error ? err.message : String(err);
59
+ }
60
+
61
+ createRoot(document.getElementById('root')!).render(<Settings />);
@@ -1,14 +1,14 @@
1
1
  :root {
2
- color-scheme: light dark;
3
- font-family:
4
- system-ui,
5
- -apple-system,
6
- sans-serif;
2
+ color-scheme: var(--bt-scheme);
7
3
  }
8
4
 
9
5
  body {
10
6
  margin: 0;
11
7
  padding: 20px;
8
+ background: var(--bt-color-bg);
9
+ color: var(--bt-color-fg);
10
+ font-family: var(--bt-font-body);
11
+ font-size: var(--bt-text-body);
12
12
  line-height: 1.5;
13
13
  }
14
14
 
@@ -18,68 +18,50 @@ main {
18
18
  }
19
19
 
20
20
  h1 {
21
- font-size: 1.25rem;
22
- margin: 0 0 0.25rem;
23
- }
24
-
25
- .hint {
26
- opacity: 0.7;
27
- font-size: 0.85rem;
28
- margin: 0 0 1.25rem;
29
- }
30
-
31
- .field {
32
- display: flex;
33
- flex-direction: column;
34
- gap: 4px;
35
- margin-bottom: 14px;
36
- }
37
-
38
- .field label {
39
- font-size: 0.85rem;
21
+ font-family: var(--bt-font-display);
22
+ font-size: var(--bt-text-title);
40
23
  font-weight: 600;
24
+ margin: 0 0 4px;
41
25
  }
42
26
 
43
- .field input,
44
- .field select {
45
- padding: 8px 10px;
46
- font-size: 1rem;
47
- border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
48
- border-radius: 8px;
49
- background: transparent;
50
- color: inherit;
27
+ .hint {
28
+ color: var(--bt-color-muted);
29
+ font-family: var(--bt-font-mono);
30
+ font-size: var(--bt-text-hint);
31
+ margin: 0 0 20px;
51
32
  }
52
33
 
53
34
  .row {
54
35
  display: flex;
55
- gap: 10px;
36
+ gap: 12px;
56
37
  align-items: center;
57
- margin-top: 8px;
38
+ margin-top: 16px;
58
39
  }
59
40
 
60
- button {
61
- padding: 9px 16px;
62
- font-size: 0.95rem;
63
- font-weight: 600;
64
- border: none;
65
- border-radius: 8px;
66
- background: #00a8e8;
67
- color: #06232e;
41
+ .row button {
42
+ padding: 10px 16px;
43
+ font-family: var(--bt-font-mono);
44
+ font-size: var(--bt-text-body);
45
+ border: 1px solid var(--bt-color-accent);
46
+ border-radius: 0;
47
+ background: var(--bt-color-accent-soft);
48
+ color: var(--bt-color-fg);
68
49
  cursor: pointer;
69
50
  }
70
51
 
71
- button.secondary {
72
- background: color-mix(in srgb, currentColor 12%, transparent);
73
- color: inherit;
52
+ .row button.secondary {
53
+ border-color: var(--bt-color-edge);
54
+ background: none;
74
55
  }
75
56
 
76
- button:disabled {
57
+ .row button:disabled {
77
58
  opacity: 0.5;
78
59
  cursor: default;
79
60
  }
80
61
 
81
62
  .status {
82
- font-size: 0.85rem;
83
- opacity: 0.8;
63
+ color: var(--bt-color-muted);
64
+ font-family: var(--bt-font-mono);
65
+ font-size: var(--bt-text-hint);
84
66
  min-height: 1.2em;
85
67
  }
@@ -70,7 +70,7 @@ export default function App() {
70
70
  <div className="mt-6 flex gap-4">
71
71
  <button
72
72
  className="border border-edge px-6 py-3 font-mono text-row text-near transition active:bg-neutral-soft"
73
- onClick={() => client.player.skipPrev()}>
73
+ onClick={() => client.player.skipPrev({ allowSeeking: true })}>
74
74
  ◀◀
75
75
  </button>
76
76
  <button
@@ -18,5 +18,5 @@
18
18
  "verbatimModuleSyntax": true,
19
19
  "useDefineForClassFields": true
20
20
  },
21
- "include": ["src"]
21
+ "include": ["src", "settings", "extension"]
22
22
  }
@@ -3,14 +3,11 @@ import react from '@vitejs/plugin-react';
3
3
  import { defineConfig } from 'vite';
4
4
  import { bridgething, daemonProxy } from './scripts/bridgething';
5
5
 
6
- export default defineConfig(async () => ({
6
+ export default defineConfig(async ({ command }) => ({
7
7
  plugins: [react(), tailwindcss(), bridgething()],
8
8
  build: {
9
9
  target: 'es2022',
10
10
  sourcemap: true,
11
11
  },
12
- server: {
13
- host: true,
14
- proxy: await daemonProxy(),
15
- },
12
+ server: command === 'serve' ? { host: true, proxy: await daemonProxy() } : {},
16
13
  }));
@@ -12,7 +12,7 @@ function sizeGuard(): Plugin {
12
12
  return {
13
13
  name: 'settings-size-guard',
14
14
  closeBundle() {
15
- const out = resolve(__dirname, 'dist', 'settings.html');
15
+ const out = resolve(import.meta.dirname, 'dist', 'settings.html');
16
16
  let bytes: number;
17
17
  try {
18
18
  bytes = statSync(out).size;
@@ -26,14 +26,14 @@ function sizeGuard(): Plugin {
26
26
  '',
27
27
  '========================================================================',
28
28
  ` settings.html is ${kib} KiB.`,
29
- ` the companion hard-rejects settings pages over ${HARD_CAP} at install.`,
30
- ' trim dependencies or inline assets before this page stops installing.',
29
+ ` the device refuses to install a bundle whose settings page is over ${HARD_CAP}.`,
30
+ ' trim dependencies or inline assets; the whole install fails, not just this page.',
31
31
  '========================================================================',
32
32
  '',
33
33
  ].join('\n'),
34
34
  );
35
35
  } else if (bytes > WARN_BYTES) {
36
- console.warn(`settings.html is ${kib} KiB; keep it lean (install hard cap is ${HARD_CAP}).`);
36
+ console.warn(`settings.html is ${kib} KiB; keep it lean (install fails over ${HARD_CAP}).`);
37
37
  }
38
38
  },
39
39
  };
@@ -42,12 +42,13 @@ function sizeGuard(): Plugin {
42
42
  export default defineConfig({
43
43
  root: 'settings',
44
44
  plugins: [preact(), viteSingleFile(), sizeGuard()],
45
+ resolve: { dedupe: ['preact'] },
45
46
  build: {
46
47
  target: 'es2022',
47
- outDir: resolve(__dirname, 'dist'),
48
+ outDir: resolve(import.meta.dirname, 'dist'),
48
49
  emptyOutDir: false,
49
50
  rollupOptions: {
50
- input: resolve(__dirname, 'settings', 'settings.html'),
51
+ input: resolve(import.meta.dirname, 'settings', 'settings.html'),
51
52
  },
52
53
  },
53
54
  });
@@ -14,7 +14,11 @@ back to the built-in overlay, the recovery path when a build misbehaves.
14
14
  The daemon prepends one global before your bundle:
15
15
 
16
16
  ```js
17
- window.__bridgethingOverlay = { origin: 'http://127.0.0.1:8891', surfaces: {...} };
17
+ window.__bridgethingOverlay = {
18
+ origin: 'http://127.0.0.1:8891',
19
+ url: 'ws://127.0.0.1:8891/?scope=overlay',
20
+ surfaces: {...},
21
+ };
18
22
  ```
19
23
 
20
24
  `surfaces` is the active webapp's declared profile, a boolean each for
@@ -22,16 +26,22 @@ window.__bridgethingOverlay = { origin: 'http://127.0.0.1:8891', surfaces: {...}
22
26
  only the surfaces set to true; an app that draws its own volume indicator
23
27
  declares `volume: false`. When every surface is false the daemon injects nothing.
24
28
 
25
- `origin` is the kiosk origin. Compare `location.origin` against it before
26
- mounting. Everything else comes from `@bridgething/client` over the local
27
- websocket.
29
+ `url` is the socket to hand `@bridgething/client`. Always use it rather than
30
+ building one from `location`, because the kiosk can be showing a dev server or
31
+ an external page whose host is not the daemon. That socket is scoped to your
32
+ bundle, so `client.config` and `client.doc` read your own settings and storage
33
+ rather than the foreground webapp's, and `client.config.onChanged` fires when a
34
+ companion changes one of your settings.
35
+
36
+ `origin` is the kiosk origin, for telling a kiosk page apart from a dev server.
28
37
 
29
38
  ### Output constraints
30
39
 
31
40
  `overlay.js` must be one self-contained file under 512 KiB. The daemon injects it
32
- as a script string into another app's document, so it carries its own code and
33
- styles and reaches nothing at runtime. `vite.overlay.config.ts` builds a single
34
- inlined iife; keep that shape.
41
+ as a script string into an isolated world over another app's document, so it
42
+ shares the DOM with that app but none of its javascript, and it carries its own
43
+ code and styles. `vite.overlay.config.ts` builds a single inlined iife; keep that
44
+ shape.
35
45
 
36
46
  Style `overlay/main.tsx` with tailwind classes like the rest of the project.
37
47
  `overlay/style.css` is imported with vite's `?inline`, so the compiled css mounts
@@ -40,7 +50,7 @@ into the shadow root as a string. Tailwind scans only `overlay/main.tsx`
40
50
 
41
51
  ### Keep these four from the starter
42
52
 
43
- 1. The origin guard before mounting.
53
+ 1. The socket url from the prelude, never one built from `location`.
44
54
  2. The `__bridgethingOverlayMounted` guard, so a second injection is a no-op.
45
55
  3. The closed shadow root, which keeps your styles and the host app's apart.
46
56
  4. Escape-only key handling on the capture phase, active only while something is
@@ -13,7 +13,7 @@ type OverlaySurfaces = {
13
13
  volume: boolean;
14
14
  };
15
15
 
16
- type OverlayConfig = { origin: string; surfaces: OverlaySurfaces };
16
+ type OverlayConfig = { origin: string; url: string; surfaces: OverlaySurfaces };
17
17
 
18
18
  declare global {
19
19
  interface Window {
@@ -209,8 +209,7 @@ function Overlay({ cfg, client }: { cfg: OverlayConfig; client: BridgethingClien
209
209
 
210
210
  function boot() {
211
211
  const cfg = window.__bridgethingOverlay;
212
- if (!cfg || !location.origin.startsWith(cfg.origin)) return;
213
- if (window.__bridgethingOverlayMounted) return;
212
+ if (!cfg || window.__bridgethingOverlayMounted) return;
214
213
  window.__bridgethingOverlayMounted = true;
215
214
 
216
215
  const mount = () => {
@@ -218,7 +217,7 @@ function boot() {
218
217
  host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none';
219
218
  const shadow = host.attachShadow({ mode: 'closed' });
220
219
  document.body.appendChild(host);
221
- render(<Overlay cfg={cfg} client={new BridgethingClient({ url: `ws://${location.host}/` })} />, shadow);
220
+ render(<Overlay cfg={cfg} client={new BridgethingClient({ url: cfg.url })} />, shadow);
222
221
  };
223
222
 
224
223
  if (document.body) mount();
@@ -13,12 +13,12 @@ function sizeGuard(): Plugin {
13
13
  closeBundle() {
14
14
  let bytes: number;
15
15
  try {
16
- bytes = statSync(resolve(__dirname, 'dist', 'overlay.js')).size;
16
+ bytes = statSync(resolve(import.meta.dirname, 'dist', 'overlay.js')).size;
17
17
  } catch {
18
18
  return;
19
19
  }
20
20
  if (bytes > WARN_BYTES) {
21
- console.warn(`overlay.js is ${(bytes / 1024).toFixed(1)} KiB; the daemon ignores overlays over ${HARD_CAP}.`);
21
+ console.warn(`overlay.js is ${(bytes / 1024).toFixed(1)} KiB; install fails over ${HARD_CAP}.`);
22
22
  }
23
23
  },
24
24
  };
@@ -28,11 +28,11 @@ export default defineConfig({
28
28
  plugins: [preact(), tailwindcss(), sizeGuard()],
29
29
  build: {
30
30
  target: 'es2022',
31
- outDir: resolve(__dirname, 'dist'),
31
+ outDir: resolve(import.meta.dirname, 'dist'),
32
32
  emptyOutDir: false,
33
33
  cssCodeSplit: false,
34
34
  lib: {
35
- entry: resolve(__dirname, 'overlay', 'main.tsx'),
35
+ entry: resolve(import.meta.dirname, 'overlay', 'main.tsx'),
36
36
  formats: ['iife'],
37
37
  name: '__overlay',
38
38
  fileName: () => 'overlay.js',
@@ -0,0 +1,105 @@
1
+ # Building bridgething webapps in this repo
2
+
3
+ This repo is a bun workspace of webapps for the Spotify Car Thing, plus the pipeline that publishes them as a `catalog.v1` source on GitHub Pages. Each `apps/<slug>` is one webapp: a single page running full-screen in the chromium kiosk on the device, reaching the on-device daemon through `@bridgething/client`.
4
+
5
+ ## The device
6
+
7
+ The screen is 800x480, landscape, and never resizes. The kiosk shows one webapp, so build in-app views rather than tabs or windows. Add an on-screen keyboard if the app needs text entry.
8
+
9
+ Listen with a `keydown` handler and a `wheel` handler on `window`.
10
+
11
+ | Control | Event |
12
+ | ------------ | ------------------------------------- |
13
+ | Preset 1-4 | `keydown` key `"1"` `"2"` `"3"` `"4"` |
14
+ | Mode | `keydown` key `"m"` |
15
+ | Back | `keydown` key `"Escape"` |
16
+ | Rotary wheel | `wheel` with horizontal `deltaX` |
17
+ | Wheel click | `keydown` key `"Enter"` |
18
+ | Touch | pointer and touch events |
19
+
20
+ Make horizontal wheel scroll move through the main list and wheel click activate the selected item. Five fast presses of Mode returns to the launcher.
21
+
22
+ ## The client
23
+
24
+ ```ts
25
+ import { BridgethingClient } from '@bridgething/client';
26
+ import { useMemo } from 'react';
27
+
28
+ import { daemonUrl } from './daemon';
29
+
30
+ const client = useMemo(() => new BridgethingClient({ url: daemonUrl() }), []);
31
+ ```
32
+
33
+ Construct it once and reuse it. It connects and reconnects on its own. Call `daemonUrl()` instead of a literal `ws://` address.
34
+
35
+ Now-playing and library data come from the phone's Spotify, so render a placeholder when no phone is connected. Fetch artwork with `client.asset.get` using the opaque id on the track.
36
+
37
+ Every surface: `player asset config store doc blob capabilities library audio notifications phone peer geo net hardware bluetooth system time voice lyrics webapp forward`. Each method is an event, a request, or a command.
38
+
39
+ Methods, types, and examples: `.claude/skills/bridgething/reference/sdk.md`. That skill lives once at the repo root and every app symlinks it, so it is one copy for the whole workspace. `bun run skills` refreshes it from the published `create-bridgething`; `bun run check` says when it is behind.
40
+
41
+ ## Working on one app
42
+
43
+ ```sh
44
+ bun run dev # the only app, against a connected Car Thing
45
+ bun run dev <slug> # name it when there is more than one
46
+ bun run --cwd apps/<slug> dev # always works
47
+ bun run --cwd apps/<slug> dev:device # show that server on the device's own screen
48
+ bun run --cwd apps/<slug> push # build and install onto the connected device
49
+ bun run --cwd apps/<slug> build # writes dist/
50
+ bun run --cwd apps/<slug> typecheck # checks src, settings and extension together
51
+ bun run --cwd apps/<slug> share # zips dist/ to hand to someone directly
52
+ bun run --cwd apps/<slug> update # brings the device to the latest bridgething release
53
+ ```
54
+
55
+ An app with a variant or an extension carries its own `apps/<slug>/CLAUDE.md` with the parts specific to it.
56
+
57
+ Running and driving the app: `.claude/skills/bridgething/reference/develop.md`. Push, zip, update: `.claude/skills/bridgething/reference/ship.md`. The desktop-side Deno extension and its permission model, for an app with an `extension/` directory: `.claude/skills/bridgething/reference/extension.md`.
58
+
59
+ ## The three files that describe an app
60
+
61
+ **`apps/<slug>/public/manifest.json`** is the app's identity as far as the device and the catalog are concerned.
62
+
63
+ - `id` identifies this webapp on the device. It is a uuidv7 generated once at scaffold time. **Never change it**: the device keys upgrade-in-place and the app's key-value namespace on it, so a new uuid orphans everyone's installed copy and its data.
64
+ - `version` is what the store publishes against. Move it with `bun run bump`, never by hand.
65
+ - `description` is the store tagline and must not be empty.
66
+ - `config` declares the settings `client.config` reads. The companion app edits them through `<ConfigForm/>` from `@bridgething/client/form`, on a default page when the app has no `settings` page.
67
+ - `permissions` grants `geo` and `net.proxy`.
68
+ - `art.heroPx` and `art.thumbPx` are the sizes artwork arrives at.
69
+ - `settings` names the page built from `settings/`, capped at 1 MiB. It talks to the companion app through `@bridgething/client/settings` and embeds `<ConfigForm/>` for the declared fields. A button on it that acts on those fields awaits the form's `flush()` first. The companion keeps the page loaded between visits, so it rereads device state in `settings.onRefresh`. It is also where a user picks a file for the app (`settings.pick` + `settings.blob.put`), which the webapp then shows through `client.blob`.
70
+ - `extension` declares the desktop-side Deno process and the host permissions it gets.
71
+
72
+ **`apps/<slug>/catalog.json`** is the store listing and nothing else: `author`, `homepage`, `source`, an `icon` override, `screenshots`, and `min_libbridgething_version` (the oldest daemon the app works against). Never restate anything the manifest already says. An app that ships an extension must carry its `github.com` repo url in `source`, because the store shows that link next to the host permissions it is asking for.
73
+
74
+ **`apps/<slug>/CHANGELOG.md`** carries a `## <version>` section per release, which becomes that version's changelog in the catalog.
75
+
76
+ ## Adding and shipping
77
+
78
+ ```sh
79
+ bun run new <slug> [--extension | --launcher | --overlay]
80
+ bun run shot <slug> [--replace | --name <label>]
81
+ bun run bump <slug> <major | minor | patch | x.y.z> [-m "note"]
82
+ bun run check
83
+ ```
84
+
85
+ `new` scaffolds into `apps/<slug>`. Never add an app by hand: the scaffold generates the uuid, writes the store listing, wires the dev server to a connected device, and lands the tsconfig and scripts CI expects.
86
+
87
+ `shot` captures the kiosk over CDP into `apps/<slug>/screenshots/`. The image fronts chromium's debugging port on `bridgething.local:9222`, so it needs nothing but the device plugged in. Up to six, filename order, first is the store card.
88
+
89
+ `bump` writes `public/manifest.json` and `package.json` together and opens the changelog section. Editing one without the other fails the build.
90
+
91
+ `check` is the whole gate and is exactly what CI runs: typecheck, build, bundle, generate the catalog, validate it against `catalog.v1` and the cross-reference invariants. Run it before claiming anything works.
92
+
93
+ Pushing to main publishes. `bun run publish --dry-run` assembles the exact bytes that would be pushed, into `site/`, without pushing.
94
+
95
+ ## Publishing rules that are not negotiable
96
+
97
+ - **A published version is immutable.** `published.json` on the `gh-pages` branch records the sha256 clients verify against. Changing an app means a new version, always. `check` fails a pull request that changed an app without bumping it.
98
+ - **Bundles are reproducible.** Timestamps are flattened before zipping so the same source gives the same digest. Do not add anything nondeterministic to a build.
99
+ - **`site/` is generated and gitignored.** Nothing about a release belongs on main.
100
+ - **CORS is a requirement.** A source must serve its catalog and its downloads with `Access-Control-Allow-Origin: *` or browser clients cannot read it. GitHub Pages does; most self-hosting does not.
101
+ - **`base_url` in `source.json` is where this catalog is served.** If you forked this repo, point it at your own pages site and give every app a fresh uuid, or you publish app ids that belong to someone else. `check` refuses when it disagrees with the origin remote.
102
+
103
+ ## Comments
104
+
105
+ Terse, lowercase, self-contained, non-obvious WHY only, 120 columns. No historical cross-references, no pointers at other files, no emdashes. Most code here needs none.
@@ -0,0 +1,57 @@
1
+ # **SOURCE_NAME**
2
+
3
+ **SOURCE_DESCRIPTION**
4
+
5
+ Webapps for the Spotify Car Thing running [bridgething](https://bridgething.com).
6
+
7
+ ## First run
8
+
9
+ 1. Push this repo to `https://github.com/__REPO_OWNER__/__REPO_NAME__`.
10
+ 2. In **Settings > Pages**, set the source to **Deploy from a branch**, branch `gh-pages`, folder `/ (root)`.
11
+
12
+ The catalog is published to `__BASE_URL__/catalog.v1.json`, which can be submitted to <bridgething.com/apps>
13
+
14
+ ## Develop
15
+
16
+ ```sh
17
+ bun run dev # develop the app against a connected bridgething instance
18
+ bun run dev:device # show the dev server on the car thing screen
19
+ bun run push # build and install to the device
20
+ bun run check # ensure the catalog is valid
21
+ ```
22
+
23
+ With more than one app in `apps/` your commands must specify which one: `bun run dev __APP_SLUG__`.
24
+
25
+ Screenshot for the store listing:
26
+
27
+ ```sh
28
+ bun run shot __APP_SLUG__ # grabs what is on the screen
29
+ bun run shot __APP_SLUG__ --replace # overwrite
30
+ ```
31
+
32
+ ## Add another app
33
+
34
+ ```sh
35
+ bun run new weather # a webapp
36
+ bun run new dashboard --extension # a webapp plus a desktop-side Deno process
37
+ bun run new home --launcher # a replacement home screen
38
+ bun run new hud --overlay # a system overlay drawn over every webapp
39
+ ```
40
+
41
+ ## Ship
42
+
43
+ ```sh
44
+ bun run bump __APP_SLUG__ patch -m "Fix the wind direction arrow"
45
+ git commit -am "__APP_SLUG__: fix the wind direction arrow" && git push
46
+ ```
47
+
48
+ Pushing to main builds the apps and regenerates the catalog.
49
+
50
+ ## Agent skill
51
+
52
+ `.claude/skills/bridgething/` holds the `/bridgething` skill.
53
+
54
+ ```sh
55
+ bun run skills # refresh it from the published create-bridgething
56
+ bun run skills --check # check whether it is behind
57
+ ```
@@ -0,0 +1 @@
1
+ 1.4.0
@@ -0,0 +1,28 @@
1
+ name: ci
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ check:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+
19
+ - uses: oven-sh/setup-bun@v2
20
+ with:
21
+ bun-version-file: .bun-version
22
+
23
+ - run: bun install --frozen-lockfile
24
+
25
+ - name: build, bundle and validate the catalog
26
+ env:
27
+ CHECK_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }}
28
+ run: bun run check