janela 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +10 -2
  2. package/api/index.d.ts +11 -1
  3. package/bin/janela.mjs +33 -2
  4. package/package.json +1 -1
  5. package/templates/index.html +224 -53
  6. package/templates/janela.conf.json +2 -2
  7. package/templates/react/files/index.html +1 -0
  8. package/templates/react/files/janela.conf.json +2 -2
  9. package/templates/react/files/src/App.tsx +154 -37
  10. package/templates/react/files/src/main.tsx +1 -0
  11. package/templates/react/files/src/styles.css +137 -0
  12. package/templates/react/files/src-host/main.ts +82 -0
  13. package/templates/solid/files/index.html +1 -0
  14. package/templates/solid/files/janela.conf.json +2 -2
  15. package/templates/solid/files/src/App.tsx +145 -35
  16. package/templates/solid/files/src/main.tsx +1 -0
  17. package/templates/solid/files/src/styles.css +137 -0
  18. package/templates/solid/files/src-host/main.ts +82 -0
  19. package/templates/svelte/files/index.html +1 -0
  20. package/templates/svelte/files/janela.conf.json +2 -2
  21. package/templates/svelte/files/src/App.svelte +116 -27
  22. package/templates/svelte/files/src/main.ts +1 -0
  23. package/templates/svelte/files/src/styles.css +137 -0
  24. package/templates/svelte/files/src-host/main.ts +82 -0
  25. package/templates/vue/files/index.html +1 -0
  26. package/templates/vue/files/janela.conf.json +2 -2
  27. package/templates/vue/files/src/App.vue +99 -20
  28. package/templates/vue/files/src/main.ts +1 -0
  29. package/templates/vue/files/src/styles.css +137 -0
  30. package/templates/vue/files/src-host/main.ts +82 -0
  31. package/templates/react/files/src/App.css +0 -3
  32. package/templates/solid/files/src/App.css +0 -3
@@ -0,0 +1,137 @@
1
+ /* The janela starter's look. Shared by every template so a project scaffolded
2
+ with --template vue looks the same as one scaffolded without a framework.
3
+ All of it is yours to replace.
4
+
5
+ The window mark in App is drawn in CSS rather than shipped as an image: a
6
+ janela frontend is embedded in the binary, so every asset has to be inlined
7
+ anyway, and a window needs no artwork. */
8
+
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f6f6f7;
12
+ --card: #ffffff;
13
+ --text: #18181b;
14
+ --muted: #71717a;
15
+ --line: #e4e4e7;
16
+ --accent: #2563eb;
17
+ --accent-soft: #eff4ff;
18
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 8px 24px -12px rgb(0 0 0 / 0.12);
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #17171a;
23
+ --card: #202024;
24
+ --text: #fafafa;
25
+ --muted: #a1a1aa;
26
+ --line: #2e2e34;
27
+ --accent: #60a5fa;
28
+ --accent-soft: #1d2433;
29
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px -12px rgb(0 0 0 / 0.5);
30
+ }
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+ body {
35
+ margin: 0;
36
+ min-height: 100vh;
37
+ display: flex;
38
+ justify-content: center;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
42
+ -webkit-font-smoothing: antialiased;
43
+ }
44
+ main { width: 100%; max-width: 760px; padding: 26px 22px 32px; }
45
+
46
+ /* --- header ------------------------------------------------------- */
47
+ header { text-align: center; margin-bottom: 20px; }
48
+ .mark {
49
+ width: 56px; height: 44px; margin: 0 auto 14px;
50
+ border: 2px solid var(--text); border-radius: 8px;
51
+ display: flex; flex-direction: column; overflow: hidden;
52
+ }
53
+ .mark .bar {
54
+ height: 13px; flex: none;
55
+ border-bottom: 2px solid var(--text);
56
+ display: flex; align-items: center; gap: 3px; padding: 0 4px;
57
+ }
58
+ .mark .bar i { width: 3px; height: 3px; border-radius: 50%; background: var(--text); }
59
+ .mark .pane { flex: 1; background: linear-gradient(135deg, var(--accent), transparent 70%); opacity: 0.85; }
60
+ h1 { margin: 0; font-size: 26px; letter-spacing: -0.02em; font-weight: 650; }
61
+ .sub { margin: 5px 0 0; color: var(--muted); font-size: 13.5px; }
62
+
63
+ /* The one line that proves the whole stack: this string was produced by
64
+ TypeScript compiled to machine code, and handed to the page. */
65
+ .greeting {
66
+ margin: 0 0 20px;
67
+ padding: 11px 16px;
68
+ border: 1px solid var(--line);
69
+ border-radius: 10px;
70
+ background: var(--accent-soft);
71
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
72
+ text-align: center;
73
+ overflow-wrap: anywhere;
74
+ }
75
+
76
+ /* --- cards -------------------------------------------------------- */
77
+ /* auto-fit rather than a fixed column count: the same page is the iOS and
78
+ Android frontend, where it must read as a single column. */
79
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 13px; }
80
+ .card {
81
+ background: var(--card);
82
+ border: 1px solid var(--line);
83
+ border-radius: 12px;
84
+ padding: 15px 17px;
85
+ box-shadow: var(--shadow);
86
+ }
87
+ .card h2 {
88
+ margin: 0 0 2px;
89
+ font-size: 13px; font-weight: 600;
90
+ letter-spacing: 0.04em; text-transform: uppercase;
91
+ color: var(--muted);
92
+ }
93
+ .card.wide { grid-column: 1 / -1; }
94
+ .card p.why { margin: 0 0 11px; color: var(--muted); font-size: 12.5px; line-height: 1.45; }
95
+ .card h3 {
96
+ margin: 16px 0 6px; font-size: 11px; font-weight: 600;
97
+ letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted);
98
+ }
99
+ .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
100
+
101
+ input, button {
102
+ font: inherit;
103
+ border-radius: 8px;
104
+ border: 1px solid var(--line);
105
+ padding: 8px 12px;
106
+ background: var(--card);
107
+ color: var(--text);
108
+ transition: border-color 0.15s, background 0.15s;
109
+ }
110
+ input { min-width: 0; }
111
+ input[type="number"] { width: 5.5em; text-align: center; }
112
+ input.grow { flex: 1 1 12em; }
113
+ input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
114
+ button { cursor: pointer; font-weight: 500; }
115
+ button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
116
+ button:disabled { opacity: 0.5; cursor: default; }
117
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
118
+ button.primary:hover:not(:disabled) { filter: brightness(1.08); color: #fff; }
119
+
120
+ .result { margin: 12px 0 0; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); }
121
+ .result.filled { color: var(--text); }
122
+ pre.result { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 11em; overflow: auto; margin-top: 12px; }
123
+
124
+ /* A dot that keeps moving while a 2s command is pending. If it stalls,
125
+ the host has blocked the UI thread — which is the bug this proves is
126
+ absent. */
127
+ .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1s ease-in-out infinite; }
128
+ @keyframes pulse { 0%, 100% { opacity: 0.25; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.15); } }
129
+ @media (prefers-reduced-motion: reduce) { .pulse { animation: none; opacity: 1; } }
130
+
131
+ ul.events { list-style: none; margin: 0; padding: 0; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
132
+ ul.events li { padding: 3px 0; border-bottom: 1px dashed var(--line); }
133
+ ul.events li:last-child { border-bottom: 0; }
134
+ ul.events:empty::after { content: "nothing yet — press add"; color: var(--muted); font-family: inherit; }
135
+
136
+ footer { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; }
137
+ footer code { background: var(--card); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; font-size: 12px; }
@@ -17,9 +17,29 @@ export type AppCommands = {
17
17
  greet: (args: { name: string }) => string;
18
18
  log: (args: string) => void;
19
19
  wait: (args: { ms: number }) => string;
20
+ readFile: (args: { path: string }) => ReadResult;
21
+ openFile: () => PickResult;
22
+ setTitle: (args: { title: string }) => void;
20
23
  quit: () => void;
21
24
  };
22
25
 
26
+ /**
27
+ * Results are declared as TOTAL records rather than unions of shapes: every
28
+ * field is always present, and `error` is null on success. A discriminated
29
+ * union would read better in isolation, but the page would then have to narrow
30
+ * before touching `text` — and the point of this template is to show the type
31
+ * edge working, not to teach narrowing.
32
+ */
33
+ export type ReadResult = { ok: boolean; error: string | null; length: number; text: string };
34
+ export type PickResult = {
35
+ ok: boolean;
36
+ error: string | null;
37
+ cancelled: boolean;
38
+ path: string;
39
+ length: number;
40
+ text: string;
41
+ };
42
+
23
43
  /** Every event this app emits, and what each one carries. */
24
44
  export type AppEvents = {
25
45
  added: number;
@@ -54,6 +74,68 @@ export function setup(app: App): void {
54
74
  });
55
75
  });
56
76
 
77
+ // File I/O without freezing the window: the read happens on a worker thread
78
+ // in the shim. Use this instead of node:fs readFileSync, which blocks the
79
+ // host loop — and with it the whole UI — until the syscall returns.
80
+ app.commandAsync("readFile", (args, resolve) => {
81
+ app.readFileAsync(args.path, (err, text) => {
82
+ // Annotated, not inferred: a bare `null` in a returned literal infers as
83
+ // `null | undefined`, which does not lift into the declared
84
+ // `string | null` (scriptc SC2002).
85
+ const answer: ReadResult =
86
+ err !== null
87
+ ? { ok: false, error: err, length: 0, text: "" }
88
+ : { ok: true, error: null, length: text.length, text: text };
89
+ resolve(answer);
90
+ });
91
+ });
92
+
93
+ // The native "open" dialog, paired with the reader above — picking a file is
94
+ // what makes readFileAsync useful. commandAsync is the right shape here: the
95
+ // page's promise stays parked while the user takes as long as they like, and
96
+ // the window carries on serving other calls meanwhile.
97
+ app.commandAsync("openFile", (_args, resolve) => {
98
+ app.openFileDialog(
99
+ { title: "Pick a file", filters: [{ name: "Text", extensions: ["txt", "md"] }] },
100
+ (paths, err) => {
101
+ if (err !== undefined) {
102
+ const failed: PickResult = {
103
+ ok: false, error: err, cancelled: false, path: "", length: 0, text: "",
104
+ };
105
+ resolve(failed);
106
+ return;
107
+ }
108
+ if (paths === null) {
109
+ const cancelled: PickResult = {
110
+ ok: true, error: null, cancelled: true, path: "", length: 0, text: "",
111
+ };
112
+ resolve(cancelled);
113
+ return;
114
+ }
115
+ app.readFileAsync(paths[0], (rerr, text) => {
116
+ const answer: PickResult =
117
+ rerr !== null
118
+ ? { ok: false, error: rerr, cancelled: false, path: paths[0], length: 0, text: "" }
119
+ : {
120
+ ok: true,
121
+ error: null,
122
+ cancelled: false,
123
+ path: paths[0],
124
+ length: text.length,
125
+ text: text,
126
+ };
127
+ resolve(answer);
128
+ });
129
+ },
130
+ );
131
+ });
132
+
133
+ // The window is yours to change at runtime, not just at startup.
134
+ app.command("setTitle", (args) => {
135
+ app.setTitle(args.title);
136
+ return null;
137
+ });
138
+
57
139
  app.command("quit", (_args) => {
58
140
  app.quit();
59
141
  return null;
@@ -2,6 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5
6
  <title>__NAME__</title>
6
7
  </head>
7
8
  <body>
@@ -4,8 +4,8 @@
4
4
  "version": "0.1.0",
5
5
  "window": {
6
6
  "title": "__NAME__",
7
- "width": 640,
8
- "height": 420
7
+ "width": 820,
8
+ "height": 700
9
9
  },
10
10
  "ios": {
11
11
  "identifier": "dev.janela.__NAME__",
@@ -1,41 +1,130 @@
1
1
  <script lang="ts">
2
+ import { onDestroy, onMount } from "svelte";
2
3
  import { createClient } from "janela/api";
3
- // Type-only import of the host contract: erased at compile time, so no
4
- // host code is bundled into the page.
5
- import type { App as Contract } from "../src-host/main";
4
+ // A type-only import of the host's contract. It is erased at compile time,
5
+ // so no host code is bundled into the page — this is purely a type edge.
6
+ import type { App } from "../src-host/main";
6
7
 
7
- const client = createClient<Contract>();
8
+ const client = createClient<App>();
8
9
 
9
- let greeting = $state("…");
10
+ let greeting = $state("booting…");
10
11
  let a = $state(2);
11
12
  let b = $state(40);
12
- let sum = $state<number | null>(null);
13
+ let sum = $state("—");
14
+ let waited = $state("—");
15
+ let waiting = $state(false);
13
16
  let events = $state<string[]>([]);
17
+ let path = $state("janela.conf.json");
18
+ let title = $state("__NAME__ — renamed");
19
+ let fileOut = $state("—");
14
20
 
15
- // Backend→frontend events. The payload arrives as a value, and the generic
16
- // says which value.
17
- client.on("added", (value) => (events = [`host emitted: ${value}`, ...events]));
21
+ // The event name is checked against the contract and `value` is inferred.
22
+ // `on` returns a disposer.
23
+ const off = client.on("added", (value) => {
24
+ events = [`host emitted 'added': ${value}`, ...events];
25
+ });
26
+ onDestroy(off);
18
27
 
19
- client.invoke("greet", { name: "__NAME__" }).then((g) => (greeting = g));
28
+ onMount(async () => {
29
+ greeting = await client.invoke("greet", { name: "__NAME__" });
30
+ await client.invoke("log", "page loaded");
31
+ });
20
32
 
21
33
  async function add() {
22
- sum = await client.invoke("add", { a: Number(a), b: Number(b) });
34
+ sum = `add(${a}, ${b}) → ${await client.invoke("add", { a: Number(a), b: Number(b) })}`;
35
+ }
36
+
37
+ async function wait() {
38
+ waiting = true;
39
+ waited = "waiting… try add, it still answers";
40
+ waited = await client.invoke("wait", { ms: 2000 });
41
+ waiting = false;
42
+ }
43
+
44
+ async function read() {
45
+ const r = await client.invoke("readFile", { path });
46
+ fileOut = r.ok ? `${path} — ${r.length} chars\n\n${r.text.slice(0, 400)}` : `error: ${r.error}`;
47
+ }
48
+
49
+ // The native open dialog. The window keeps serving other commands while the
50
+ // user is deciding.
51
+ async function pick() {
52
+ const r = await client.invoke("openFile");
53
+ if (!r.ok) fileOut = `error: ${r.error}`;
54
+ else if (r.cancelled) fileOut = "cancelled";
55
+ else fileOut = `${r.path} — ${r.length} chars\n\n${r.text.slice(0, 400)}`;
23
56
  }
24
57
  </script>
25
58
 
26
- <h1>{greeting}</h1>
27
- <p>
28
- <input type="number" bind:value={a} /> +
29
- <input type="number" bind:value={b} />
30
- <button onclick={add}>add</button>
31
- {#if sum !== null}<span> = {sum}</span>{/if}
32
- </p>
33
- <ul>
34
- {#each events as e}<li>{e}</li>{/each}
35
- </ul>
36
-
37
- <style>
38
- :global(body) { font: 16px -apple-system, system-ui, sans-serif; padding: 2rem; }
39
- input { width: 5em; }
40
- ul { color: #666; font-size: 13px; }
41
- </style>
59
+ <main>
60
+ <header>
61
+ <div class="mark" aria-hidden="true">
62
+ <div class="bar"><i></i><i></i><i></i></div>
63
+ <div class="pane"></div>
64
+ </div>
65
+ <h1>__NAME__</h1>
66
+ <p class="sub">TypeScript, compiled to a native binary. No Rust, no Node, no Electron.</p>
67
+ </header>
68
+
69
+ <p class="greeting">{greeting}</p>
70
+
71
+ <div class="grid">
72
+ <section class="card">
73
+ <h2>Typed commands</h2>
74
+ <p class="why">
75
+ The name, the arguments and the result are all checked at compile time — no codegen,
76
+ because both sides are TypeScript.
77
+ </p>
78
+ <div class="row">
79
+ <input type="number" bind:value={a} aria-label="first number" />
80
+ <span aria-hidden="true">+</span>
81
+ <input type="number" bind:value={b} aria-label="second number" />
82
+ <button class="primary" onclick={add}>add</button>
83
+ </div>
84
+ <p class="result" class:filled={sum !== "—"}>{sum}</p>
85
+ <h3>Events from the host</h3>
86
+ <ul class="events">
87
+ {#each events as e, i (i)}
88
+ <li>{e}</li>
89
+ {/each}
90
+ </ul>
91
+ </section>
92
+
93
+ <section class="card">
94
+ <h2>Async without blocking</h2>
95
+ <p class="why">
96
+ The window keeps answering while this is pending: the dot keeps moving and
97
+ <strong>add</strong> still works.
98
+ </p>
99
+ <div class="row">
100
+ <button disabled={waiting} onclick={wait}>wait 2s</button>
101
+ {#if waiting}<span><span class="pulse"></span></span>{/if}
102
+ </div>
103
+ <p class="result" class:filled={waited !== "—"}>{waited}</p>
104
+ </section>
105
+
106
+ <section class="card wide">
107
+ <h2>Files and the window</h2>
108
+ <p class="why">
109
+ Reads run on a worker thread, so a large file never freezes the window. The dialog is the
110
+ real native one.
111
+ </p>
112
+ <div class="row">
113
+ <input class="grow" type="text" bind:value={path} aria-label="file to read" />
114
+ <button onclick={read}>read</button>
115
+ <button onclick={pick}>pick a file…</button>
116
+ <input class="grow" type="text" bind:value={title} aria-label="window title" />
117
+ <button onclick={() => client.invoke("setTitle", { title })}>set title</button>
118
+ </div>
119
+ <pre class="result" class:filled={fileOut !== "—"}>{fileOut}</pre>
120
+ </section>
121
+ </div>
122
+
123
+ <footer>
124
+ <span>
125
+ Edit <code>src/App.svelte</code> for the page and <code>src-host/main.ts</code> for the
126
+ commands.
127
+ </span>
128
+ <button onclick={() => client.invoke("quit")}>Quit</button>
129
+ </footer>
130
+ </main>
@@ -1,3 +1,4 @@
1
+ import "./styles.css";
1
2
  import { mount } from "svelte";
2
3
  import App from "./App.svelte";
3
4
 
@@ -0,0 +1,137 @@
1
+ /* The janela starter's look. Shared by every template so a project scaffolded
2
+ with --template vue looks the same as one scaffolded without a framework.
3
+ All of it is yours to replace.
4
+
5
+ The window mark in App is drawn in CSS rather than shipped as an image: a
6
+ janela frontend is embedded in the binary, so every asset has to be inlined
7
+ anyway, and a window needs no artwork. */
8
+
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f6f6f7;
12
+ --card: #ffffff;
13
+ --text: #18181b;
14
+ --muted: #71717a;
15
+ --line: #e4e4e7;
16
+ --accent: #2563eb;
17
+ --accent-soft: #eff4ff;
18
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 8px 24px -12px rgb(0 0 0 / 0.12);
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #17171a;
23
+ --card: #202024;
24
+ --text: #fafafa;
25
+ --muted: #a1a1aa;
26
+ --line: #2e2e34;
27
+ --accent: #60a5fa;
28
+ --accent-soft: #1d2433;
29
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px -12px rgb(0 0 0 / 0.5);
30
+ }
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+ body {
35
+ margin: 0;
36
+ min-height: 100vh;
37
+ display: flex;
38
+ justify-content: center;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
42
+ -webkit-font-smoothing: antialiased;
43
+ }
44
+ main { width: 100%; max-width: 760px; padding: 26px 22px 32px; }
45
+
46
+ /* --- header ------------------------------------------------------- */
47
+ header { text-align: center; margin-bottom: 20px; }
48
+ .mark {
49
+ width: 56px; height: 44px; margin: 0 auto 14px;
50
+ border: 2px solid var(--text); border-radius: 8px;
51
+ display: flex; flex-direction: column; overflow: hidden;
52
+ }
53
+ .mark .bar {
54
+ height: 13px; flex: none;
55
+ border-bottom: 2px solid var(--text);
56
+ display: flex; align-items: center; gap: 3px; padding: 0 4px;
57
+ }
58
+ .mark .bar i { width: 3px; height: 3px; border-radius: 50%; background: var(--text); }
59
+ .mark .pane { flex: 1; background: linear-gradient(135deg, var(--accent), transparent 70%); opacity: 0.85; }
60
+ h1 { margin: 0; font-size: 26px; letter-spacing: -0.02em; font-weight: 650; }
61
+ .sub { margin: 5px 0 0; color: var(--muted); font-size: 13.5px; }
62
+
63
+ /* The one line that proves the whole stack: this string was produced by
64
+ TypeScript compiled to machine code, and handed to the page. */
65
+ .greeting {
66
+ margin: 0 0 20px;
67
+ padding: 11px 16px;
68
+ border: 1px solid var(--line);
69
+ border-radius: 10px;
70
+ background: var(--accent-soft);
71
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
72
+ text-align: center;
73
+ overflow-wrap: anywhere;
74
+ }
75
+
76
+ /* --- cards -------------------------------------------------------- */
77
+ /* auto-fit rather than a fixed column count: the same page is the iOS and
78
+ Android frontend, where it must read as a single column. */
79
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 13px; }
80
+ .card {
81
+ background: var(--card);
82
+ border: 1px solid var(--line);
83
+ border-radius: 12px;
84
+ padding: 15px 17px;
85
+ box-shadow: var(--shadow);
86
+ }
87
+ .card h2 {
88
+ margin: 0 0 2px;
89
+ font-size: 13px; font-weight: 600;
90
+ letter-spacing: 0.04em; text-transform: uppercase;
91
+ color: var(--muted);
92
+ }
93
+ .card.wide { grid-column: 1 / -1; }
94
+ .card p.why { margin: 0 0 11px; color: var(--muted); font-size: 12.5px; line-height: 1.45; }
95
+ .card h3 {
96
+ margin: 16px 0 6px; font-size: 11px; font-weight: 600;
97
+ letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted);
98
+ }
99
+ .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
100
+
101
+ input, button {
102
+ font: inherit;
103
+ border-radius: 8px;
104
+ border: 1px solid var(--line);
105
+ padding: 8px 12px;
106
+ background: var(--card);
107
+ color: var(--text);
108
+ transition: border-color 0.15s, background 0.15s;
109
+ }
110
+ input { min-width: 0; }
111
+ input[type="number"] { width: 5.5em; text-align: center; }
112
+ input.grow { flex: 1 1 12em; }
113
+ input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
114
+ button { cursor: pointer; font-weight: 500; }
115
+ button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
116
+ button:disabled { opacity: 0.5; cursor: default; }
117
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
118
+ button.primary:hover:not(:disabled) { filter: brightness(1.08); color: #fff; }
119
+
120
+ .result { margin: 12px 0 0; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); }
121
+ .result.filled { color: var(--text); }
122
+ pre.result { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 11em; overflow: auto; margin-top: 12px; }
123
+
124
+ /* A dot that keeps moving while a 2s command is pending. If it stalls,
125
+ the host has blocked the UI thread — which is the bug this proves is
126
+ absent. */
127
+ .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1s ease-in-out infinite; }
128
+ @keyframes pulse { 0%, 100% { opacity: 0.25; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.15); } }
129
+ @media (prefers-reduced-motion: reduce) { .pulse { animation: none; opacity: 1; } }
130
+
131
+ ul.events { list-style: none; margin: 0; padding: 0; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
132
+ ul.events li { padding: 3px 0; border-bottom: 1px dashed var(--line); }
133
+ ul.events li:last-child { border-bottom: 0; }
134
+ ul.events:empty::after { content: "nothing yet — press add"; color: var(--muted); font-family: inherit; }
135
+
136
+ footer { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; }
137
+ footer code { background: var(--card); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; font-size: 12px; }
@@ -17,9 +17,29 @@ export type AppCommands = {
17
17
  greet: (args: { name: string }) => string;
18
18
  log: (args: string) => void;
19
19
  wait: (args: { ms: number }) => string;
20
+ readFile: (args: { path: string }) => ReadResult;
21
+ openFile: () => PickResult;
22
+ setTitle: (args: { title: string }) => void;
20
23
  quit: () => void;
21
24
  };
22
25
 
26
+ /**
27
+ * Results are declared as TOTAL records rather than unions of shapes: every
28
+ * field is always present, and `error` is null on success. A discriminated
29
+ * union would read better in isolation, but the page would then have to narrow
30
+ * before touching `text` — and the point of this template is to show the type
31
+ * edge working, not to teach narrowing.
32
+ */
33
+ export type ReadResult = { ok: boolean; error: string | null; length: number; text: string };
34
+ export type PickResult = {
35
+ ok: boolean;
36
+ error: string | null;
37
+ cancelled: boolean;
38
+ path: string;
39
+ length: number;
40
+ text: string;
41
+ };
42
+
23
43
  /** Every event this app emits, and what each one carries. */
24
44
  export type AppEvents = {
25
45
  added: number;
@@ -54,6 +74,68 @@ export function setup(app: App): void {
54
74
  });
55
75
  });
56
76
 
77
+ // File I/O without freezing the window: the read happens on a worker thread
78
+ // in the shim. Use this instead of node:fs readFileSync, which blocks the
79
+ // host loop — and with it the whole UI — until the syscall returns.
80
+ app.commandAsync("readFile", (args, resolve) => {
81
+ app.readFileAsync(args.path, (err, text) => {
82
+ // Annotated, not inferred: a bare `null` in a returned literal infers as
83
+ // `null | undefined`, which does not lift into the declared
84
+ // `string | null` (scriptc SC2002).
85
+ const answer: ReadResult =
86
+ err !== null
87
+ ? { ok: false, error: err, length: 0, text: "" }
88
+ : { ok: true, error: null, length: text.length, text: text };
89
+ resolve(answer);
90
+ });
91
+ });
92
+
93
+ // The native "open" dialog, paired with the reader above — picking a file is
94
+ // what makes readFileAsync useful. commandAsync is the right shape here: the
95
+ // page's promise stays parked while the user takes as long as they like, and
96
+ // the window carries on serving other calls meanwhile.
97
+ app.commandAsync("openFile", (_args, resolve) => {
98
+ app.openFileDialog(
99
+ { title: "Pick a file", filters: [{ name: "Text", extensions: ["txt", "md"] }] },
100
+ (paths, err) => {
101
+ if (err !== undefined) {
102
+ const failed: PickResult = {
103
+ ok: false, error: err, cancelled: false, path: "", length: 0, text: "",
104
+ };
105
+ resolve(failed);
106
+ return;
107
+ }
108
+ if (paths === null) {
109
+ const cancelled: PickResult = {
110
+ ok: true, error: null, cancelled: true, path: "", length: 0, text: "",
111
+ };
112
+ resolve(cancelled);
113
+ return;
114
+ }
115
+ app.readFileAsync(paths[0], (rerr, text) => {
116
+ const answer: PickResult =
117
+ rerr !== null
118
+ ? { ok: false, error: rerr, cancelled: false, path: paths[0], length: 0, text: "" }
119
+ : {
120
+ ok: true,
121
+ error: null,
122
+ cancelled: false,
123
+ path: paths[0],
124
+ length: text.length,
125
+ text: text,
126
+ };
127
+ resolve(answer);
128
+ });
129
+ },
130
+ );
131
+ });
132
+
133
+ // The window is yours to change at runtime, not just at startup.
134
+ app.command("setTitle", (args) => {
135
+ app.setTitle(args.title);
136
+ return null;
137
+ });
138
+
57
139
  app.command("quit", (_args) => {
58
140
  app.quit();
59
141
  return null;
@@ -2,6 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5
6
  <title>__NAME__</title>
6
7
  </head>
7
8
  <body>
@@ -4,8 +4,8 @@
4
4
  "version": "0.1.0",
5
5
  "window": {
6
6
  "title": "__NAME__",
7
- "width": 640,
8
- "height": 420
7
+ "width": 820,
8
+ "height": 700
9
9
  },
10
10
  "ios": {
11
11
  "identifier": "dev.janela.__NAME__",