create-octane-xplat 0.9.0 → 0.10.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.
package/scaffold.mjs ADDED
@@ -0,0 +1,531 @@
1
+ // Scaffold manifest + compose/apply shared by create-octane-xplat and the
2
+ // CLI's `xplat add`. template/package.json is the canonical store for dep
3
+ // versions and script bodies — targets name which keys they own; unclaimed
4
+ // files, deps, and scripts are part of the base scaffold. Keeping versions in
5
+ // template/package.json means scripts/bump-versions.mjs keeps working
6
+ // unchanged at release time.
7
+ import {
8
+ cpSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
21
+
22
+ /** Scaffoldable targets. `files` entries are template-relative paths —
23
+ * directories copy recursively. `deps`/`devDeps`/`scripts` name keys whose
24
+ * values come from template/package.json. `typecheck` contributes one clause
25
+ * to the generated typecheck script. */
26
+ export const targets = {
27
+ web: {
28
+ label: 'Web (Vite)',
29
+ files: ['index.html', 'vite.config.ts', 'src/main.web.tsrx'],
30
+ scripts: ['dev', 'build'],
31
+ devDeps: ['@octanejs/vite-plugin'],
32
+ },
33
+ ios: {
34
+ label: 'iOS (NativeScript)',
35
+ requires: ['native-shared'],
36
+ files: ['App_Resources/iOS'],
37
+ scripts: ['dev:ios', 'build:ios'],
38
+ devDeps: ['@nativescript/ios'],
39
+ note: 'needs macOS + Xcode — `pnpm xplat doctor` checks the toolchain',
40
+ },
41
+ android: {
42
+ label: 'Android (NativeScript)',
43
+ requires: ['native-shared'],
44
+ files: ['App_Resources/Android'],
45
+ scripts: ['dev:android', 'build:android'],
46
+ devDeps: ['@nativescript/android'],
47
+ note: 'needs Android SDK + a compatible JDK — `pnpm xplat doctor` checks the toolchain',
48
+ },
49
+ // Shared NativeScript machinery pulled in by ios/android. Not user-
50
+ // selectable on its own. `patches` names the packages whose
51
+ // patchedDependencies entries in pnpm-workspace.yaml belong to this
52
+ // fragment — pnpm hard-fails installs on unused patch entries, so a
53
+ // scaffold must not carry patches for deps it doesn't install.
54
+ 'native-shared': {
55
+ hidden: true,
56
+ patches: [
57
+ '@nativescript/core',
58
+ '@nativescript/types',
59
+ 'css',
60
+ '@nativescript/vite',
61
+ '@nativescript-community/octane',
62
+ '@nativescript-community/vite-octane',
63
+ ],
64
+ files: [
65
+ 'nativescript.config.ts',
66
+ 'vite.config.native.mts',
67
+ 'tsconfig.native.json',
68
+ 'references.d.ts',
69
+ 'src/main.ts',
70
+ ],
71
+ deps: [
72
+ '@nativescript-community/gesturehandler',
73
+ '@nativescript-community/octane',
74
+ '@nativescript-community/ui-drawer',
75
+ '@nativescript/core',
76
+ '@octane-xplat/effects',
77
+ '@valor/nativescript-websockets',
78
+ ],
79
+ devDeps: [
80
+ '@nativescript-community/vite-octane',
81
+ '@nativescript/types',
82
+ '@nativescript/vite',
83
+ 'nativescript',
84
+ ],
85
+ typecheck: 'tsrx-tsc --noEmit -p tsconfig.native.json',
86
+ },
87
+ }
88
+
89
+ export const selectableTargets = Object.keys(targets).filter((id) => !targets[id].hidden)
90
+
91
+ /** `pnpm create` without --targets and `xplat add`'s prompt default. */
92
+ export const defaultTargets = ['web', 'ios', 'android']
93
+
94
+ const BASE_TYPECHECK = 'tsrx-tsc --noEmit'
95
+
96
+ // package.json keys every scaffolded app keeps regardless of target set —
97
+ // the targets' `scripts` lists own the rest.
98
+ const BASE_SCRIPTS = ['xplat', 'lint', 'lint:fix']
99
+
100
+ /** Expand `requires` edges, dependencies first. Throws on unknown ids. */
101
+ export function resolveTargets(ids) {
102
+ const resolved = []
103
+ const visit = (id) => {
104
+ const t = targets[id]
105
+ if (!t) {
106
+ throw new Error(`unknown target "${id}" — expected one of: ${selectableTargets.join(', ')}`)
107
+ }
108
+
109
+ for (const req of t.requires ?? []) {
110
+ visit(req)
111
+ }
112
+
113
+ if (!resolved.includes(id)) {
114
+ resolved.push(id)
115
+ }
116
+ }
117
+
118
+ for (const id of ids) {
119
+ visit(id)
120
+ }
121
+
122
+ return resolved
123
+ }
124
+
125
+ const templateManifest = () => JSON.parse(readFileSync(join(TEMPLATE_DIR, 'package.json'), 'utf8'))
126
+
127
+ /** Which target claims a template-relative path (entries may be dirs). */
128
+ function ownerOf(path) {
129
+ for (const [id, t] of Object.entries(targets)) {
130
+ for (const entry of t.files) {
131
+ if (path === entry || path.startsWith(entry + '/')) {
132
+ return id
133
+ }
134
+ }
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ function walk(dir, prefix = '') {
141
+ const out = []
142
+ for (const name of readdirSync(dir)) {
143
+ if (name === 'node_modules') {
144
+ continue
145
+ }
146
+
147
+ const rel = prefix ? `${prefix}/${name}` : name
148
+ if (statSync(join(dir, name)).isDirectory()) {
149
+ out.push(...walk(join(dir, name), rel))
150
+ } else {
151
+ out.push(rel)
152
+ }
153
+ }
154
+
155
+ return out
156
+ }
157
+
158
+ function pickKeys(source, names, label) {
159
+ const out = {}
160
+ for (const name of names) {
161
+ if (source[name] === undefined) {
162
+ throw new Error(`template/package.json has no ${label} entry "${name}"`)
163
+ }
164
+
165
+ out[name] = source[name]
166
+ }
167
+
168
+ return out
169
+ }
170
+
171
+ /**
172
+ * The package.json a given target set produces. With every target selected
173
+ * this is identical to template/package.json.
174
+ */
175
+ export function composeManifest(ids) {
176
+ const resolved = resolveTargets(ids)
177
+ const tpl = templateManifest()
178
+ const manifest = { ...tpl }
179
+
180
+ for (const section of ['dependencies', 'devDependencies']) {
181
+ const key = section === 'dependencies' ? 'deps' : 'devDeps'
182
+ const wanted = new Set()
183
+ for (const id of resolved) {
184
+ for (const name of targets[id][key] ?? []) {
185
+ wanted.add(name)
186
+ }
187
+ }
188
+
189
+ const next = {}
190
+ for (const [name, spec] of Object.entries(tpl[section] ?? {})) {
191
+ // Unclaimed entries are base; claimed entries follow their target.
192
+ const claimed = Object.keys(targets).some((id) => (targets[id][key] ?? []).includes(name))
193
+
194
+ if (!claimed || wanted.has(name)) {
195
+ next[name] = spec
196
+ }
197
+ }
198
+
199
+ manifest[section] = next
200
+ }
201
+
202
+ const wantedScripts = new Set(BASE_SCRIPTS)
203
+ for (const id of resolved) {
204
+ for (const name of targets[id].scripts ?? []) {
205
+ wantedScripts.add(name)
206
+ }
207
+ }
208
+
209
+ const scripts = pickKeys(tpl.scripts ?? {}, [...wantedScripts].sort(), 'script')
210
+ const typecheck = [BASE_TYPECHECK]
211
+ for (const id of resolved) {
212
+ if (targets[id].typecheck) {
213
+ typecheck.push(targets[id].typecheck)
214
+ }
215
+ }
216
+
217
+ scripts.typecheck = typecheck.join(' && ')
218
+ // Keep the template's key order: scripts sit where they were.
219
+ manifest.scripts = {}
220
+ for (const name of Object.keys(tpl.scripts ?? {})) {
221
+ if (name === 'typecheck' || wantedScripts.has(name)) {
222
+ manifest.scripts[name] = scripts[name]
223
+ }
224
+ }
225
+
226
+ if (!resolved.includes('native-shared')) {
227
+ // "main" points at the native entry — meaningless without it.
228
+ delete manifest.main
229
+ }
230
+
231
+ return manifest
232
+ }
233
+
234
+ // --- pnpm-workspace.yaml patchedDependencies --------------------------------
235
+ // Patch entries look like ` octane@0.6.3: <path>` or
236
+ // ` "@nativescript/core@9.1.2": <path>`, each preceded by a comment block.
237
+ // Ownership comes from each target's `patches` list; unclaimed entries are
238
+ // base and ship in every scaffold.
239
+ const PATCH_ENTRY = /^\s+"?(@?[\w./-]+@[\w.-]+)"?:\s+\S+\s*$/
240
+
241
+ const patchPkg = (key) => key.replace(/@[^@]+$/, '')
242
+
243
+ // Split a pnpm-workspace.yaml into per-package patch blocks (leading comment
244
+ // lines included) so compose can drop entries and apply can transplant them.
245
+ function parsePatchEntries(lines) {
246
+ const entries = []
247
+ let inPatches = false
248
+ let pending = []
249
+ for (const line of lines) {
250
+ if (/^\S/.test(line)) {
251
+ inPatches = line.startsWith('patchedDependencies')
252
+ pending = []
253
+ continue
254
+ }
255
+
256
+ if (!inPatches) {
257
+ continue
258
+ }
259
+
260
+ const m = line.match(PATCH_ENTRY)
261
+ if (m) {
262
+ entries.push({ name: patchPkg(m[1]), comments: pending, line })
263
+ pending = []
264
+ } else {
265
+ pending.push(line)
266
+ }
267
+ }
268
+
269
+ return entries
270
+ }
271
+
272
+ /** The pnpm-workspace.yaml a given target set produces. */
273
+ export function composeWorkspaceYaml(ids) {
274
+ const resolved = new Set(resolveTargets(ids))
275
+ const drop = new Set()
276
+ for (const [id, t] of Object.entries(targets)) {
277
+ if (!resolved.has(id)) {
278
+ for (const name of t.patches ?? []) {
279
+ drop.add(name)
280
+ }
281
+ }
282
+ }
283
+
284
+ const lines = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8').split('\n')
285
+ const out = []
286
+ let inPatches = false
287
+ let pending = []
288
+ for (const line of lines) {
289
+ if (/^\S/.test(line)) {
290
+ out.push(...pending, line)
291
+ pending = []
292
+ inPatches = line.startsWith('patchedDependencies')
293
+ continue
294
+ }
295
+
296
+ if (!inPatches) {
297
+ out.push(line)
298
+ continue
299
+ }
300
+
301
+ const m = line.match(PATCH_ENTRY)
302
+ if (!m) {
303
+ pending.push(line) // comment/blank inside the section
304
+ continue
305
+ }
306
+
307
+ if (drop.has(patchPkg(m[1]))) {
308
+ pending = []
309
+ continue
310
+ }
311
+
312
+ out.push(...pending, line)
313
+ pending = []
314
+ }
315
+
316
+ out.push(...pending)
317
+ return out.join('\n')
318
+ }
319
+
320
+ /**
321
+ * Add the patch entries that `enabledIds` owns to the app's
322
+ * pnpm-workspace.yaml. Entries already present (by package name, any
323
+ * version) are kept. Returns true if the file changed.
324
+ */
325
+ function ensurePatchEntries(appRoot, enabledIds) {
326
+ const needed = new Set()
327
+ for (const id of enabledIds) {
328
+ for (const name of targets[id].patches ?? []) {
329
+ needed.add(name)
330
+ }
331
+ }
332
+
333
+ if (!needed.size) {
334
+ return false
335
+ }
336
+
337
+ const yamlPath = join(appRoot, 'pnpm-workspace.yaml')
338
+ const appText = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : ''
339
+ const present = new Set(parsePatchEntries(appText.split('\n')).map((e) => e.name))
340
+ const missing = [...needed].filter((name) => !present.has(name))
341
+ if (!missing.length) {
342
+ return false
343
+ }
344
+
345
+ const tplText = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8')
346
+ const blocks = parsePatchEntries(tplText.split('\n'))
347
+ .filter((e) => missing.includes(e.name))
348
+ .map((e) => [...e.comments, e.line].join('\n'))
349
+
350
+ if (!appText.trim()) {
351
+ writeFileSync(yamlPath, composeWorkspaceYaml(enabledIds))
352
+ return true
353
+ }
354
+
355
+ let next = appText
356
+ const lines = next.split('\n')
357
+ const sectionStart = lines.findIndex((l) => l.startsWith('patchedDependencies'))
358
+ if (sectionStart === -1) {
359
+ next = `${appText.trimEnd()}\npatchedDependencies:\n${blocks.join('\n')}\n`
360
+ } else {
361
+ // Insert before the next top-level key (or at EOF).
362
+ let insertAt = lines.length
363
+ for (let i = sectionStart + 1; i < lines.length; i++) {
364
+ if (/^\S/.test(lines[i])) {
365
+ insertAt = i
366
+ break
367
+ }
368
+ }
369
+
370
+ lines.splice(insertAt, 0, ...blocks.join('\n').split('\n'))
371
+ next = lines.join('\n')
372
+ }
373
+
374
+ writeFileSync(yamlPath, next.endsWith('\n') ? next : next + '\n')
375
+ return true
376
+ }
377
+
378
+ /** Write a scaffold for `ids` into `dir` (must not exist or be empty). */
379
+ export function composeTargets(ids, dir) {
380
+ const resolved = new Set(resolveTargets(ids))
381
+ mkdirSync(dir, { recursive: true })
382
+
383
+ for (const rel of walk(TEMPLATE_DIR)) {
384
+ if (rel === 'package.json' || rel === 'pnpm-workspace.yaml') {
385
+ continue // generated below
386
+ }
387
+
388
+ const owner = ownerOf(rel)
389
+ if (owner && !resolved.has(owner)) {
390
+ continue
391
+ }
392
+
393
+ const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel)
394
+ mkdirSync(dirname(dest), { recursive: true })
395
+ cpSync(join(TEMPLATE_DIR, rel), dest, { recursive: true })
396
+ }
397
+
398
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(composeManifest(ids), null, '\t') + '\n')
399
+ writeFileSync(join(dir, 'pnpm-workspace.yaml'), composeWorkspaceYaml(ids))
400
+ return [...resolved]
401
+ }
402
+
403
+ const readJson = (file) => {
404
+ try {
405
+ return JSON.parse(readFileSync(file, 'utf8'))
406
+ } catch {
407
+ return null
408
+ }
409
+ }
410
+
411
+ /** Sentinel-based enablement check, mirroring the CLI's targets.mjs. */
412
+ export function targetEnabled(appRoot, id) {
413
+ if (id === 'web') {
414
+ return (
415
+ existsSync(join(appRoot, 'vite.config.ts')) || existsSync(join(appRoot, 'vite.config.mts'))
416
+ )
417
+ }
418
+
419
+ if (id === 'native-shared') {
420
+ return existsSync(join(appRoot, 'nativescript.config.ts'))
421
+ }
422
+
423
+ // ios/android: declared runtime devDep is the canonical signal — App_Resources
424
+ // alone doesn't reach the bundler config.
425
+ const manifest = readJson(join(appRoot, 'package.json')) ?? {}
426
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies }
427
+ const runtime = { ios: '@nativescript/ios', android: '@nativescript/android' }[id]
428
+ return runtime ? typeof declared[runtime] === 'string' : false
429
+ }
430
+
431
+ /** Copy a template file/dir into the app unless the destination exists. */
432
+ function copyMissing(rel, appRoot, report) {
433
+ const src = join(TEMPLATE_DIR, rel)
434
+ const dest = join(appRoot, rel === 'gitignore' ? '.gitignore' : rel)
435
+ if (existsSync(dest)) {
436
+ report.skipped.push(rel)
437
+ return
438
+ }
439
+
440
+ mkdirSync(dirname(dest), { recursive: true })
441
+ cpSync(src, dest, { recursive: true })
442
+ report.copied.push(rel)
443
+ }
444
+
445
+ /**
446
+ * Enable `id` (plus unmet `requires`) inside an existing app at `appRoot`.
447
+ * Additive only: existing files, scripts, and dep entries are skipped, never
448
+ * overwritten. Returns a report for the caller to render.
449
+ */
450
+ export function applyTarget(appRoot, id) {
451
+ const manifestPath = join(appRoot, 'package.json')
452
+ const raw = readFileSync(manifestPath, 'utf8')
453
+ const manifest = JSON.parse(raw)
454
+ const tpl = templateManifest()
455
+ const report = {
456
+ enabled: [],
457
+ already: [],
458
+ copied: [],
459
+ skipped: [],
460
+ scriptsAdded: [],
461
+ scriptsSkipped: [],
462
+ depsAdded: [],
463
+ }
464
+
465
+ let touched = false
466
+ for (const tid of resolveTargets([id])) {
467
+ if (targetEnabled(appRoot, tid)) {
468
+ report.already.push(tid)
469
+ continue
470
+ }
471
+
472
+ report.enabled.push(tid)
473
+ const t = targets[tid]
474
+ for (const rel of t.files) {
475
+ copyMissing(rel, appRoot, report)
476
+ }
477
+
478
+ for (const [section, key] of [
479
+ ['dependencies', 'deps'],
480
+ ['devDependencies', 'devDeps'],
481
+ ]) {
482
+ for (const name of t[key] ?? []) {
483
+ manifest[section] ??= {}
484
+ if (manifest[section][name] === undefined) {
485
+ manifest[section][name] = tpl[section][name]
486
+ report.depsAdded.push(name)
487
+ }
488
+ }
489
+ }
490
+
491
+ manifest.scripts ??= {}
492
+ for (const name of t.scripts ?? []) {
493
+ if (manifest.scripts[name] === undefined) {
494
+ manifest.scripts[name] = tpl.scripts[name]
495
+ report.scriptsAdded.push(name)
496
+ } else if (manifest.scripts[name] !== tpl.scripts[name]) {
497
+ report.scriptsSkipped.push(name)
498
+ }
499
+ }
500
+
501
+ if (t.typecheck) {
502
+ const current = manifest.scripts.typecheck ?? ''
503
+ if (!current.includes(t.typecheck)) {
504
+ manifest.scripts.typecheck = current ? `${current} && ${t.typecheck}` : t.typecheck
505
+ report.scriptsAdded.push('typecheck')
506
+ }
507
+ }
508
+
509
+ if (tid === 'native-shared' && manifest.main === undefined) {
510
+ manifest.main = tpl.main
511
+ }
512
+
513
+ touched = true
514
+ }
515
+
516
+ // Newly enabled targets may carry patch entries the scaffolded
517
+ // pnpm-workspace.yaml never had — without them the next install misses the
518
+ // framework patches. enabled+already covers the yaml-missing case, where
519
+ // the file is regenerated for the whole enabled set.
520
+ if (ensurePatchEntries(appRoot, [...report.enabled, ...report.already])) {
521
+ report.patchesAdded = true
522
+ }
523
+
524
+ if (touched) {
525
+ // Match the file's existing indent so diffs stay minimal.
526
+ const indent = raw.match(/\n(\s+)"/)?.[1] ?? '\t'
527
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, indent) + '\n')
528
+ }
529
+
530
+ return report
531
+ }
@@ -12,53 +12,122 @@ below prevent that; `pnpm lint` enforces most of them.
12
12
 
13
13
  ## Non-negotiables
14
14
 
15
- 1. **One element vocabulary per file.** Shared code renders only
16
- `@octane-xplat/ui` components. Platform divergence lives in whole files
17
- — `.web` for browser code, `.mobile` for shared iOS/Android variants, OS-specific suffixes, and the unsuffixed native default —
18
- chosen by the bundler when something imports `./Foo`. Never branch on
19
- platform inside JSX (`Platform.OS`, conditional imports, `typeof
20
- document` checks). Platform-authentic widgets live behind
21
- `@octane-xplat/ui/ios`, `/android`, and `/web` — importing a subpath
22
- outside a matching suffix file fails the other platform's build (the
23
- `xplat/platform-subpath-import` lint enforces it).
24
- 2. **No DOM globals in shared code.** `document`, `window`, `localStorage`,
25
- DOM events — all web-only. Device capabilities (clipboard, storage,
26
- permissions, connectivity, geolocation, …) come from
27
- `@octane-xplat/platform`.
28
- 3. **Styles:** `className` for anything static; `style` objects only for
29
- values that change while the app runs. Shared CSS must use the portable
30
- subset — the native build warns on declarations it drops
31
- (`position: fixed`, `margin: auto`, `box-shadow`, …).
32
- 4. **Hooks and JSX live in `.tsrx` (or `.tsx`).** Plain `.ts` is for
33
- non-component code and may not import `.tsrx`. Import `.tsrx` files with
34
- the explicit extension: `import { App } from './App.tsrx'`.
35
- 5. **State = `octane/signals`.** `signal$()` for module-level shared state,
36
- `useSignal$()` for component-local, `derived$()` for computed, `query$()`
37
- for async data. Name signal variables with a `$` suffix (`count$`,
38
- `user$`) — the compiler uses the suffix to preserve reactive reads on
39
- native. Every module that reads or writes signals needs a runtime
40
- `import 'octane/signals'` (or `octane/signals/client`). `query$` reads
41
- suspend — put `.get()` under `@try`/`@pending`/`@catch`, or use
42
- `.snapshot()`/`.latest()` for non-suspending reads. Never write to a
43
- shared signal during render — screen-scoped data keyed by route params
44
- gets a component-local `query$`, not a shared selector signal.
45
- 6. **One `octane` per app.** Don't add a second renderer or duplicate the
46
- package — two copies break the reconciler without a helpful error.
15
+ **One element vocabulary per file.** Shared code renders only
16
+ `@octane-xplat/ui` components. Platform divergence lives in whole files
17
+ — `.web` for browser code, `.mobile` for shared iOS/Android variants, OS-specific suffixes, and the unsuffixed native default —
18
+ chosen by the bundler when something imports `./Foo`. Never branch on
19
+ platform inside JSX (`Platform.OS`, conditional imports, checks for browser globals). Platform-authentic widgets live behind
20
+ `@octane-xplat/ui/ios`, `/android`, and `/web` — importing a subpath
21
+ outside a matching suffix file fails the other platform's build (the
22
+ `xplat/platform-subpath-import` lint enforces it).
23
+ **No DOM globals in shared code.** `document`, `window`, `localStorage`,
24
+ DOM events — all web-only. Device capabilities (clipboard, storage,
25
+ permissions, connectivity) come from
26
+ `@octane-xplat/platform`; plugin-backed features use their leaf packages.
27
+ **Styles:** `className` for anything static; `style` objects only for
28
+ values that change while the app runs. Shared CSS must use the portable
29
+ subset — the native build warns on declarations it drops
30
+ (`position: fixed`, `margin: auto`, `box-shadow`, …).
31
+ **Hooks and JSX live in `.tsrx` (or `.tsx`).** Plain `.ts` is for
32
+ non-component code and may not import `.tsrx`. Import `.tsrx` files with
33
+ the explicit extension: `import { App } from './App.tsrx'`.
34
+ **Keep signal names suffixed with `$`.** Use `useSignal$` for state owned
35
+ by a component. Runtime signal imports let the compiler track reads.
36
+
37
+ ```tsrx
38
+ import { useSignal$ } from 'octane/signals/client'
39
+ import { Pressable, Text } from '@octane-xplat/ui'
40
+
41
+ export function Counter() {
42
+ const count$ = useSignal$(0)
43
+ return <Pressable onPress={() => count$.set((value) => value + 1)}>
44
+ <Text>{count$.get()}</Text>
45
+ </Pressable>
46
+ }
47
+ ```
48
+
49
+ Use `signal$` for shared module state and `derived$` for a computed value.
50
+ Write from event handlers, not while rendering.
51
+
52
+ ```ts
53
+ import { signal$, derived$ } from 'octane/signals'
54
+
55
+ export const travelers$ = signal$(1)
56
+ export const seatsRemaining$ = derived$(() => 4 - travelers$.get())
57
+ export function addTraveler() {
58
+ travelers$.set((count) => count + 1)
59
+ }
60
+ ```
61
+
62
+ `query$` loads asynchronous data. A screen-owned query keeps its selection
63
+ local to the screen. `.latest()` reads without suspending; use the data
64
+ guide's boundary pattern when reading with `.get()`.
65
+
66
+ ```tsrx
67
+ import { query$ } from 'octane/signals'
68
+ import { Text } from '@octane-xplat/ui'
69
+
70
+ export function TripTitle(props: { id: string; loadTitle: (id: string) => Promise<string> }) {
71
+ const title$ = query$(() => props.id, (id) => props.loadTitle(id))
72
+ return <Text>{title$.latest() ?? 'Loading trip…'}</Text>
73
+ }
74
+ ```
75
+
76
+ **One `octane` per app.** Don't add a second renderer or duplicate the
77
+ package — two copies break the reconciler without a helpful error.
78
+
79
+ For component, routing, service, and styling usage, use the references below.
80
+ They show the supported names and matching code examples.
47
81
 
48
82
  ## Compiler rules that surprise
49
83
 
50
84
  - `.tsrx` files can't declare `async` top-level functions — the compiler
51
85
  treats them as async components and the native build fails.
52
- - Literal `@{` in JSX text parses as a code block. Write `{'@'}{expr}` to
53
- render a literal `@`.
86
+ - Literal `@{` in JSX text parses as a code block. Render the sign separately:
87
+
88
+ ```tsrx
89
+ import { Text } from '@octane-xplat/ui'
90
+
91
+ export function Handle() { return <Text>{'@'}{'traveler'}</Text> }
92
+ ```
93
+
54
94
  - Effect dependencies are inferred from closure reads. An effect that only
55
95
  writes (refs, DOM) and never reads its driving prop won't re-run —
56
- declare deps explicitly: `useLayoutEffect(fn, [props.x])`.
57
- - Input handlers must be idempotent — `onInput`/`onChange` can dispatch
58
- more than once per user action on web.
96
+ declare dependencies explicitly when needed:
97
+
98
+ ```tsrx
99
+ import { useLayoutEffect } from 'octane'
100
+ import { Text } from '@octane-xplat/ui'
101
+
102
+ export function Progress(props: { percent: number }) {
103
+ useLayoutEffect(() => { console.log('Progress changed') }, [props.percent])
104
+ return <Text>{props.percent}%</Text>
105
+ }
106
+ ```
107
+
108
+ - Input handlers must be idempotent: store the supplied value rather than
109
+ incrementing a counter for each input event. Web may dispatch more than once.
110
+
111
+ ```tsrx
112
+ import { useState } from 'octane'
113
+ import { TextInput } from '@octane-xplat/ui'
114
+
115
+ export function NameField() {
116
+ const [name, setName] = useState('')
117
+ return <TextInput value={name} onChange={setName} accessibilityLabel="Name" />
118
+ }
119
+ ```
120
+
59
121
  - `.mobile.tsrx`/`.ios.tsrx`/`.android.tsrx` files and unsuffixed native-default files containing JSX must
60
122
  start with `/** @jsxImportSource @nativescript-community/octane */` on
61
- line 1 — nothing may precede it.
123
+ line 1 — nothing may precede it:
124
+
125
+ ```tsrx
126
+ /** @jsxImportSource @nativescript-community/octane */
127
+ import { Text } from '@octane-xplat/ui'
128
+
129
+ export function NativeGreeting() { return <Text>Hello</Text> }
130
+ ```
62
131
 
63
132
  ## Verify
64
133