create-techtiten-app 0.1.0 → 0.2.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/README.md CHANGED
@@ -6,6 +6,8 @@ One command for a new app, with the same structure every time:
6
6
  npx create-techtiten-app my-app
7
7
  ```
8
8
 
9
+ ![What you get: the starter phone screens, website and admin panel](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/screens.png)
10
+
9
11
  ```
10
12
  my-app/
11
13
  ├─ apps/mobile Expo (latest SDK), Expo Router, TypeScript
@@ -16,16 +18,54 @@ my-app/
16
18
  └─ AGENTS.md, STATUS.md, .gitignore (secrets blocked)
17
19
  ```
18
20
 
19
- ## It asks
20
- | Question | Options |
21
+ ## Options
22
+ Run `npx create-techtiten-app --help` any time to see them all. It asks these questions, or you pass them as flags:
23
+
24
+ | Question | Flag | Choices |
25
+ |---|---|---|
26
+ | Display name | `--name="My App"` | anything |
27
+ | Package id | `--package=com.techtiten.myapp` | permanent once on Play |
28
+ | Web app | `--web` / `--no-web` | Next.js: website + `/admin` + `/api` |
29
+ | Navigation | `--nav=stack` / `--nav=tabs` | stack, bottom tabs |
30
+ | State | `--state=zustand` / `--state=redux` | both with TanStack Query |
31
+ | Color theme | `--theme=<preset>` | `techtiten`, `ocean`, `sunset`, `royal`, `mono`, `retro` (green LCD) |
32
+ | Brand color | `--brand=#FF3366` | optional, replaces the preset's brand color |
33
+ | Font | `--font=<font>` | `system`, `inter`, `poppins`, `space-grotesk`, `pixel` (Press Start 2P) |
34
+ | Ads | `--ads` / `--no-ads` | AdMob banner, test ads in development |
35
+
36
+ Other flags: `--yes` (no questions), `--skip-install`, `--no-git`, `--help`, `--version`.
37
+
38
+ ### Defaults (what you get if you just press Enter, or pass `--yes`)
39
+ | Option | Default |
21
40
  |---|---|
22
- | Display name, package id | e.g. `Retro Games`, `com.techtiten.retrogames` |
23
- | Web | Next.js app for website + admin (basic auth) + API (health, cron jobs) |
24
- | State | Zustand + TanStack Query (default) or Redux Toolkit + TanStack Query |
25
- | Ads | AdMob banner (test ads in dev) |
41
+ | Display name | folder name in Title Case (`my-app` → `My App`) |
42
+ | Package id | `com.techtiten.` + folder name without dashes (`com.techtiten.myapp`) |
43
+ | Web app | yes |
44
+ | Navigation | stack |
45
+ | State | Zustand + TanStack Query |
46
+ | Theme | `techtiten` (light + dark, follows the phone's setting) |
47
+ | Brand color | from the theme |
48
+ | Font | system |
49
+ | Ads | no |
50
+
51
+ ### Color themes
52
+ ![Color theme presets in light and dark](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/themes.png)
53
+
54
+ Your colors end up in `packages/theme/src/colors.ts`: one file, used by the phone app and the website. Change any value there later.
55
+
56
+ ### Fonts
57
+ ![Font options](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/fonts.png)
58
+
59
+
60
+ ```
61
+ npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
62
+ npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
63
+ ```
64
+
65
+ Every generated project also has `npm run format` (Prettier), `npm run upgrade:mobile` (latest Expo SDK) and `npm run upgrade:web` (latest Next.js).
26
66
 
27
- Skip the questions: `npx create-techtiten-app my-app --yes --web --state=zustand --ads --name="My App" --package=com.techtiten.myapp`
28
- Other flags: `--no-web`, `--no-ads`, `--skip-install`, `--no-git`.
67
+ ## Add an option to this CLI
68
+ All options live in one list (`OPTIONS` in `bin/index.mjs`). Theme presets and fonts are in `bin/presets.mjs`. Add an entry and it appears in the questions, the flags and `--help`.
29
69
 
30
70
  ## Always the latest
31
71
  - The base apps come from `create-expo-app@latest` and `create-next-app@latest`, so a new project starts on the newest Expo SDK and Next.js.
@@ -35,7 +75,7 @@ Other flags: `--no-web`, `--no-ads`, `--skip-install`, `--no-git`.
35
75
  - One React version for the whole repo (web is pinned to the version Expo installed), so hooks never break.
36
76
 
37
77
  ## What you get
38
- **Mobile:** theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
78
+ **Mobile:** Reanimated 4 + Worklets + Gesture Handler (SDK-matched, root view already wrapped), theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
39
79
 
40
80
  **Web:** landing page using the same theme colors, `/admin` dashboard behind `ADMIN_USER`/`ADMIN_PASSWORD` (`src/proxy.ts`), `GET /api/health` (shared zod schema), `POST /api/cron/[job]` protected by `CRON_API_KEY` for outside schedulers (Vercel functions are short-lived).
41
81
 
package/bin/index.mjs CHANGED
@@ -1,30 +1,126 @@
1
1
  #!/usr/bin/env node
2
2
  // create-techtiten-app: scaffolds an npm-workspaces monorepo
3
- // apps/mobile Expo (latest SDK, Expo Router, typed) always
4
- // apps/web Next.js (latest): website + /admin + /api optional
5
- // packages/theme colors, spacing, radius, fonts shared
6
- // packages/shared functions, types, zod schemas shared
3
+ // apps/mobile Expo (latest SDK, Expo Router, typed) always
4
+ // apps/web Next.js (latest): website + /admin + /api optional
5
+ // packages/theme colors (preset), spacing, radius, fonts shared
6
+ // packages/shared functions, types, zod schemas shared
7
7
  // Latest versions every time: the base apps come from create-expo-app / create-next-app,
8
8
  // and native-aware packages are added with `npx expo install` (SDK-matched). Nothing native
9
9
  // is committed: android/ and ios/ are generated at build time (CNG).
10
+ //
11
+ // Every option lives in OPTIONS below: the prompts, the flags and --help are all built from it,
12
+ // so a new feature only has to be added once.
10
13
  import * as p from '@clack/prompts';
11
14
  import pc from 'picocolors';
12
15
  import { spawnSync } from 'node:child_process';
13
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
17
  import { dirname, join, relative, resolve } from 'node:path';
15
18
  import { fileURLToPath } from 'node:url';
19
+ import { FONTS, THEMES, buildColors, isHex } from './presets.mjs';
16
20
 
17
- const TPL = resolve(dirname(fileURLToPath(import.meta.url)), '../templates');
18
- const argv = process.argv.slice(2);
19
- const flag = (name) => argv.includes(`--${name}`);
20
- const opt = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
21
- const positional = argv.find((a) => !a.startsWith('--'));
22
- const YES = flag('yes') || flag('y');
21
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
+ const TPL = join(ROOT, 'templates');
23
+ const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
23
24
 
24
25
  const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
25
26
  const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
26
- const cancel = (v) => { if (p.isCancel(v)) { p.cancel('Cancelled.'); process.exit(0); } return v; };
27
+ const choices = (obj) => Object.entries(obj).map(([value, o]) => ({ value, label: o.label, hint: o.hint }));
28
+
29
+ // ---------------------------------------------------------------- options (single source of truth)
30
+ const OPTIONS = [
31
+ { key: 'name', kind: 'text', flag: '--name=<text>', help: 'App display name (under the icon)', def: (a) => titleCase(a.slug), message: 'App display name' },
32
+ {
33
+ key: 'package', kind: 'text', flag: '--package=<id>', help: 'Android package / iOS bundle id (permanent once on Play)',
34
+ def: (a) => `com.techtiten.${a.slug.replace(/-/g, '')}`, message: 'Android package / iOS bundle id',
35
+ validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v ?? '') ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
36
+ },
37
+ { key: 'web', kind: 'bool', flag: '--web / --no-web', help: 'Next.js app next to the phone app: website + /admin panel + /api backend', def: () => true, message: 'Add a Next.js app (website + admin panel + API)?' },
38
+ {
39
+ key: 'nav', kind: 'select', flag: '--nav=<stack|tabs>', help: 'Navigation layout', def: () => 'stack', message: 'Navigation',
40
+ options: [{ value: 'stack', label: 'Stack', hint: 'screens push on top of each other' }, { value: 'tabs', label: 'Bottom tabs', hint: 'Home + Settings tabs, add more in app/(tabs)' }],
41
+ },
42
+ {
43
+ key: 'state', kind: 'select', flag: '--state=<zustand|redux>', help: 'State management (both come with TanStack Query for API data)', def: () => 'zustand', message: 'State management',
44
+ options: [{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores' }, { value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' }],
45
+ },
46
+ { key: 'theme', kind: 'select', flag: '--theme=<preset>', help: 'Color theme preset (light + dark)', def: () => 'techtiten', message: 'Color theme', options: choices(THEMES) },
47
+ {
48
+ key: 'brand', kind: 'text', flag: '--brand=<#RRGGBB>', help: 'Optional: your own brand color instead of the preset\'s', def: () => '', message: 'Custom brand color? (#RRGGBB, empty = use the preset)',
49
+ validate: (v) => (!v || isHex(v) ? undefined : 'Like #00C389, or leave empty'),
50
+ },
51
+ { key: 'font', kind: 'select', flag: '--font=<font>', help: 'App font (phone app and website)', def: () => 'system', message: 'Font', options: choices(FONTS) },
52
+ { key: 'ads', kind: 'bool', flag: '--ads / --no-ads', help: 'Google AdMob banner (test ads in development)', def: () => false, message: 'Google AdMob ads?' },
53
+ ];
54
+
55
+ const TOOL_FLAGS = [
56
+ ['--yes, -y', 'Skip the questions, use defaults (or the flags you pass)'],
57
+ ['--skip-install', 'Only write files, don\'t run npm install'],
58
+ ['--no-git', 'Don\'t run git init / first commit'],
59
+ ['--help, -h', 'Show this help'],
60
+ ['--version, -v', 'Show the version'],
61
+ ];
62
+
63
+ function printHelp() {
64
+ const line = (a, b) => ` ${pc.cyan(a.padEnd(28))} ${b}`;
65
+ console.log(`
66
+ ${pc.bold('create-techtiten-app')} v${VERSION}
67
+ Scaffold an Expo app (+ optional Next.js web/admin/API) with a shared theme and shared logic.
68
+ Always uses the latest Expo SDK and Next.js.
69
+
70
+ ${pc.bold('Usage')}
71
+ npx create-techtiten-app ${pc.dim('<folder>')} ${pc.dim('[options]')}
72
+
73
+ ${pc.bold('Options')}
74
+ ${OPTIONS.map((o) => line(o.flag, `${o.help}${o.options ? '' : ''}`)).join('\n')}
75
+
76
+ ${pc.bold('Choices')}
77
+ ${line('themes', Object.entries(THEMES).map(([k, t]) => `${k} (${t.label})`).join(', '))}
78
+ ${line('fonts', Object.keys(FONTS).join(', '))}
79
+
80
+ ${pc.bold('Other')}
81
+ ${TOOL_FLAGS.map(([a, b]) => line(a, b)).join('\n')}
82
+
83
+ ${pc.bold('Examples')}
84
+ npx create-techtiten-app my-app
85
+ npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
86
+ npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
87
+ `);
88
+ }
89
+
90
+ // ---------------------------------------------------------------- args
91
+ const argv = process.argv.slice(2);
92
+ const has = (...names) => names.some((n) => argv.includes(n));
93
+ const val = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
94
+ const positional = argv.find((a) => !a.startsWith('-'));
95
+ const YES = has('--yes', '-y');
96
+
97
+ function fromFlags(o) {
98
+ if (o.kind === 'bool') return has(`--${o.key}`) ? true : has(`--no-${o.key}`) ? false : undefined;
99
+ return val(o.key);
100
+ }
101
+
102
+ function checkFlag(o, v) {
103
+ if (v === undefined) return;
104
+ if (o.kind === 'select' && !o.options.some((x) => x.value === v)) fail(`--${o.key}=${v} isn't valid. Choose: ${o.options.map((x) => x.value).join(', ')}`);
105
+ const err = o.validate?.(v);
106
+ if (err) fail(`--${o.key}: ${err}`);
107
+ }
27
108
 
109
+ function fail(msg) { p.cancel(msg); process.exit(1); }
110
+ const cancel = (v) => { if (p.isCancel(v)) fail('Cancelled.'); return v; };
111
+
112
+ async function ask(o, answers) {
113
+ const given = fromFlags(o);
114
+ checkFlag(o, given);
115
+ if (given !== undefined) return given;
116
+ const def = o.def(answers);
117
+ if (YES) return def;
118
+ if (o.kind === 'bool') return cancel(await p.confirm({ message: o.message, initialValue: def }));
119
+ if (o.kind === 'select') return cancel(await p.select({ message: o.message, options: o.options, initialValue: def }));
120
+ return cancel(await p.text({ message: o.message, initialValue: def, placeholder: def || undefined, validate: o.validate }));
121
+ }
122
+
123
+ // ---------------------------------------------------------------- helpers
28
124
  function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
29
125
  const r = spawnSync(cmd, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', shell: process.platform === 'win32', env: { ...process.env, CI: '1', EXPO_NO_TELEMETRY: '1', NEXT_TELEMETRY_DISABLED: '1', ...env } });
30
126
  if (r.status !== 0) {
@@ -34,8 +130,8 @@ function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
34
130
  return String(r.stdout ?? '');
35
131
  }
36
132
 
37
- // Copy a template folder, replacing __TOKENS__ in text files. Files named *.tpl lose the suffix;
38
- // "_gitignore" becomes ".gitignore" (npm strips real .gitignore files from published packages).
133
+ // Copy a template folder, replacing __TOKENS__ in text files. "_gitignore" -> ".gitignore",
134
+ // "_github" -> ".github" (npm drops real dot-files like .gitignore from published packages).
39
135
  function copyTemplate(from, to, vars) {
40
136
  if (!existsSync(from)) return;
41
137
  for (const name of readdirSync(from)) {
@@ -55,33 +151,64 @@ function copyTemplate(from, to, vars) {
55
151
  const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));
56
152
  const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2) + '\n');
57
153
 
154
+ // Generated files for the theme preset and the font (phone + web).
155
+ function writeThemeAndFonts(target, a) {
156
+ const colors = buildColors(a.theme, a.brand);
157
+ writeFileSync(join(target, 'packages/theme/src/colors.ts'),
158
+ `// ${THEMES[a.theme].label} preset${a.brand ? `, brand ${a.brand}` : ''} (create-techtiten-app). Edit freely.\nexport const colors = ${JSON.stringify(colors, null, 2)} as const;\n`);
159
+
160
+ const f = FONTS[a.font];
161
+ const m = join(target, 'apps/mobile/src/theme/fonts.ts');
162
+ if (!f.pkg) {
163
+ writeFileSync(m, `// System font (San Francisco / Roboto): nothing to load.\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = { regular: undefined, medium: undefined, bold: undefined, black: undefined };\nexport const useAppFonts = () => true;\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = 1;\n`);
164
+ } else {
165
+ const names = [...new Set(Object.values(f.weights))];
166
+ writeFileSync(m, `// ${f.label} from ${f.pkg}. Text/Button pick the family per weight.\nimport { useFonts, ${names.join(', ')} } from '${f.pkg}';\n\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = ${JSON.stringify(f.weights)};\n\n/** true once the font files are loaded (the root layout waits for it). */\nexport function useAppFonts() {\n const [loaded, error] = useFonts({ ${names.join(', ')} });\n return loaded || !!error;\n}\n\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = ${f.scale ?? 1};\n`);
167
+ }
168
+ if (a.web) {
169
+ const w = join(target, 'apps/web/src/app/fonts.ts');
170
+ if (!f.next) writeFileSync(w, `// System font.\nexport const appFontClass = '';\n`);
171
+ else {
172
+ const opts = { subsets: ['latin'], variable: '--font-app', display: 'swap', ...(f.nextWeights ? { weight: f.nextWeights } : {}) };
173
+ // next/font reads these options at build time and needs a plain object literal (unquoted keys).
174
+ const literal = '{ ' + Object.entries(opts).map(([k, v]) => `${k}: ${JSON.stringify(v).replace(/"/g, "'")}`).join(', ') + ' }';
175
+ writeFileSync(w, `// ${f.label} via next/font (self-hosted at build time, no layout shift).\nimport { ${f.next} } from 'next/font/google';\n\nconst font = ${f.next}(${literal});\nexport const appFontClass = font.variable;\n`);
176
+ }
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------- main
58
181
  async function main() {
182
+ if (has('--help', '-h')) return printHelp();
183
+ if (has('--version', '-v')) return console.log(VERSION);
184
+
59
185
  console.log();
60
- p.intro(pc.bgGreen(pc.black(' create-techtiten-app ')));
186
+ p.intro(pc.bgGreen(pc.black(` create-techtiten-app v${VERSION} `)));
61
187
 
62
188
  const dirName = positional ?? (YES ? 'my-app' : cancel(await p.text({ message: 'Project folder name', placeholder: 'my-app', validate: (v) => (slugify(v || '') ? undefined : 'Required') })));
63
189
  const slug = slugify(dirName);
64
190
  const target = resolve(process.cwd(), dirName);
65
- if (existsSync(target) && readdirSync(target).length) { p.cancel(`${dirName} already exists and isn't empty.`); process.exit(1); }
66
-
67
- const displayName = opt('name') ?? (YES ? titleCase(slug) : cancel(await p.text({ message: 'App display name', initialValue: titleCase(slug) })));
68
- const packageId = opt('package') ?? (YES ? `com.techtiten.${slug.replace(/-/g, '')}` : cancel(await p.text({
69
- message: 'Android package / iOS bundle id', initialValue: `com.techtiten.${slug.replace(/-/g, '')}`,
70
- validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v) ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
71
- })));
72
- const web = flag('web') ? true : flag('no-web') ? false : YES ? true : cancel(await p.confirm({ message: 'Add a Next.js app (website + admin panel + API) next to the mobile app?', initialValue: true }));
73
- const state = opt('state') ?? (YES ? 'zustand' : cancel(await p.select({
74
- message: 'State management',
75
- options: [
76
- { value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores, server data cached' },
77
- { value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' },
78
- ],
79
- })));
80
- const ads = flag('ads') ? true : flag('no-ads') ? false : YES ? false : cancel(await p.confirm({ message: 'Google AdMob ads (banner component, test ads in dev)?', initialValue: false }));
81
- const install = !flag('skip-install');
191
+ if (existsSync(target) && readdirSync(target).length) fail(`${dirName} already exists and isn't empty.`);
82
192
 
193
+ const a = { slug };
194
+ for (const o of OPTIONS) a[o.key] = await ask(o, a);
195
+ if (a.brand && !a.brand.startsWith('#')) a.brand = `#${a.brand}`;
196
+
197
+ if (!YES) {
198
+ p.note([
199
+ `Name ${a.name} (${a.package})`,
200
+ `Apps mobile${a.web ? ' + web (site, admin, API)' : ''}`,
201
+ `Navigation ${a.nav} State ${a.state}`,
202
+ `Theme ${THEMES[a.theme].label}${a.brand ? ` with brand ${a.brand}` : ''} Font ${FONTS[a.font].label}`,
203
+ `Ads ${a.ads ? 'AdMob' : 'none'}`,
204
+ ].join('\n'), 'Summary');
205
+ if (!cancel(await p.confirm({ message: 'Create it?', initialValue: true }))) fail('Cancelled.');
206
+ }
207
+
208
+ const install = !has('--skip-install');
209
+ const { name: displayName, package: packageId, web, state, ads } = a;
83
210
  const scope = `@${slug}`;
84
- const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false' };
211
+ const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false', THEME_LABEL: THEMES[a.theme].label };
85
212
  const s = p.spinner();
86
213
 
87
214
  // 1. Root + shared packages
@@ -90,7 +217,7 @@ async function main() {
90
217
  copyTemplate(join(TPL, 'packages'), join(target, 'packages'), vars);
91
218
  if (!web) rmSync(join(target, '.github/workflows/web-check.yml'), { force: true });
92
219
  const rootPkg = readJson(join(target, 'package.json'));
93
- if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; }
220
+ if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; delete rootPkg.scripts['upgrade:web']; }
94
221
  writeJson(join(target, 'package.json'), rootPkg);
95
222
 
96
223
  // 2. Mobile: latest Expo blank TypeScript app, then our layer on top
@@ -105,6 +232,7 @@ async function main() {
105
232
  rmSync(join(m, '.git'), { recursive: true, force: true });
106
233
  copyTemplate(join(TPL, 'mobile/common'), m, vars);
107
234
  writeJson(join(m, 'config/base.json'), sdkBase);
235
+ copyTemplate(join(TPL, `mobile/nav-${a.nav}`), m, vars);
108
236
  copyTemplate(join(TPL, `mobile/${state}`), m, vars);
109
237
  copyTemplate(join(TPL, ads ? 'mobile/ads' : 'mobile/noads'), m, vars);
110
238
  const mp = readJson(join(m, 'package.json'));
@@ -129,7 +257,7 @@ async function main() {
129
257
  const nc = join(w, 'next.config.ts');
130
258
  const tp = `transpilePackages: ['${scope}/theme', '${scope}/shared'],`;
131
259
  if (existsSync(nc) && /const nextConfig: NextConfig = \{/.test(readFileSync(nc, 'utf8'))) {
132
- writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (m) => `${m}\n ${tp}`));
260
+ writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (x) => `${x}\n ${tp}`));
133
261
  } else {
134
262
  writeFileSync(nc, `import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n ${tp}\n};\n\nexport default nextConfig;\n`);
135
263
  }
@@ -141,13 +269,17 @@ async function main() {
141
269
  s.stop('Next.js app created');
142
270
  }
143
271
 
272
+ writeThemeAndFonts(target, a);
273
+ const pluginList = (added = []) => [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])];
274
+
144
275
  // 4. Install: one root install, then SDK-matched native packages via expo install
145
276
  if (install) {
146
277
  s.start('Installing (one install for all workspaces)');
147
278
  run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
148
279
  s.message('Adding Expo packages matched to this SDK');
149
- const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'react-native-safe-area-context', 'react-native-screens', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
280
+ const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'expo-font', '@expo/vector-icons', 'react-native-safe-area-context', 'react-native-screens', 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
150
281
  if (ads) expoPkgs.push('react-native-google-mobile-ads');
282
+ if (FONTS[a.font].pkg) expoPkgs.push(FONTS[a.font].pkg);
151
283
  const expoArgs = ['expo', 'install', ...expoPkgs, '--', '--no-audit', '--no-fund', '--loglevel=error'];
152
284
  // expo install adds config plugins itself, but can't edit app.config.ts. Give it a temporary
153
285
  // app.json, then copy the plugins it added into config/plugins.json (read by app.config.ts).
@@ -157,7 +289,7 @@ async function main() {
157
289
  run('npx', expoArgs, m, { quiet: true });
158
290
  } catch {
159
291
  // Expo's version API unreachable (proxy/offline): use the versions bundled with the installed SDK.
160
- s.message('Expo API unreachable, using the SDK\'s bundled version list');
292
+ s.message("Expo API unreachable, using the SDK's bundled version list");
161
293
  run('npx', expoArgs, m, { quiet: true, env: { EXPO_OFFLINE: '1' } });
162
294
  }
163
295
  const added = (readJson(join(m, 'app.json')).expo.plugins ?? []).map((x) => (Array.isArray(x) ? x[0] : x));
@@ -166,8 +298,9 @@ async function main() {
166
298
  const statePkgs = state === 'redux' ? ['@reduxjs/toolkit', 'react-redux', '@tanstack/react-query'] : ['zustand', '@tanstack/react-query'];
167
299
  run('npm', ['install', ...statePkgs, '-w', `${scope}/mobile`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
168
300
  run('npm', ['install', 'zod', '-w', `${scope}/shared`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
301
+ run('npm', ['install', '-D', 'prettier', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
169
302
  // Now that the packages exist, turn their config plugins on.
170
- writeJson(join(m, 'config/plugins.json'), [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])]);
303
+ writeJson(join(m, 'config/plugins.json'), pluginList(added));
171
304
  if (web) {
172
305
  // One React for the whole repo: pin web's react/react-dom to the version Expo installed,
173
306
  // otherwise npm can install two Reacts and hooks break ("Invalid hook call").
@@ -183,16 +316,17 @@ async function main() {
183
316
  run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
184
317
  }
185
318
  s.stop('Installed');
319
+ } else {
320
+ writeJson(join(m, 'config/plugins.json'), pluginList());
186
321
  }
187
322
 
188
- if (!install) writeJson(join(m, 'config/plugins.json'), ['expo-router', './plugins/withSigning.js', ...(ads ? ['react-native-google-mobile-ads'] : [])]);
189
-
190
- // 5. Git
191
- if (!flag('no-git')) {
323
+ // 5. Git (uses your git name/email; falls back to a neutral author if none is set)
324
+ if (!has('--no-git')) {
192
325
  try {
193
326
  run('git', ['init', '-b', 'main'], target, { quiet: true });
194
327
  run('git', ['add', '-A'], target, { quiet: true });
195
- run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true });
328
+ try { run('git', ['commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
329
+ catch { run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
196
330
  } catch { p.log.warn('git init skipped (git missing or commit failed). Run git init yourself.'); }
197
331
  }
198
332
 
@@ -202,9 +336,10 @@ async function main() {
202
336
  'npm run mobile # Expo dev server (press a for Android)',
203
337
  web ? 'npm run web # Next.js on http://localhost:3000 (/admin, /api/health)' : null,
204
338
  'npm run typecheck # all workspaces',
339
+ 'npm run format # prettier',
205
340
  ads ? 'Ads need a dev build (npm run android), not Expo Go.' : null,
206
341
  ].filter(Boolean).join('\n'), 'Next steps');
207
342
  p.outro(`${pc.green('Done.')} ${displayName} is ready in ${pc.cyan(rel)}`);
208
343
  }
209
344
 
210
- main().catch((e) => { p.cancel(e.message); process.exit(1); });
345
+ main().catch((e) => fail(e.message));
@@ -0,0 +1,68 @@
1
+ // Theme presets and fonts. Add one here and it shows up in the prompts and in --help.
2
+
3
+ const neutrals = {
4
+ cool: {
5
+ light: { background: '#F7F8FA', surface: '#FFFFFF', surfaceAlt: '#EEF0F3', border: '#E2E5EA', text: '#0B0D12', textMuted: '#5B6270' },
6
+ dark: { background: '#0B0B12', surface: '#15151F', surfaceAlt: '#1D1D2A', border: '#2A2A38', text: '#ECEDF2', textMuted: '#9A9CAB' },
7
+ },
8
+ navy: {
9
+ light: { background: '#F5F8FC', surface: '#FFFFFF', surfaceAlt: '#EAF0F8', border: '#DCE4EF', text: '#0A1324', textMuted: '#55627A' },
10
+ dark: { background: '#0A1020', surface: '#121A2E', surfaceAlt: '#1A2440', border: '#26314F', text: '#E8EEF8', textMuted: '#93A0BA' },
11
+ },
12
+ warm: {
13
+ light: { background: '#FBF8F5', surface: '#FFFFFF', surfaceAlt: '#F4EEE8', border: '#EADFD5', text: '#1A120D', textMuted: '#6E6058' },
14
+ dark: { background: '#140F0C', surface: '#1E1713', surfaceAlt: '#2A201A', border: '#3A2D25', text: '#F3ECE6', textMuted: '#AE9F95' },
15
+ },
16
+ plum: {
17
+ light: { background: '#F8F7FC', surface: '#FFFFFF', surfaceAlt: '#EFECF9', border: '#E1DCF2', text: '#120E22', textMuted: '#5E5875' },
18
+ dark: { background: '#0E0B1A', surface: '#171328', surfaceAlt: '#211B37', border: '#2E2649', text: '#EEEAFB', textMuted: '#A29BBF' },
19
+ },
20
+ lcd: {
21
+ light: { background: '#C7D3A6', surface: '#BCC994', surfaceAlt: '#AFBD86', border: '#8F9D68', text: '#1E2A14', textMuted: '#4A5832' },
22
+ dark: { background: '#10160C', surface: '#182012', surfaceAlt: '#212B19', border: '#33402A', text: '#B8E07A', textMuted: '#7E9A57' },
23
+ },
24
+ };
25
+
26
+ const fixed = { danger: '#E5484D', warning: '#E9A049', success: '#2EAD5B' };
27
+
28
+ // brand per mode: [brand, brandStrong, onBrand]
29
+ export const THEMES = {
30
+ techtiten: { label: 'Techtiten', hint: 'mint green + purple (FitPrep / EzySplit feel)', neutral: 'cool', light: ['#00A372', '#008A60', '#FFFFFF'], dark: ['#00C389', '#00A372', '#04110C'], accent: '#7C5CFF' },
31
+ ocean: { label: 'Ocean', hint: 'calm blue, trusted (finance, productivity)', neutral: 'navy', light: ['#2F6FED', '#1F57C8', '#FFFFFF'], dark: ['#4D8DFF', '#2F6FED', '#06122A'], accent: '#00B8D9' },
32
+ sunset: { label: 'Sunset', hint: 'orange + pink, energetic (food, fitness)', neutral: 'warm', light: ['#F2552C', '#D4401B', '#FFFFFF'], dark: ['#FF6B4A', '#F2552C', '#1A0B06'], accent: '#FF4F8B' },
33
+ royal: { label: 'Royal', hint: 'violet, premium (education, lifestyle)', neutral: 'plum', light: ['#6A4CF0', '#5435D6', '#FFFFFF'], dark: ['#8B70FF', '#6A4CF0', '#0E0820'], accent: '#FF5CA8' },
34
+ mono: { label: 'Mono', hint: 'black & white, minimal (tools, portfolio)', neutral: 'cool', light: ['#111317', '#000000', '#FFFFFF'], dark: ['#F2F3F5', '#FFFFFF', '#0B0B12'], accent: '#7A808C' },
35
+ retro: { label: 'Retro LCD', hint: 'old-phone green screen (games, nostalgia)', neutral: 'lcd', light: ['#2F3B1E', '#1E2A14', '#C7D3A6'], dark: ['#9BD14A', '#B8E07A', '#10160C'], accent: '#5E7A2E' },
36
+ };
37
+
38
+ // Mobile: @expo-google-fonts/<pkg> exports; web: next/font/google import name + options.
39
+ export const FONTS = {
40
+ system: { label: 'System', hint: 'San Francisco / Roboto, nothing to load' },
41
+ inter: { label: 'Inter', hint: 'clean UI font', pkg: '@expo-google-fonts/inter', weights: { regular: 'Inter_400Regular', medium: 'Inter_500Medium', bold: 'Inter_700Bold', black: 'Inter_800ExtraBold' }, next: 'Inter', nextWeights: null },
42
+ poppins: { label: 'Poppins', hint: 'round and friendly (consumer apps)', pkg: '@expo-google-fonts/poppins', weights: { regular: 'Poppins_400Regular', medium: 'Poppins_500Medium', bold: 'Poppins_700Bold', black: 'Poppins_800ExtraBold' }, next: 'Poppins', nextWeights: ['400', '500', '700', '800'] },
43
+ 'space-grotesk': { label: 'Space Grotesk', hint: 'techy, modern', pkg: '@expo-google-fonts/space-grotesk', weights: { regular: 'SpaceGrotesk_400Regular', medium: 'SpaceGrotesk_500Medium', bold: 'SpaceGrotesk_700Bold', black: 'SpaceGrotesk_700Bold' }, next: 'Space_Grotesk', nextWeights: null },
44
+ pixel: { label: 'Press Start 2P', hint: 'pixel/8-bit (retro games)', scale: 0.72, pkg: '@expo-google-fonts/press-start-2p', weights: { regular: 'PressStart2P_400Regular', medium: 'PressStart2P_400Regular', bold: 'PressStart2P_400Regular', black: 'PressStart2P_400Regular' }, next: 'Press_Start_2P', nextWeights: ['400'] },
45
+ };
46
+
47
+ const hex = (h) => h.replace('#', '').padEnd(6, '0').slice(0, 6);
48
+ const toRgb = (h) => [0, 2, 4].map((i) => parseInt(hex(h).slice(i, i + 2), 16));
49
+ const toHex = (rgb) => '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
50
+ const shade = (h, f) => toHex(toRgb(h).map((v) => (f < 0 ? v * (1 + f) : v + (255 - v) * f)));
51
+ const luminance = (h) => { const [r, g, b] = toRgb(h).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
52
+ export const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test(v ?? '');
53
+
54
+ /** { light: {...}, dark: {...} } colors for packages/theme. `brand` (#RRGGBB) overrides the preset's brand. */
55
+ export function buildColors(themeKey, brand) {
56
+ const t = THEMES[themeKey];
57
+ const out = {};
58
+ for (const mode of ['light', 'dark']) {
59
+ let [b, strong, on] = t[mode];
60
+ if (brand) {
61
+ b = '#' + hex(brand).toUpperCase();
62
+ strong = shade(b, mode === 'light' ? -0.15 : 0.15);
63
+ on = luminance(b) > 0.45 ? '#0B0D12' : '#FFFFFF';
64
+ }
65
+ out[mode] = { ...neutrals[t.neutral][mode], brand: b, brandStrong: strong, onBrand: on, accent: t.accent, ...fixed };
66
+ }
67
+ return out;
68
+ }
package/package.json CHANGED
@@ -1,17 +1,40 @@
1
1
  {
2
2
  "name": "create-techtiten-app",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a Techtiten app: Expo (Android/iOS) + optional Next.js (web, admin panel, API) with a shared theme and shared logic. Always on the latest Expo and Next.js.",
5
5
  "type": "module",
6
- "bin": { "create-techtiten-app": "bin/index.mjs" },
7
- "files": ["bin", "templates"],
8
- "engines": { "node": ">=20.19" },
9
- "keywords": ["expo", "react-native", "nextjs", "monorepo", "template", "create-app", "zustand", "redux"],
6
+ "bin": {
7
+ "create-techtiten-app": "bin/index.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20.19"
15
+ },
16
+ "keywords": [
17
+ "expo",
18
+ "react-native",
19
+ "nextjs",
20
+ "monorepo",
21
+ "template",
22
+ "create-app",
23
+ "zustand",
24
+ "redux"
25
+ ],
10
26
  "author": "Arun Kumar (Techtiten)",
11
27
  "license": "MIT",
12
- "repository": { "type": "git", "url": "git+https://github.com/codewitharun/rnboilerplate.git" },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/codewitharun/create-techtiten-app.git"
31
+ },
13
32
  "dependencies": {
14
33
  "@clack/prompts": "^1.8.1",
15
34
  "picocolors": "^1.1.1"
35
+ },
36
+ "homepage": "https://github.com/codewitharun/create-techtiten-app#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/codewitharun/create-techtiten-app/issues"
16
39
  }
17
40
  }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
3
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
4
+ import { Providers } from '@/state/Providers';
5
+ import { ThemeProvider } from '@/theme';
6
+ import { useAppFonts } from '@/theme/fonts';
7
+
8
+ /** Everything the app needs around it: gestures, safe area, state + query client, theme, fonts.
9
+ * Reanimated/Worklets need no setup: babel-preset-expo adds their Babel plugin automatically. */
10
+ export function AppProviders({ children }: { children: ReactNode }) {
11
+ const fontsLoaded = useAppFonts();
12
+ if (!fontsLoaded) return null; // the splash screen stays up until fonts are ready
13
+ return (
14
+ <GestureHandlerRootView style={{ flex: 1 }}>
15
+ <SafeAreaProvider>
16
+ <Providers>
17
+ <ThemeProvider>{children}</ThemeProvider>
18
+ </Providers>
19
+ </SafeAreaProvider>
20
+ </GestureHandlerRootView>
21
+ );
22
+ }
@@ -16,7 +16,7 @@ export default function Home() {
16
16
  <Screen>
17
17
  <Card>
18
18
  <Text size="xl" weight="black">Welcome to __APP_NAME__</Text>
19
- <Text muted>Edit app/index.tsx to start. Colors come from packages/theme, helpers from packages/shared.</Text>
19
+ <Text muted>Edit src/screens/Home.tsx to start. Colors come from packages/theme, helpers from packages/shared.</Text>
20
20
  </Card>
21
21
 
22
22
  <Card>
@@ -1,6 +1,7 @@
1
1
  import { Pressable, Text as RNText, View, type PressableProps, type TextProps, type ViewProps } from 'react-native';
2
2
  import * as Haptics from 'expo-haptics';
3
3
  import { useTheme } from '@/theme';
4
+ import { fontScale, fonts } from '@/theme/fonts';
4
5
 
5
6
  export function Screen({ style, ...rest }: ViewProps) {
6
7
  const t = useTheme();
@@ -14,7 +15,10 @@ export function Card({ style, ...rest }: ViewProps) {
14
15
 
15
16
  export function Text({ style, muted, size = 'md', weight = 'regular', ...rest }: TextProps & { muted?: boolean; size?: keyof ReturnType<typeof useTheme>['fontSize']; weight?: keyof ReturnType<typeof useTheme>['fontWeight'] }) {
16
17
  const t = useTheme();
17
- return <RNText style={[{ color: muted ? t.colors.textMuted : t.colors.text, fontSize: t.fontSize[size], fontWeight: t.fontWeight[weight] }, style]} {...rest} />;
18
+ // Custom fonts carry their weight in the family name (fonts.bold…); the system font uses fontWeight.
19
+ const family = fonts[weight];
20
+ const fontSize = Math.round(t.fontSize[size] * fontScale);
21
+ return <RNText style={[{ color: muted ? t.colors.textMuted : t.colors.text, fontSize, lineHeight: Math.round(fontSize * (fontScale < 1 ? 1.6 : 1.35)) }, family ? { fontFamily: family } : { fontWeight: t.fontWeight[weight] }, style]} {...rest} />;
18
22
  }
19
23
 
20
24
  export function Button({ title, variant = 'primary', onPress, style, ...rest }: PressableProps & { title: string; variant?: 'primary' | 'ghost' }) {
@@ -31,7 +35,7 @@ export function Button({ title, variant = 'primary', onPress, style, ...rest }:
31
35
  alignItems: 'center', opacity: state.pressed ? 0.8 : 1,
32
36
  }, typeof style === 'function' ? style(state) : style]}
33
37
  {...rest}>
34
- <RNText style={{ color: primary ? t.colors.onBrand : t.colors.text, fontWeight: t.fontWeight.bold, fontSize: t.fontSize.md }}>{title}</RNText>
38
+ <RNText style={[{ color: primary ? t.colors.onBrand : t.colors.text, fontSize: Math.round(t.fontSize.md * fontScale) }, fonts.bold ? { fontFamily: fonts.bold } : { fontWeight: t.fontWeight.bold }]}>{title}</RNText>
35
39
  </Pressable>
36
40
  );
37
41
  }
@@ -1,8 +1,8 @@
1
1
  import { Stack } from 'expo-router';
2
2
  import { StatusBar } from 'expo-status-bar';
3
- import { SafeAreaProvider } from 'react-native-safe-area-context';
4
- import { Providers } from '@/state/Providers';
5
- import { ThemeProvider, useTheme } from '@/theme';
3
+ import { AppProviders } from '@/AppProviders';
4
+ import { useTheme } from '@/theme';
5
+ import { fonts } from '@/theme/fonts';
6
6
 
7
7
  function ThemedStack() {
8
8
  const t = useTheme();
@@ -13,6 +13,7 @@ function ThemedStack() {
13
13
  screenOptions={{
14
14
  headerStyle: { backgroundColor: t.colors.background },
15
15
  headerTintColor: t.colors.text,
16
+ headerTitleStyle: { fontFamily: fonts.bold },
16
17
  headerShadowVisible: false,
17
18
  contentStyle: { backgroundColor: t.colors.background },
18
19
  }}>
@@ -25,12 +26,8 @@ function ThemedStack() {
25
26
 
26
27
  export default function RootLayout() {
27
28
  return (
28
- <SafeAreaProvider>
29
- <Providers>
30
- <ThemeProvider>
31
- <ThemedStack />
32
- </ThemeProvider>
33
- </Providers>
34
- </SafeAreaProvider>
29
+ <AppProviders>
30
+ <ThemedStack />
31
+ </AppProviders>
35
32
  );
36
33
  }
@@ -0,0 +1 @@
1
+ export { default } from '@/screens/Home';
@@ -0,0 +1 @@
1
+ export { default } from '@/screens/Settings';
@@ -0,0 +1,25 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import { Tabs } from 'expo-router';
3
+ import { useTheme } from '@/theme';
4
+ import { fonts } from '@/theme/fonts';
5
+
6
+ export default function TabsLayout() {
7
+ const t = useTheme();
8
+ return (
9
+ <Tabs
10
+ screenOptions={{
11
+ headerStyle: { backgroundColor: t.colors.background },
12
+ headerTintColor: t.colors.text,
13
+ headerTitleStyle: { fontFamily: fonts.bold },
14
+ headerShadowVisible: false,
15
+ tabBarStyle: { backgroundColor: t.colors.surface, borderTopColor: t.colors.border },
16
+ tabBarActiveTintColor: t.colors.brand,
17
+ tabBarInactiveTintColor: t.colors.textMuted,
18
+ tabBarLabelStyle: { fontFamily: fonts.medium },
19
+ sceneStyle: { backgroundColor: t.colors.background },
20
+ }}>
21
+ <Tabs.Screen name="index" options={{ title: '__APP_NAME__', tabBarLabel: 'Home', tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color as string} size={size} /> }} />
22
+ <Tabs.Screen name="settings" options={{ title: 'Settings', tabBarIcon: ({ color, size }) => <Ionicons name="settings" color={color as string} size={size} /> }} />
23
+ </Tabs>
24
+ );
25
+ }
@@ -0,0 +1 @@
1
+ export { default } from '@/screens/Home';
@@ -0,0 +1 @@
1
+ export { default } from '@/screens/Settings';
@@ -0,0 +1,25 @@
1
+ import { Stack } from 'expo-router';
2
+ import { StatusBar } from 'expo-status-bar';
3
+ import { AppProviders } from '@/AppProviders';
4
+ import { useTheme } from '@/theme';
5
+
6
+ function Root() {
7
+ const t = useTheme();
8
+ return (
9
+ <>
10
+ <StatusBar style={t.mode === 'dark' ? 'light' : 'dark'} />
11
+ {/* Tabs live in app/(tabs). Push full-screen pages (details, modals) on this stack. */}
12
+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: t.colors.background } }}>
13
+ <Stack.Screen name="(tabs)" />
14
+ </Stack>
15
+ </>
16
+ );
17
+ }
18
+
19
+ export default function RootLayout() {
20
+ return (
21
+ <AppProviders>
22
+ <Root />
23
+ </AppProviders>
24
+ );
25
+ }
@@ -1,37 +1,10 @@
1
1
  // One theme for the phone app and the website. Change a color here and both update.
2
2
  export type Mode = 'light' | 'dark';
3
3
 
4
- export const palette = {
5
- brand: '#00C389',
6
- brandStrong: '#00A372',
7
- accent: '#7C5CFF',
8
- danger: '#E5484D',
9
- warning: '#E9A049',
10
- success: '#2EAD5B',
11
- } as const;
12
-
13
- export const colors = {
14
- light: {
15
- background: '#F7F8FA',
16
- surface: '#FFFFFF',
17
- surfaceAlt: '#EEF0F3',
18
- border: '#E2E5EA',
19
- text: '#0B0D12',
20
- textMuted: '#5B6270',
21
- onBrand: '#FFFFFF',
22
- ...palette,
23
- },
24
- dark: {
25
- background: '#0B0B12',
26
- surface: '#15151F',
27
- surfaceAlt: '#1D1D2A',
28
- border: '#2A2A38',
29
- text: '#ECEDF2',
30
- textMuted: '#9A9CAB',
31
- onBrand: '#04110C',
32
- ...palette,
33
- },
34
- } as const;
4
+ // colors.ts is written by create-techtiten-app from the theme preset you picked (__THEME_LABEL__).
5
+ // Edit it freely: both the phone app and the website read it.
6
+ import { colors } from './colors';
7
+ export { colors };
35
8
 
36
9
  export type ColorName = keyof (typeof colors)['light'];
37
10
  export type Colors = Record<ColorName, string>;
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ .expo
3
+ .next
4
+ dist
5
+ package-lock.json
6
+ apps/mobile/android
7
+ apps/mobile/ios
@@ -0,0 +1,5 @@
1
+ {
2
+ "singleQuote": true,
3
+ "printWidth": 120,
4
+ "trailingComma": "all"
5
+ }
@@ -20,3 +20,12 @@ npm run typecheck # every workspace
20
20
  ## Add a package
21
21
  - Native/Expo package: `cd apps/mobile && npx expo install <pkg>` (picks the SDK-matched version)
22
22
  - Anything else: `npm install <pkg> -w __SCOPE__/mobile` (or `__SCOPE__/web`, `__SCOPE__/shared`)
23
+
24
+ ## Upgrade later
25
+ New projects always start on the latest Expo / Next.js. To move this project to newer versions:
26
+ ```
27
+ npm run upgrade:mobile # latest Expo SDK + matching React Native and packages
28
+ npm run upgrade:web # latest Next.js
29
+ npm run typecheck
30
+ ```
31
+ Read the Expo SDK and Next.js release notes first: big versions can need small code changes.
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "__APP_SLUG__",
3
3
  "private": true,
4
- "workspaces": ["apps/*", "packages/*"],
4
+ "workspaces": [
5
+ "apps/*",
6
+ "packages/*"
7
+ ],
5
8
  "scripts": {
6
9
  "mobile": "npm run start -w __SCOPE__/mobile",
7
10
  "android": "npm run android -w __SCOPE__/mobile",
8
11
  "web": "npm run dev -w __SCOPE__/web",
9
12
  "build:web": "npm run build -w __SCOPE__/web",
10
- "typecheck": "npm run typecheck --workspaces --if-present"
13
+ "typecheck": "npm run typecheck --workspaces --if-present",
14
+ "format": "prettier --write .",
15
+ "upgrade:mobile": "cd apps/mobile && npx expo install expo@latest && npx expo install --fix",
16
+ "upgrade:web": "npm install next@latest eslint-config-next@latest -w __SCOPE__/web"
11
17
  },
12
- "engines": { "node": ">=20.19" }
18
+ "engines": {
19
+ "node": ">=20.19"
20
+ }
13
21
  }
@@ -19,5 +19,5 @@
19
19
  body {
20
20
  background: var(--color-background);
21
21
  color: var(--color-text);
22
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
22
+ font-family: var(--font-app, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
23
  }
@@ -1,5 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { cssVariables } from '__SCOPE__/theme';
3
+ import { appFontClass } from './fonts';
3
4
  import './globals.css';
4
5
 
5
6
  export const metadata: Metadata = {
@@ -13,7 +14,7 @@ const themeCss = `:root{${toCss(cssVariables('light'))}}@media (prefers-color-sc
13
14
 
14
15
  export default function RootLayout({ children }: LayoutProps<'/'>) {
15
16
  return (
16
- <html lang="en" className="h-full antialiased">
17
+ <html lang="en" className={`h-full antialiased ${appFontClass}`}>
17
18
  <head>
18
19
  <style dangerouslySetInnerHTML={{ __html: themeCss }} />
19
20
  </head>