create-octane-xplat 0.14.0 → 0.16.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.
Files changed (47) hide show
  1. package/README.md +78 -1
  2. package/compatibility.json +199 -0
  3. package/compatibility.ts +109 -0
  4. package/dist/compatibility.js +66 -0
  5. package/dist/index.js +132 -0
  6. package/dist/scaffold.js +439 -0
  7. package/index.ts +198 -0
  8. package/library-template/AGENTS.md +18 -0
  9. package/{LICENSE → library-template/LICENSE} +6 -6
  10. package/library-template/README.md +84 -0
  11. package/library-template/gitignore +5 -0
  12. package/library-template/package.json +92 -0
  13. package/library-template/pnpm-workspace.yaml +156 -0
  14. package/library-template/src/Badge.tsx +21 -0
  15. package/library-template/src/Badge.web.tsx +21 -0
  16. package/library-template/src/index.ts +3 -0
  17. package/library-template/src/index.web.ts +3 -0
  18. package/library-template/src/normalizeBadgeLabel.ts +12 -0
  19. package/library-template/src/props.ts +14 -0
  20. package/library-template/tests/normalizeBadgeLabel.test.ts +6 -0
  21. package/library-template/tests/packed-consumer.mjs +156 -0
  22. package/library-template/tsconfig.envs.json +17 -0
  23. package/library-template/tsconfig.json +27 -0
  24. package/library-template/tsconfig.types.json +11 -0
  25. package/library-template/tsrx-typegen.json +16 -0
  26. package/package.json +27 -8
  27. package/{scaffold.mjs → scaffold.ts} +149 -47
  28. package/template/.agents/skills/xplat/SKILL.md +11 -3
  29. package/template/.agents/skills/xplat/references/components.md +2 -2
  30. package/template/.agents/skills/xplat/references/navigation.md +9 -5
  31. package/template/.agents/skills/xplat/references/platform.md +2 -2
  32. package/template/AGENTS.md +47 -0
  33. package/template/App_Resources/Android/app.gradle +1 -1
  34. package/template/package.json +35 -32
  35. package/template/pnpm-workspace.yaml +73 -11
  36. package/template/src/app.spec.tsrx +80 -2
  37. package/template/src/app.web.test.tsx +20 -0
  38. package/template/src/failure-reporting.spec.ts +11 -0
  39. package/template/src/main.web.tsrx +1 -0
  40. package/template/src/native-runtime.spec.ts +18 -0
  41. package/template/src/test-setup.web.ts +17 -0
  42. package/template/tsconfig.json +32 -8
  43. package/template/vite.config.ts +2 -2
  44. package/template/vitest.web.config.mts +13 -0
  45. package/index.mjs +0 -129
  46. package/template/.oxlintrc.json +0 -25
  47. package/template/tsconfig.native.json +0 -18
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "create-octane-xplat",
3
- "version": "0.14.0",
4
- "description": "Scaffold a minimal @octane-xplat/ui app — one Octane codebase for web + iOS + Android",
3
+ "version": "0.16.0",
4
+ "description": "Scaffold Octane Xplat apps and cross-platform libraries",
5
5
  "keywords": [
6
+ "component-library",
6
7
  "create",
7
8
  "cross-platform",
8
9
  "init",
@@ -20,21 +21,39 @@
20
21
  "directory": "packages/create"
21
22
  },
22
23
  "bin": {
23
- "create-octane-xplat": "./index.mjs"
24
+ "create-octane-xplat": "./dist/index.js"
24
25
  },
25
26
  "files": [
26
- "index.mjs",
27
- "scaffold.mjs",
28
- "template"
27
+ "index.ts",
28
+ "scaffold.ts",
29
+ "compatibility.ts",
30
+ "compatibility.json",
31
+ "dist",
32
+ "template",
33
+ "library-template"
29
34
  ],
30
35
  "type": "module",
31
36
  "exports": {
32
- "./scaffold": "./scaffold.mjs"
37
+ "./scaffold": "./dist/scaffold.js",
38
+ "./compatibility": {
39
+ "types": "./compatibility.ts",
40
+ "default": "./dist/compatibility.js"
41
+ },
42
+ "./compatibility.json": "./compatibility.json"
33
43
  },
34
44
  "publishConfig": {
35
45
  "access": "public"
36
46
  },
37
47
  "dependencies": {
38
- "@clack/prompts": "1.8.1"
48
+ "@clack/prompts": "1.8.1",
49
+ "yaml": "2.9.1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "24.10.1",
53
+ "vite": "8.3.0"
54
+ },
55
+ "scripts": {
56
+ "build": "vite build",
57
+ "typecheck": "tsc -p tsconfig.json"
39
58
  }
40
59
  }
@@ -1,9 +1,6 @@
1
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.
2
+ // CLI's `xplat add`. compatibility.json owns version pins; the generated
3
+ // templates own script bodies and the dependency set for each starter.
7
4
  import {
8
5
  cpSync,
9
6
  existsSync,
@@ -14,27 +11,40 @@ import {
14
11
  writeFileSync,
15
12
  } from 'node:fs'
16
13
 
17
- import { dirname, join } from 'node:path'
14
+ import { basename, dirname, join, resolve } from 'node:path'
18
15
  import { fileURLToPath } from 'node:url'
16
+ import { parseDocument } from 'yaml'
17
+ import { ensureCompatibilityCatalog } from './compatibility.ts'
19
18
 
20
- export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
19
+ const moduleDir = dirname(fileURLToPath(import.meta.url))
20
+ // dist/ emit sits one level deeper than the root sources it mirrors.
21
+ const pkgRoot = basename(moduleDir) === 'dist' ? join(moduleDir, '..') : moduleDir
22
+ export const TEMPLATE_DIR = join(pkgRoot, 'template')
23
+ export const LIBRARY_TEMPLATE_DIR = join(pkgRoot, 'library-template')
21
24
 
22
25
  /** Scaffoldable targets. `files` entries are template-relative paths —
23
26
  * directories copy recursively. `deps`/`devDeps`/`scripts` name keys whose
24
27
  * values come from template/package.json. `typecheck` contributes one clause
25
28
  * to the generated typecheck script. */
26
- export const targets = {
29
+ export const targets: Record<string, any> = {
27
30
  web: {
28
31
  label: 'Web (Vite)',
29
- files: ['index.html', 'vite.config.ts', 'src/main.web.tsrx'],
30
- scripts: ['dev', 'build'],
31
- devDeps: ['@octanejs/vite-plugin'],
32
+ files: [
33
+ 'index.html',
34
+ 'vite.config.ts',
35
+ 'src/main.web.tsrx',
36
+ 'vitest.web.config.mts',
37
+ 'src/app.web.test.tsx',
38
+ 'src/test-setup.web.ts',
39
+ ],
40
+ scripts: ['dev', 'build', 'test', 'test:web'],
41
+ devDeps: ['@octanejs/vite-plugin', 'jsdom'],
32
42
  },
33
43
  ios: {
34
44
  label: 'iOS (NativeScript)',
35
45
  requires: ['native-shared'],
36
46
  files: ['App_Resources/iOS'],
37
- scripts: ['dev:ios', 'test:ios', 'build:ios'],
47
+ scripts: ['dev:ios', 'build:ios', 'test:ios'],
38
48
  devDeps: ['@nativescript/ios'],
39
49
  note: 'needs macOS + Xcode — `pnpm xplat doctor` checks the toolchain',
40
50
  },
@@ -42,7 +52,7 @@ export const targets = {
42
52
  label: 'Android (NativeScript)',
43
53
  requires: ['native-shared'],
44
54
  files: ['App_Resources/Android'],
45
- scripts: ['dev:android', 'test:android', 'build:android'],
55
+ scripts: ['dev:android', 'build:android', 'test:android'],
46
56
  devDeps: ['@nativescript/android'],
47
57
  note: 'needs Android SDK + a compatible JDK — `pnpm xplat doctor` checks the toolchain',
48
58
  },
@@ -64,23 +74,17 @@ export const targets = {
64
74
  files: [
65
75
  'nativescript.config.ts',
66
76
  'vite.config.native.mts',
67
- 'tsconfig.native.json',
68
77
  'references.d.ts',
69
78
  'src/main.ts',
70
- // On-device Vitest lane — `ns test` builds src/test.ts under
71
- // --env.unitTesting and runs every src/**/*.spec file on a
72
- // simulator/emulator through the upstream runner.
79
+ 'vitest.config.mts',
73
80
  'src/test.ts',
74
81
  'src/app.spec.tsrx',
75
- 'vitest.config.mts',
82
+ 'src/native-runtime.spec.ts',
83
+ 'src/failure-reporting.spec.ts',
76
84
  ],
77
85
  deps: [
78
- '@nativescript-community/gesturehandler',
79
86
  '@nativescript-community/octane',
80
- '@nativescript-community/ui-drawer',
81
87
  '@nativescript/core',
82
- '@octane-xplat/effects',
83
- '@octane-xplat/image',
84
88
  '@valor/nativescript-websockets',
85
89
  ],
86
90
  devDeps: [
@@ -89,9 +93,8 @@ export const targets = {
89
93
  '@nativescript/unit-test-runner',
90
94
  '@nativescript/vite',
91
95
  'nativescript',
92
- 'vitest',
93
96
  ],
94
- typecheck: 'tsrx-tsc --noEmit -p tsconfig.native.json',
97
+ typecheck: 'xplat typecheck',
95
98
  },
96
99
  }
97
100
 
@@ -100,15 +103,15 @@ export const selectableTargets = Object.keys(targets).filter((id) => !targets[id
100
103
  /** `pnpm create` without --targets and `xplat add`'s prompt default. */
101
104
  export const defaultTargets = ['web', 'ios', 'android']
102
105
 
103
- const BASE_TYPECHECK = 'tsrx-tsc --noEmit'
106
+ const BASE_TYPECHECK = 'xplat typecheck'
104
107
 
105
108
  // package.json keys every scaffolded app keeps regardless of target set —
106
109
  // the targets' `scripts` lists own the rest.
107
- const BASE_SCRIPTS = ['xplat', 'lint', 'lint:fix']
110
+ const BASE_SCRIPTS = ['xplat', 'lint', 'lint:fix', 'format']
108
111
 
109
112
  /** Expand `requires` edges, dependencies first. Throws on unknown ids. */
110
113
  export function resolveTargets(ids) {
111
- const resolved = []
114
+ const resolved: string[] = []
112
115
  const visit = (id) => {
113
116
  const t = targets[id]
114
117
  if (!t) {
@@ -147,7 +150,7 @@ function ownerOf(path) {
147
150
  }
148
151
 
149
152
  function walk(dir, prefix = '') {
150
- const out = []
153
+ const out: string[] = []
151
154
  for (const name of readdirSync(dir)) {
152
155
  if (name === 'node_modules') {
153
156
  continue
@@ -165,7 +168,7 @@ function walk(dir, prefix = '') {
165
168
  }
166
169
 
167
170
  function pickKeys(source, names, label) {
168
- const out = {}
171
+ const out: Record<string, string> = {}
169
172
  for (const name of names) {
170
173
  if (source[name] === undefined) {
171
174
  throw new Error(`template/package.json has no ${label} entry "${name}"`)
@@ -184,11 +187,11 @@ function pickKeys(source, names, label) {
184
187
  export function composeManifest(ids) {
185
188
  const resolved = resolveTargets(ids)
186
189
  const tpl = templateManifest()
187
- const manifest = { ...tpl }
190
+ const manifest: Record<string, any> = { ...tpl }
188
191
 
189
192
  for (const section of ['dependencies', 'devDependencies']) {
190
193
  const key = section === 'dependencies' ? 'deps' : 'devDeps'
191
- const wanted = new Set()
194
+ const wanted = new Set<string>()
192
195
  for (const id of resolved) {
193
196
  for (const name of targets[id][key] ?? []) {
194
197
  wanted.add(name)
@@ -216,14 +219,14 @@ export function composeManifest(ids) {
216
219
  }
217
220
 
218
221
  const scripts = pickKeys(tpl.scripts ?? {}, [...wantedScripts].sort(), 'script')
219
- const typecheck = [BASE_TYPECHECK]
222
+ const typecheck: string[] = [BASE_TYPECHECK]
220
223
  for (const id of resolved) {
221
224
  if (targets[id].typecheck) {
222
225
  typecheck.push(targets[id].typecheck)
223
226
  }
224
227
  }
225
228
 
226
- scripts.typecheck = typecheck.join(' && ')
229
+ scripts.typecheck = [...new Set(typecheck)].join(' && ')
227
230
  // Keep the template's key order: scripts sit where they were.
228
231
  manifest.scripts = {}
229
232
  for (const name of Object.keys(tpl.scripts ?? {})) {
@@ -252,9 +255,9 @@ const patchPkg = (key) => key.replace(/@[^@]+$/, '')
252
255
  // Split a pnpm-workspace.yaml into per-package patch blocks (leading comment
253
256
  // lines included) so compose can drop entries and apply can transplant them.
254
257
  function parsePatchEntries(lines) {
255
- const entries = []
258
+ const entries: any[] = []
256
259
  let inPatches = false
257
- let pending = []
260
+ let pending: string[] = []
258
261
  for (const line of lines) {
259
262
  if (/^\S/.test(line)) {
260
263
  inPatches = line.startsWith('patchedDependencies')
@@ -281,7 +284,7 @@ function parsePatchEntries(lines) {
281
284
  /** The pnpm-workspace.yaml a given target set produces. */
282
285
  export function composeWorkspaceYaml(ids) {
283
286
  const resolved = new Set(resolveTargets(ids))
284
- const drop = new Set()
287
+ const drop = new Set<string>()
285
288
  for (const [id, t] of Object.entries(targets)) {
286
289
  if (!resolved.has(id)) {
287
290
  for (const name of t.patches ?? []) {
@@ -291,9 +294,9 @@ export function composeWorkspaceYaml(ids) {
291
294
  }
292
295
 
293
296
  const lines = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8').split('\n')
294
- const out = []
297
+ const out: string[] = []
295
298
  let inPatches = false
296
- let pending = []
299
+ let pending: string[] = []
297
300
  for (const line of lines) {
298
301
  if (/^\S/.test(line)) {
299
302
  out.push(...pending, line)
@@ -323,7 +326,18 @@ export function composeWorkspaceYaml(ids) {
323
326
  }
324
327
 
325
328
  out.push(...pending)
326
- return out.join('\n')
329
+ const doc = parseDocument(out.join('\n'))
330
+ const manifest = composeManifest(ids)
331
+ const used = new Set([
332
+ ...Object.keys(manifest.dependencies),
333
+ ...Object.keys(manifest.devDependencies),
334
+ ])
335
+ for (const name of Object.keys(doc.toJS().catalogs?.xplat ?? {})) {
336
+ if (!used.has(name)) {
337
+ doc.deleteIn(['catalogs', 'xplat', name])
338
+ }
339
+ }
340
+ return String(doc)
327
341
  }
328
342
 
329
343
  /**
@@ -384,13 +398,33 @@ function ensurePatchEntries(appRoot, enabledIds) {
384
398
  return true
385
399
  }
386
400
 
387
- /** Write a scaffold for `ids` into `dir` (must not exist or be empty). */
401
+ /**
402
+ * The tsconfig.json a given target set produces. One program covers every
403
+ * platform suffix (moduleSuffixes is the fixed precedence order); a web-only
404
+ * scaffold just drops the native ambient root and entrypoint.
405
+ */
406
+ export function composeTsconfig(ids) {
407
+ const resolved = new Set(resolveTargets(ids))
408
+ const config = JSON.parse(readFileSync(join(TEMPLATE_DIR, 'tsconfig.json'), 'utf8'))
409
+ if (!resolved.has('native-shared')) {
410
+ delete config.files
411
+ config.compilerOptions.types = (config.compilerOptions.types ?? []).filter(
412
+ (t) => t !== '@nativescript/types',
413
+ )
414
+
415
+ config.exclude = [...new Set([...(config.exclude ?? []), 'src/main.ts'])]
416
+ }
417
+
418
+ return JSON.stringify(config, null, '\t') + '\n'
419
+ }
420
+
421
+ /** Write a scaffold with tests for `ids` into `dir` (must not exist or be empty). */
388
422
  export function composeTargets(ids, dir) {
389
423
  const resolved = new Set(resolveTargets(ids))
390
424
  mkdirSync(dir, { recursive: true })
391
425
 
392
426
  for (const rel of walk(TEMPLATE_DIR)) {
393
- if (rel === 'package.json' || rel === 'pnpm-workspace.yaml') {
427
+ if (rel === 'package.json' || rel === 'pnpm-workspace.yaml' || rel === 'tsconfig.json') {
394
428
  continue // generated below
395
429
  }
396
430
 
@@ -406,9 +440,43 @@ export function composeTargets(ids, dir) {
406
440
 
407
441
  writeFileSync(join(dir, 'package.json'), JSON.stringify(composeManifest(ids), null, '\t') + '\n')
408
442
  writeFileSync(join(dir, 'pnpm-workspace.yaml'), composeWorkspaceYaml(ids))
443
+ writeFileSync(join(dir, 'tsconfig.json'), composeTsconfig(ids))
409
444
  return [...resolved]
410
445
  }
411
446
 
447
+ /** Write the cross-platform library starter into `dir`. */
448
+ export function composeLibrary(dir, packageName = basename(resolve(dir))) {
449
+ if (
450
+ packageName.length > 214 ||
451
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(packageName)
452
+ ) {
453
+ throw new Error(
454
+ `invalid npm package name "${packageName}" — use lowercase letters, numbers, dots, underscores, or hyphens`,
455
+ )
456
+ }
457
+
458
+ mkdirSync(dir, { recursive: true })
459
+ for (const rel of walk(LIBRARY_TEMPLATE_DIR)) {
460
+ if (rel === 'package.json') {
461
+ continue
462
+ }
463
+
464
+ const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel)
465
+ mkdirSync(dirname(dest), { recursive: true })
466
+ const source = join(LIBRARY_TEMPLATE_DIR, rel)
467
+ if (/\.(?:md|ts|tsx|mjs|json)$/.test(rel)) {
468
+ const content = readFileSync(source, 'utf8').replaceAll('octane-xplat-library', packageName)
469
+ writeFileSync(dest, content)
470
+ } else {
471
+ cpSync(source, dest, { recursive: true })
472
+ }
473
+ }
474
+
475
+ const manifest = JSON.parse(readFileSync(join(LIBRARY_TEMPLATE_DIR, 'package.json'), 'utf8'))
476
+ manifest.name = packageName
477
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, null, '\t') + '\n')
478
+ }
479
+
412
480
  const readJson = (file) => {
413
481
  try {
414
482
  return JSON.parse(readFileSync(file, 'utf8'))
@@ -431,7 +499,7 @@ export function targetEnabled(appRoot, id) {
431
499
 
432
500
  // ios/android: declared runtime devDep is the canonical signal — App_Resources
433
501
  // alone doesn't reach the bundler config.
434
- const manifest = readJson(join(appRoot, 'package.json')) ?? {}
502
+ const manifest: Record<string, any> = readJson(join(appRoot, 'package.json')) ?? {}
435
503
  const declared = { ...manifest.dependencies, ...manifest.devDependencies }
436
504
  const runtime = { ios: '@nativescript/ios', android: '@nativescript/android' }[id]
437
505
  return runtime ? typeof declared[runtime] === 'string' : false
@@ -451,6 +519,33 @@ function copyMissing(rel, appRoot, report) {
451
519
  report.copied.push(rel)
452
520
  }
453
521
 
522
+ /**
523
+ * Fold a native-capable app's sources into the one tsconfig program: the
524
+ * manifest (tsconfig.envs.json) is gone — one program covers every platform
525
+ * suffix — so enabling native means wiring the ambient declaration file and
526
+ * the entrypoint into the app's own config.
527
+ */
528
+ function enableNativeInTsconfig(appRoot) {
529
+ const path = join(appRoot, 'tsconfig.json')
530
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
531
+ const config = readJson(path)
532
+ if (!config || typeof config !== 'object') {
533
+ return false
534
+ }
535
+
536
+ const indent = text.match(/\n(\s+)"/)?.[1] ?? '\t'
537
+ config.files = [...new Set(['references.d.ts', ...(config.files ?? [])])]
538
+ config.exclude = (config.exclude ?? []).filter((e) => e !== 'src/main.ts')
539
+
540
+ const types = config.compilerOptions?.types
541
+ if (config.compilerOptions && Array.isArray(types) && !types.includes('@nativescript/types')) {
542
+ config.compilerOptions.types = [...types, '@nativescript/types']
543
+ }
544
+
545
+ writeFileSync(path, JSON.stringify(config, null, indent) + '\n')
546
+ return true
547
+ }
548
+
454
549
  /**
455
550
  * Enable `id` (plus unmet `requires`) inside an existing app at `appRoot`.
456
551
  * Additive only: existing files, scripts, and dep entries are skipped, never
@@ -459,9 +554,9 @@ function copyMissing(rel, appRoot, report) {
459
554
  export function applyTarget(appRoot, id) {
460
555
  const manifestPath = join(appRoot, 'package.json')
461
556
  const raw = readFileSync(manifestPath, 'utf8')
462
- const manifest = JSON.parse(raw)
557
+ const manifest: Record<string, any> = JSON.parse(raw)
463
558
  const tpl = templateManifest()
464
- const report = {
559
+ const report: Record<string, any> = {
465
560
  enabled: [],
466
561
  already: [],
467
562
  copied: [],
@@ -515,8 +610,15 @@ export function applyTarget(appRoot, id) {
515
610
  }
516
611
  }
517
612
 
518
- if (tid === 'native-shared' && manifest.main === undefined) {
519
- manifest.main = tpl.main
613
+ if (tid === 'native-shared') {
614
+ if (manifest.main === undefined) {
615
+ manifest.main = tpl.main
616
+ }
617
+
618
+ // Native sources join the app's single typecheck program — pull the
619
+ // ambient declaration entry into the roots and un-exclude the
620
+ // NativeScript entry that a web-only scaffold skips.
621
+ report.tsconfigUpdated = enableNativeInTsconfig(appRoot)
520
622
  }
521
623
 
522
624
  touched = true
@@ -529,12 +631,12 @@ export function applyTarget(appRoot, id) {
529
631
  if (ensurePatchEntries(appRoot, [...report.enabled, ...report.already])) {
530
632
  report.patchesAdded = true
531
633
  }
634
+ ensureCompatibilityCatalog(appRoot, manifest)
532
635
 
533
636
  if (touched) {
534
637
  // Match the file's existing indent so diffs stay minimal.
535
638
  const indent = raw.match(/\n(\s+)"/)?.[1] ?? '\t'
536
639
  writeFileSync(manifestPath, JSON.stringify(manifest, null, indent) + '\n')
537
640
  }
538
-
539
641
  return report
540
642
  }
@@ -119,22 +119,30 @@ export function NameField() {
119
119
  ```
120
120
 
121
121
  - `.mobile.tsrx`/`.ios.tsrx`/`.android.tsrx` files and unsuffixed native-default files containing JSX must
122
- start with `/** @jsxImportSource @nativescript-community/octane */` on
122
+ start with `/** @jsxImportSource @octane-xplat/jsx */` on
123
123
  line 1 — nothing may precede it:
124
124
 
125
125
  ```tsrx
126
- /** @jsxImportSource @nativescript-community/octane */
126
+ /** @jsxImportSource @octane-xplat/jsx */
127
127
  import { Text } from '@octane-xplat/ui'
128
128
 
129
129
  export function NativeGreeting() { return <Text>Hello</Text> }
130
130
  ```
131
131
 
132
+ - Browser JSX leaves select DOM types explicitly on line 1. These pragmas
133
+ select types; the build keeps the existing web and native renderers:
134
+
135
+ ```tsx
136
+ /** @jsxImportSource @octane-xplat/jsx/dom */
137
+ export function BrowserGreeting() { return <p>Hello</p> }
138
+ ```
139
+
132
140
  ## Verify
133
141
 
134
142
  | Command | Catches |
135
143
  | ----------------------------------- | ------------------------------------------------------------------------ |
136
144
  | `pnpm lint` | vocabulary / DOM-global / style violations — its output is authoritative |
137
- | `pnpm typecheck` | both web and native TS configs |
145
+ | `pnpm typecheck` | `tsconfig.json` + every environment declared in `tsconfig.envs.json` |
138
146
  | `pnpm dev` | web behavior |
139
147
  | `pnpm dev:ios` / `pnpm dev:android` | the platform where behavior actually differs |
140
148
 
@@ -141,7 +141,7 @@ Choose platform widgets when you want the OS's own controls. Import them only
141
141
  in the matching platform file. This iOS example belongs in `Preference.ios.tsrx`:
142
142
 
143
143
  ```tsrx
144
- /** @jsxImportSource @nativescript-community/octane */
144
+ /** @jsxImportSource @octane-xplat/jsx */
145
145
  import { useState } from 'octane'
146
146
  import { UISwitch } from '@octane-xplat/ui/ios'
147
147
 
@@ -154,7 +154,7 @@ export function Preference() {
154
154
  The Android equivalent belongs in `Preference.android.tsrx`:
155
155
 
156
156
  ```tsrx
157
- /** @jsxImportSource @nativescript-community/octane */
157
+ /** @jsxImportSource @octane-xplat/jsx */
158
158
  import { useState } from 'octane'
159
159
  import { MaterialSwitch } from '@octane-xplat/ui/android'
160
160
 
@@ -5,7 +5,8 @@ web turns it into a URL, while native presents the screen in the app's navigatio
5
5
  Put components in `.tsrx` files.
6
6
 
7
7
  ```tsrx
8
- import { defineRoutes, registerRoutes, Text } from '@octane-xplat/ui'
8
+ import { Text } from '@octane-xplat/ui'
9
+ import { defineRoutes, registerRoutes } from '@octane-xplat/ui/routing'
9
10
 
10
11
  function Home() { return <Text>Home</Text> }
11
12
  function Settings() { return <Text>Settings</Text> }
@@ -20,7 +21,8 @@ After registering `settings`, use `NavLink` for a screen link or `pushRoute`
20
21
  in an event handler. `Link` accepts a URL, including an external website.
21
22
 
22
23
  ```tsrx
23
- import { HStack, Link, NavLink, Pressable, Text, pushRoute } from '@octane-xplat/ui'
24
+ import { HStack, Link, NavLink, Pressable, Text } from '@octane-xplat/ui'
25
+ import { pushRoute } from '@octane-xplat/ui/routing'
24
26
 
25
27
  export function SettingsLinks() {
26
28
  const settings = { stack: 'root', name: 'settings', params: {} }
@@ -36,7 +38,8 @@ Use `useRoute` to read the current screen and `useCanGoBack` to decide whether
36
38
  to offer a back button. Pass the same stack name to `popRoute`.
37
39
 
38
40
  ```tsrx
39
- import { Pressable, Text, useRoute, useCanGoBack, popRoute } from '@octane-xplat/ui'
41
+ import { Pressable, Text } from '@octane-xplat/ui'
42
+ import { useRoute, useCanGoBack, popRoute } from '@octane-xplat/ui/routing'
40
43
 
41
44
  export function NavigationStatus() {
42
45
  const route = useRoute('root')
@@ -56,7 +59,7 @@ on web with a warning; do not rely on it to carry application state. With a
56
59
  registered `/projects/:id` screen, navigate using the project ID:
57
60
 
58
61
  ```ts
59
- import { pushRoute } from '@octane-xplat/ui'
62
+ import { pushRoute } from '@octane-xplat/ui/routing'
60
63
 
61
64
  pushRoute({ stack: 'root', name: 'projects/:id', params: { id: '42' } })
62
65
  ```
@@ -66,7 +69,8 @@ initial content; pushing a route into that stack opens it inside the tab pane.
66
69
  The screen below assumes `settings` is already registered.
67
70
 
68
71
  ```tsrx
69
- import { Tabs, Text, Pressable, pushRoute } from '@octane-xplat/ui'
72
+ import { Tabs, Text, Pressable } from '@octane-xplat/ui'
73
+ import { pushRoute } from '@octane-xplat/ui/routing'
70
74
 
71
75
  export function AppTabs() {
72
76
  return <Tabs tabs={[
@@ -61,13 +61,13 @@ import { Scanner } from './parts/Scanner'
61
61
  export function ScanScreen() { return <Scanner /> }
62
62
  ```
63
63
 
64
- Native JSX files start with the NativeScript pragma on line 1. Import native
64
+ Native JSX files start with the shared JSX facade pragma on line 1. Import native
65
65
  APIs from `@nativescript/core`, and read app lifecycle state inside the call
66
66
  rather than during module initialization. This example belongs in
67
67
  `Status.mobile.tsrx`:
68
68
 
69
69
  ```tsrx
70
- /** @jsxImportSource @nativescript-community/octane */
70
+ /** @jsxImportSource @octane-xplat/jsx */
71
71
  import { Application } from '@nativescript/core'
72
72
  import { Pressable, Text } from '@octane-xplat/ui'
73
73
 
@@ -8,6 +8,53 @@ Before writing code, read `.agents/skills/xplat/SKILL.md` (or invoke the
8
8
  `xplat` skill). Its invariants are enforced by `pnpm lint` — violations
9
9
  usually still compile but silently break on one platform.
10
10
 
11
+ ## Framework versions
12
+
13
+ Keep framework/compiler/native tool pins in the supplied `catalog:xplat`.
14
+ Upgrade the version set and patches together, then run the app's checks.
15
+ Within a multi-package workspace, upgrade covers all packages. Preserve
16
+ app-specific dependencies and reconcile custom-patch conflicts before retrying:
17
+
18
+ ```sh
19
+ pnpm dlx @octane-xplat/cli@latest upgrade --check
20
+ pnpm dlx @octane-xplat/cli@latest upgrade
21
+ pnpm xplat doctor
22
+ pnpm typecheck
23
+ ```
24
+
25
+ ## JSX types
26
+
27
+ The app's `tsconfig.json` selects shared native JSX types. Browser JSX leaves
28
+ select DOM types on their first line; keep runtime imports from `octane`:
29
+
30
+ ```tsx
31
+ /** @jsxImportSource @octane-xplat/jsx/dom */
32
+ export function BrowserMessage() {
33
+ return <p>Ready</p>
34
+ }
35
+ ```
36
+
37
+ Native leaves can explicitly select the same default with
38
+ `/** @jsxImportSource @octane-xplat/jsx */`. Do not replace it with a renderer
39
+ pragma. The facade supplies types; Vite still selects the platform renderer.
40
+
41
+ ## App testing
42
+
43
+ The starter includes managed runners, sample tests, configuration, and scripts
44
+ for its enabled platforms. Adapt the samples to the app and run the relevant
45
+ commands:
46
+
47
+ ```sh
48
+ pnpm test:web
49
+ pnpm test:ios -- --device DEVICE_UDID
50
+ pnpm test:android -- --device DEVICE_SERIAL
51
+ ```
52
+
53
+ Only commands for enabled platforms are added. Web tests use jsdom; phone
54
+ tests need a selected simulator or device. Report targets actually run and
55
+ distinguish document checks from browser layout or OS input checks. See the
56
+ [testing guide](https://octane-xplat.goddardai.org/verify/testing#add-automated-tests).
57
+
11
58
  ## Stable automation targets
12
59
 
13
60
  ```tsx
@@ -4,7 +4,7 @@ dependencies {
4
4
  }
5
5
 
6
6
  android {
7
- compileSdkVersion 35
7
+ compileSdkVersion 36
8
8
  buildToolsVersion "35"
9
9
  // ndkVersion ""
10
10