kreftjs 0.0.22-beta → 0.0.23-beta.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/bin/build.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * kreft build --prerender also save every page as ready HTML (needs: npm i -D jsdom)
9
9
  * kreft build --prerender --urls urls.js ... and the addresses in that file (pages that come from your data)
10
10
  * kreft build --include docs also copy another folder into the output
11
+ * kreft build --pwa also write manifest.webmanifest + sw.js (installable, works offline)
11
12
  * kreft build --no-htaccess do not write dist/.htaccess (the Apache / Hostinger page-address rules)
12
13
  *
13
14
  * What it does:
@@ -34,11 +35,12 @@ const SERVER_DIRS = new Set();
34
35
  const SKIP = new Set(['node_modules', 'dist', 'dist-electron', '.git', '.vscode', '.idea', 'package.json', 'package-lock.json', '.DS_Store']);
35
36
 
36
37
  module.exports = function build(args) {
37
- let folder = null, out = null, base = null, prerender = null, urlsFile = null, htaccess = true, minify = true;
38
+ let pwa = false, folder = null, out = null, base = null, prerender = null, urlsFile = null, htaccess = true, minify = true;
38
39
  const include = [];
39
40
  for (let i = 0; i < args.length; i++) {
40
41
  if (args[i] === '--out' || args[i] === '-o') out = args[++i];
41
42
  else if (args[i] === '--base') base = args[++i];
43
+ else if (args[i] === '--pwa') pwa = true; // manifest + service worker: installable and works offline
42
44
  else if (args[i] === '--no-htaccess') htaccess = false;
43
45
  else if (args[i] === '--no-minify') minify = false; // keep kreftjs.js readable (for debugging)
44
46
  else if (args[i] === '--include') include.push(args[++i]); // another folder to copy in, like the docs
@@ -241,6 +243,7 @@ ${indent(components.join('\n\n'))}
241
243
  }
242
244
  write(dist, 'index.html', html);
243
245
  addCacheBusters(dist);
246
+ if (pwa) require('./pwa.js').apply(dist, root, { project });
244
247
  if (htaccess) writeHtaccess(dist, html);
245
248
 
246
249
  const report = () => {
package/bin/kreftjs.js CHANGED
@@ -48,6 +48,12 @@ const commands = {
48
48
  'electron:init': (a) => require('./electron.js').init(a),
49
49
  'electron:help': () => require('./electron.js').help(),
50
50
  'make:electron': (a) => require('./electron.js').init(a),
51
+ 'mobile:init': (a) => require('./mobile.js').init(a[0]),
52
+ 'mobile:build': (a) => require('./mobile.js').build(a),
53
+ 'mobile:open': (a) => require('./mobile.js').open(a),
54
+ 'mobile:dev': (a) => require('./mobile.js').dev(a),
55
+ 'mobile:help': () => require('./mobile.js').help(),
56
+ 'make:mobile': (a) => require('./mobile.js').init(a[0]),
51
57
  vite: (a) => require('./vite.js').dev(a),
52
58
  'vite:dev': (a) => require('./vite.js').dev(a),
53
59
  'vite:build': (a) => require('./vite.js').build(a),
@@ -91,6 +97,7 @@ function help() {
91
97
  --ts …use TypeScript across frontend (.kreft, .ts) and backend (.ts)
92
98
  --js …use JavaScript (default when non-interactive)
93
99
  --electron …with Electron desktop app support (main.js, preload.js, packaging)
100
+ --mobile …with iOS / Android support (Capacitor)
94
101
  --vite …with Vite.js bundling & instant HMR (vite.config.js, npm run vite)
95
102
  kreft dev [folder] Start the dev server (default: this folder)
96
103
  --port <number> Port to use (default: 8000, or the next free one)
@@ -121,6 +128,7 @@ function help() {
121
128
  kreft electron Open the app in a desktop window only: no browser, no URL (alias: kreft electron:dev)
122
129
  kreft electron:build Build and make installers (--mac, --win, --linux; or npm run electron:build:all)
123
130
  kreft electron:init Add Electron support to an existing project (alias: kreft make:electron)
131
+ kreft mobile:build Build for iOS / Android with Capacitor (--android, --ios, --api-url; see kreft mobile:help)
124
132
  kreft electron:help Everything about desktop apps: menu, icon, installers, errors
125
133
 
126
134
  The backend (kreft list shows every command, yours too):
@@ -196,11 +204,12 @@ function unknown() {
196
204
 
197
205
  /* ------------------------------------------------------------------ kreft new */
198
206
  async function createProject(args) {
199
- let name = null, dbKind = null, ui = null, ts = false, langChosen = false, electron = false, vite = false, redis = false, auth = true, authChosen = false;
207
+ let name = null, dbKind = null, ui = null, ts = false, langChosen = false, electron = false, mobile = false, vite = false, redis = false, auth = true, authChosen = false;
200
208
  for (let i = 0; i < args.length; i++) {
201
209
  if (args[i] === '--ts' || args[i] === '--typescript') { ts = true; langChosen = true; }
202
210
  else if (args[i] === '--js' || args[i] === '--javascript') { ts = false; langChosen = true; }
203
211
  else if (args[i] === '--electron' || args[i] === '--desktop') electron = true;
212
+ else if (args[i] === '--mobile') mobile = true;
204
213
  else if (args[i] === '--vite') vite = true;
205
214
  else if (args[i] === '--auth') { auth = true; authChosen = true; }
206
215
  else if (args[i] === '--no-auth') { auth = false; authChosen = true; }
@@ -295,6 +304,7 @@ async function createProject(args) {
295
304
  }
296
305
  fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify(projectPkg, null, 2) + '\n');
297
306
  if (electron) require('./electron.js').init(target);
307
+ if (mobile) require('./mobile.js').init(target);
298
308
  if (vite) require('./vite.js').init(target, { ts });
299
309
 
300
310
  console.log(`
package/bin/mobile.js ADDED
@@ -0,0 +1,175 @@
1
+ /*
2
+ * Mobile apps (iOS and Android) for Kreft, with Capacitor.
3
+ *
4
+ * kreft mobile:init add Capacitor to this project (capacitor.config.json, package.json entries)
5
+ * kreft mobile:build [--android|--ios] [--api-url https://…]
6
+ * build the site (relative base, PWA files), then `cap add` (first time) and `cap sync`
7
+ * kreft mobile:open [--android|--ios] open Android Studio or Xcode
8
+ * kreft mobile:dev [--android|--ios] run on a device or emulator with the dev server (live reload)
9
+ * kreft mobile:help
10
+ *
11
+ * The backend does not run inside the phone: --api-url sets the server the app talks to (written to .env
12
+ * as PUBLIC_API_URL before the build). In the app: Kreftjs.api.setup({ base: Kreftjs.env.PUBLIC_API_URL, token: … }).
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { start: spawn } = require('./win-spawn.js');
17
+
18
+ const CAP = '^7.0.0';
19
+
20
+ function projectRoot(start = process.cwd()) {
21
+ const lay = require('./layout.js').layout(start);
22
+ return lay.split ? lay.project : start;
23
+ }
24
+
25
+ function readPkg(root) {
26
+ try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch (_) { return null; }
27
+ }
28
+
29
+ // an app id like com.example.myapp, from the package name
30
+ function appId(name) {
31
+ const slug = String(name || 'app').replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9]+/g, '');
32
+ return 'com.example.' + (/^[a-z]/.test(slug) ? slug : 'app' + slug);
33
+ }
34
+
35
+ function configFor(root) {
36
+ const pkg = readPkg(root) || {};
37
+ const name = pkg.productName || pkg.name || path.basename(root);
38
+ return {
39
+ appId: appId(pkg.name || name),
40
+ appName: String(name),
41
+ webDir: 'dist',
42
+ server: { androidScheme: 'https' },
43
+ };
44
+ }
45
+
46
+ function init(target) {
47
+ const root = typeof target === 'string' ? target : projectRoot();
48
+ const cfgFile = path.join(root, 'capacitor.config.json');
49
+ if (!fs.existsSync(cfgFile)) {
50
+ fs.writeFileSync(cfgFile, JSON.stringify(configFor(root), null, 2) + '\n');
51
+ console.log(' Wrote capacitor.config.json');
52
+ }
53
+ const pkgFile = path.join(root, 'package.json');
54
+ const pkg = readPkg(root);
55
+ if (pkg) {
56
+ pkg.dependencies = { ...pkg.dependencies };
57
+ pkg.devDependencies = { ...pkg.devDependencies };
58
+ pkg.scripts = { ...pkg.scripts };
59
+ let changed = false;
60
+ const need = (obj, k, v) => { if (!obj[k]) { obj[k] = v; changed = true; } };
61
+ need(pkg.dependencies, '@capacitor/core', CAP);
62
+ need(pkg.dependencies, '@capacitor/android', CAP);
63
+ need(pkg.dependencies, '@capacitor/ios', CAP);
64
+ need(pkg.devDependencies, '@capacitor/cli', CAP);
65
+ need(pkg.scripts, 'mobile:build', 'kreft mobile:build');
66
+ need(pkg.scripts, 'mobile:android', 'kreft mobile:build --android');
67
+ need(pkg.scripts, 'mobile:ios', 'kreft mobile:build --ios');
68
+ if (changed) { fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n'); console.log(' Added Capacitor to package.json. Run: npm install'); }
69
+ }
70
+ const ignore = path.join(root, '.gitignore');
71
+ if (fs.existsSync(ignore)) {
72
+ const txt = fs.readFileSync(ignore, 'utf8');
73
+ const add = ['android/app/build', 'ios/App/Pods', 'ios/App/build'].filter((l) => !txt.includes(l));
74
+ if (add.length) fs.appendFileSync(ignore, (txt.endsWith('\n') ? '' : '\n') + add.join('\n') + '\n');
75
+ }
76
+ console.log('\n Mobile support is ready. Next:\n npm install\n kreft mobile:build --android --api-url https://your-server.example\n');
77
+ }
78
+
79
+ function platforms(args) {
80
+ const p = ['android', 'ios'].filter((x) => args.includes('--' + x));
81
+ return p;
82
+ }
83
+
84
+ function setApiUrl(root, url) {
85
+ if (!/^https?:\/\/[^\s]+$/i.test(url)) { console.error(' --api-url must be a full address, like https://api.example.com'); process.exit(1); }
86
+ const file = path.join(root, '.env');
87
+ let txt = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
88
+ const line = 'PUBLIC_API_URL=' + url;
89
+ txt = /^PUBLIC_API_URL=.*$/m.test(txt) ? txt.replace(/^PUBLIC_API_URL=.*$/m, line) : txt + (txt && !txt.endsWith('\n') ? '\n' : '') + line + '\n';
90
+ fs.writeFileSync(file, txt);
91
+ console.log(' PUBLIC_API_URL=' + url + ' (in .env)');
92
+ }
93
+
94
+ function run(cmd, args, cwd) {
95
+ return new Promise((resolve) => {
96
+ const p = spawn(cmd, args, { cwd, stdio: 'inherit' });
97
+ p.on('error', (e) => { console.error(' Could not run ' + cmd + ': ' + e.message); resolve(1); });
98
+ p.on('exit', (c) => resolve(c || 0));
99
+ });
100
+ }
101
+
102
+ function capBin(root) {
103
+ const bin = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'cap.cmd' : 'cap');
104
+ return fs.existsSync(bin) ? bin : null;
105
+ }
106
+
107
+ async function build(args = []) {
108
+ const root = projectRoot();
109
+ if (!fs.existsSync(path.join(root, 'capacitor.config.json'))) { console.log(' Capacitor is not set up yet. Initializing…'); init(root); }
110
+ const i = args.indexOf('--api-url');
111
+ if (i >= 0) setApiUrl(root, args[i + 1] || '');
112
+ const plats = platforms(args);
113
+
114
+ console.log('\n Step 1: Building the site (relative base, PWA files)…');
115
+ const kreftCli = path.join(__dirname, 'kreftjs.js');
116
+ const code = await run(process.execPath, [kreftCli, 'build', '--base', './', '--pwa', '--no-htaccess'], root);
117
+ if (code) { console.error(' Build failed with code ' + code); process.exit(code); }
118
+
119
+ const cap = capBin(root);
120
+ if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
121
+ for (const p of plats.length ? plats : ['android', 'ios']) {
122
+ if (!plats.length && !fs.existsSync(path.join(root, p))) continue; // no flag: sync only the platforms that exist
123
+ if (!fs.existsSync(path.join(root, p))) {
124
+ console.log(`\n Adding ${p}…`);
125
+ const c = await run(cap, ['add', p], root);
126
+ if (c) process.exit(c);
127
+ }
128
+ }
129
+ console.log('\n Step 2: Copying the build into the native projects…');
130
+ const c = await run(cap, ['sync', ...plats], root);
131
+ if (c) process.exit(c);
132
+ console.log('\n Done. Open the native project: kreft mobile:open --android (or --ios)\n');
133
+ }
134
+
135
+ async function open(args = []) {
136
+ const root = projectRoot();
137
+ const cap = capBin(root);
138
+ if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
139
+ process.exit(await run(cap, ['open', platforms(args)[0] || 'android'], root));
140
+ }
141
+
142
+ async function dev(args = []) {
143
+ const root = projectRoot();
144
+ const cap = capBin(root);
145
+ if (!cap) { console.error('\n Capacitor is not installed. Run: npm install\n'); process.exit(1); }
146
+ const plat = platforms(args)[0] || 'android';
147
+ const port = process.env.PORT || '8000';
148
+ const ip = Object.values(require('os').networkInterfaces()).flat().find((n) => n && n.family === 'IPv4' && !n.internal);
149
+ const url = `http://${ip ? ip.address : 'localhost'}:${port}`;
150
+ console.log(` Dev server: ${url} (the phone must be on the same network)`);
151
+ const server = spawn(process.execPath, [path.join(__dirname, 'kreftjs.js'), 'dev'], { cwd: root, stdio: 'inherit', env: { ...process.env, PORT: port, HOST: '0.0.0.0' } });
152
+ const stop = () => { try { server.kill('SIGTERM'); } catch (_) {} process.exit(0); };
153
+ process.on('SIGINT', stop); process.on('SIGTERM', stop);
154
+ const c = await run(cap, ['run', plat, '--live-reload', '--host', ip ? ip.address : 'localhost', '--port', port], root);
155
+ try { server.kill('SIGTERM'); } catch (_) {}
156
+ process.exit(c);
157
+ }
158
+
159
+ function help() {
160
+ console.log(`
161
+ Mobile apps (iOS and Android) with Capacitor
162
+
163
+ kreft new my-app --mobile a new project with mobile support
164
+ kreft mobile:init add Capacitor to this project
165
+ kreft mobile:build --android build the site and sync it into android/ (--ios for iOS; no flag: both that exist)
166
+ kreft mobile:build --api-url https://api.example.com
167
+ the address of your server (the backend does not run inside the phone)
168
+ kreft mobile:open --android open Android Studio (--ios: Xcode)
169
+ kreft mobile:dev --android run on a device or emulator with live reload
170
+
171
+ Also: kreft build --pwa makes a normal website installable and usable offline (manifest.webmanifest + sw.js).
172
+ `);
173
+ }
174
+
175
+ module.exports = { init, build, open, dev, help, appId, configFor };
package/bin/pwa.js ADDED
@@ -0,0 +1,76 @@
1
+ /*
2
+ * PWA output for `kreft build --pwa` (also used by the mobile commands):
3
+ * manifest.webmanifest name, colours, icon, start_url
4
+ * sw.js offline shell: pages and files are cached after the first visit; /api/ is never cached
5
+ * pwa-register.js registers sw.js (a file, not inline code, so it works under a strict CSP)
6
+ * and the matching <link>, <meta> and <script> tags in index.html.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+
12
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
13
+
14
+ function listFiles(dir, base = dir, out = []) {
15
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ const p = path.join(dir, e.name);
17
+ if (e.isDirectory()) listFiles(p, base, out);
18
+ else out.push(path.relative(base, p).split(path.sep).join('/'));
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function apply(dist, root, opts = {}) {
24
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return {}; } };
25
+ const pkg = read(path.join(opts.project || root, 'package.json'));
26
+ const name = opts.name || pkg.productName || pkg.name || path.basename(path.resolve(root));
27
+ const themeColor = opts.themeColor || '#ffffff';
28
+ const scope = './';
29
+
30
+ const icon = [path.join(root, 'icon.png'), path.join(opts.project || root, 'build', 'icon.png')].find((f) => fs.existsSync(f));
31
+ const icons = [];
32
+ if (icon) { fs.copyFileSync(icon, path.join(dist, 'icon.png')); icons.push({ src: 'icon.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }); }
33
+
34
+ const manifest = { name, short_name: String(name).slice(0, 12), start_url: scope, scope, display: 'standalone', background_color: themeColor, theme_color: themeColor, icons };
35
+ fs.writeFileSync(path.join(dist, 'manifest.webmanifest'), JSON.stringify(manifest, null, 2) + '\n');
36
+
37
+ const skip = new Set(['sw.js', '.htaccess']);
38
+ const files = listFiles(dist).filter((f) => !skip.has(f) && !f.endsWith('.map'));
39
+ const version = crypto.createHash('sha1').update(files.map((f) => f + fs.statSync(path.join(dist, f)).size).join('|')).digest('hex').slice(0, 10);
40
+ fs.writeFileSync(path.join(dist, 'sw.js'), `// Written by kreft build --pwa. A new build has a new version, so the old cache is dropped.
41
+ const CACHE = 'kreft-${version}';
42
+ const SHELL = ${JSON.stringify(['./', ...files.filter((f) => f !== 'index.html')])};
43
+ self.addEventListener('install', (e) => {
44
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
45
+ });
46
+ self.addEventListener('activate', (e) => {
47
+ e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
48
+ });
49
+ self.addEventListener('fetch', (e) => {
50
+ const req = e.request, url = new URL(req.url);
51
+ if (req.method !== 'GET' || url.origin !== location.origin || url.pathname.includes('/api/')) return; // data always goes to the network
52
+ if (req.mode === 'navigate') { // page addresses: network first, the cached index.html when offline
53
+ e.respondWith(fetch(req).catch(() => caches.match('./').then((r) => r || caches.match('index.html'))));
54
+ return;
55
+ }
56
+ e.respondWith(caches.match(req, { ignoreSearch: true }).then((hit) => hit || fetch(req).then((res) => {
57
+ if (res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); }
58
+ return res;
59
+ })));
60
+ });
61
+ `);
62
+ fs.writeFileSync(path.join(dist, 'pwa-register.js'),
63
+ `if ('serviceWorker' in navigator && location.protocol !== 'file:' && !location.protocol.startsWith('capacitor')) {\n window.addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(() => {}));\n}\n`);
64
+
65
+ const file = path.join(dist, 'index.html');
66
+ let html = fs.readFileSync(file, 'utf8');
67
+ const tags = ` <link rel="manifest" href="manifest.webmanifest">\n <meta name="theme-color" content="${esc(themeColor)}">\n <meta name="mobile-web-app-capable" content="yes">\n <meta name="apple-mobile-web-app-capable" content="yes">\n <meta name="apple-mobile-web-app-title" content="${esc(manifest.short_name)}">\n${icons.length ? ' <link rel="apple-touch-icon" href="icon.png">\n' : ''}`;
68
+ if (!html.includes('manifest.webmanifest')) html = html.replace(/<\/head>/i, () => tags + '</head>');
69
+ if (!html.includes('pwa-register.js')) html = html.replace(/<\/body>/i, () => ' <script src="pwa-register.js"></script>\n</body>');
70
+ // phones: draw under the notch; the app pads itself with env(safe-area-inset-*)
71
+ html = html.replace(/(<meta[^>]*name=["']viewport["'][^>]*content=["'])([^"']*)(["'])/i, (m, a, c, z) => /viewport-fit/.test(c) ? m : a + c + ', viewport-fit=cover' + z);
72
+ fs.writeFileSync(file, html);
73
+ return { name, files: files.length + 3 };
74
+ }
75
+
76
+ module.exports = { apply };
package/docs/README.md CHANGED
@@ -2221,6 +2221,48 @@ Do not turn these off to make something work. Add a bridge function instead.
2221
2221
  | The window opens before the server is ready | It waits up to 15 seconds. Check the terminal for a dev server error. |
2222
2222
  | Port 8000 is taken | Nothing to do: the hidden server picks the next free port. |
2223
2223
 
2224
+ ## Mobile apps (iOS and Android)
2225
+
2226
+ Kreft apps run on phones in two ways. Both start from the same `.kreft` code.
2227
+
2228
+ **1. An installable website (PWA).** `kreft build --pwa` also writes `manifest.webmanifest`, a service worker `sw.js` and `pwa-register.js`. Visitors can add the site to their home screen, and pages they have seen open without a connection. Requests to `/api/` are never cached. Put a 512x512 `icon.png` next to `index.html` for the home screen icon.
2229
+
2230
+ **2. A real app with Capacitor.** The built site runs inside a native WebView and goes to the App Store or Google Play.
2231
+
2232
+ ```bash
2233
+ kreft new my-app --mobile # or: kreft mobile:init in an existing app
2234
+ npm install
2235
+ kreft mobile:build --android --api-url https://api.example.com
2236
+ kreft mobile:open --android # Android Studio (--ios: Xcode, needs a Mac)
2237
+ ```
2238
+
2239
+ | Command | What it does |
2240
+ |---|---|
2241
+ | `kreft mobile:init` | Adds `capacitor.config.json`, the Capacitor packages and the `mobile:*` scripts |
2242
+ | `kreft mobile:build [--android] [--ios]` | Builds the site with a relative base and PWA files, adds the platform the first time, then runs `cap sync`. Without a flag it syncs the platforms that exist |
2243
+ | `kreft mobile:open` | Opens the native project |
2244
+ | `kreft mobile:dev` | Runs on a device or emulator with live reload from your dev server (same Wi-Fi) |
2245
+
2246
+ **The backend does not run inside the phone.** Run it on a server and give the app its address with `--api-url` (written to `.env` as `PUBLIC_API_URL`), then in your code:
2247
+
2248
+ ```js
2249
+ Kreftjs.api.setup({ base: Kreftjs.env.PUBLIC_API_URL, token: () => Kreftjs.storage.get('token') })
2250
+ ```
2251
+
2252
+ Use API tokens for sign-in in the app, not cookie sessions.
2253
+
2254
+ ### Touch events
2255
+
2256
+ ```html
2257
+ <div @tap={open}>Open</div>
2258
+ <li @swipeleft={remove($event)} @longpress={select}>Row</li>
2259
+ <section @swipe={go($event)}>…</section> <!-- $event.direction: left | right | up | down -->
2260
+ ```
2261
+
2262
+ `@tap` (moves less than 10px, under 500ms), `@longpress` (held still for 500ms), `@swipe` and `@swipeleft`, `@swiperight`, `@swipeup`, `@swipedown` (40px or more in under 800ms). They use pointer events, so they work with a finger, a pen and a mouse. `.once`, `.prevent` and `.stop` work. A horizontal swipe keeps vertical scrolling working.
2263
+
2264
+ The starter `ui.css` already pads the page for the notch (`env(safe-area-inset-*)`), removes the tap delay and makes buttons and fields at least 44px high on touch screens. New projects have `viewport-fit=cover` set by `kreft build --pwa`.
2265
+
2224
2266
  ## Packages
2225
2267
 
2226
2268
  `kreft add` is a safer `npm install`:
package/kreftjs.js CHANGED
@@ -1630,9 +1630,56 @@
1630
1630
  if (mods.includes('stop')) e.stopPropagation();
1631
1631
  invokeHandler(scope, expr, [e]);
1632
1632
  };
1633
+ if (gestureTypes.has(type)) return bindGesture(el, type, mods, scope, expr);
1633
1634
  el.addEventListener(type, handler, options);
1634
1635
  }
1635
1636
 
1637
+ /* ---------- touch gestures: @tap, @longpress, @swipe, @swipeleft/right/up/down ----------
1638
+ * Built on pointer events, so they work with a finger, a pen and a mouse. The handler gets
1639
+ * { type, target, currentTarget, direction, dx, dy, duration, originalEvent, preventDefault(), stopPropagation() }.
1640
+ * A swipe needs 40px of travel (more along one axis than the other) in under 800ms; a tap moves less than 10px and
1641
+ * ends before 500ms; a longpress is held 500ms without moving. Modifiers: .prevent .stop .once. */
1642
+ const gestureTypes = new Set(['tap', 'longpress', 'swipe', 'swipeleft', 'swiperight', 'swipeup', 'swipedown']);
1643
+ function bindGesture(el, type, mods, scope, expr) {
1644
+ let start = null, timer = null, done = false;
1645
+ const fire = (extra, orig) => {
1646
+ if (done) return;
1647
+ if (mods.includes('once')) done = true;
1648
+ if (mods.includes('prevent')) orig.preventDefault();
1649
+ if (mods.includes('stop')) orig.stopPropagation();
1650
+ invokeHandler(scope, expr, [Object.assign({
1651
+ type, target: orig.target, currentTarget: el, originalEvent: orig,
1652
+ preventDefault: () => orig.preventDefault(), stopPropagation: () => orig.stopPropagation(),
1653
+ }, extra)]);
1654
+ };
1655
+ const clear = () => { if (timer) clearTimeout(timer); timer = null; };
1656
+ // let a vertical scroll keep working, while a horizontal swipe is ours
1657
+ if (type.startsWith('swipe') && el.style) el.style.touchAction = el.style.touchAction || (type === 'swipeup' || type === 'swipedown' ? 'pan-x' : 'pan-y');
1658
+ el.addEventListener('pointerdown', (e) => {
1659
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1660
+ start = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false };
1661
+ clear();
1662
+ if (type === 'longpress') timer = setTimeout(() => { if (start && !start.moved) { const s = start; start = null; fire({ dx: 0, dy: 0, duration: Date.now() - s.t }, e); } }, 500);
1663
+ });
1664
+ el.addEventListener('pointermove', (e) => {
1665
+ if (!start) return;
1666
+ if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) { start.moved = true; if (type === 'longpress') clear(); }
1667
+ });
1668
+ el.addEventListener('pointercancel', () => { start = null; clear(); });
1669
+ el.addEventListener('pointerup', (e) => {
1670
+ clear();
1671
+ if (!start) return;
1672
+ const s = start; start = null;
1673
+ const dx = e.clientX - s.x, dy = e.clientY - s.y, duration = Date.now() - s.t;
1674
+ if (type === 'tap') { if (!s.moved && duration < 500) fire({ dx, dy, duration }, e); return; }
1675
+ if (type === 'longpress') return;
1676
+ const horizontal = Math.abs(dx) >= Math.abs(dy);
1677
+ if (duration > 800 || Math.max(Math.abs(dx), Math.abs(dy)) < 40) return;
1678
+ const direction = horizontal ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
1679
+ if (type === 'swipe' || type === 'swipe' + direction) fire({ direction, dx, dy, duration }, e);
1680
+ });
1681
+ }
1682
+
1636
1683
  /* ---------- k-model on native elements ---------- */
1637
1684
  const toNumber = (v) => { const n = parseFloat(v); return isNaN(n) ? v : n; };
1638
1685
  const valueOf = (el) => ('_value' in el ? el._value : el.value);
@@ -5669,7 +5716,7 @@
5669
5716
 
5670
5717
  // the text-only helpers the build tool reuses, so built code matches the browser exactly
5671
5718
  Object.defineProperty(Kreftjs, '__internal', {
5672
- value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
5719
+ value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams, bindEvent },
5673
5720
  });
5674
5721
 
5675
5722
  if (typeof module !== 'undefined' && module.exports) module.exports = Kreftjs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kreftjs",
3
- "version": "0.0.22-beta",
3
+ "version": "0.0.23-beta.0",
4
4
  "description": "A tiny reactive framework with its own component language (.kreft files): router with nested routes, stores, saved data, animations, an API client, hot reload, DevTools and a template engine.",
5
5
  "license": "Elastic-2.0",
6
6
  "main": "kreftjs.js",
@@ -14,7 +14,7 @@
14
14
  "start": "node bin/kreftjs.js dev --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
15
15
  "build": "node bin/kreftjs.js build demo --out dist/kreftjs-com --base / --include docs",
16
16
  "preview": "node bin/kreftjs.js dev dist --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
17
- "test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js test/stream.test.js test/syntax.test.js test/memory.test.js test/shield.test.js test/mcp-client.test.js test/dbapi-secrets.test.js test/auth-csrf.test.js test/not-a-project.test.js",
17
+ "test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js test/stream.test.js test/syntax.test.js test/memory.test.js test/shield.test.js test/mcp-client.test.js test/dbapi-secrets.test.js test/auth-csrf.test.js test/not-a-project.test.js test/mobile.test.js test/gestures.test.js",
18
18
  "electron": "kreftjs electron",
19
19
  "electron:dev": "kreftjs electron:dev",
20
20
  "electron:build": "kreftjs electron:build"
@@ -1630,9 +1630,56 @@
1630
1630
  if (mods.includes('stop')) e.stopPropagation();
1631
1631
  invokeHandler(scope, expr, [e]);
1632
1632
  };
1633
+ if (gestureTypes.has(type)) return bindGesture(el, type, mods, scope, expr);
1633
1634
  el.addEventListener(type, handler, options);
1634
1635
  }
1635
1636
 
1637
+ /* ---------- touch gestures: @tap, @longpress, @swipe, @swipeleft/right/up/down ----------
1638
+ * Built on pointer events, so they work with a finger, a pen and a mouse. The handler gets
1639
+ * { type, target, currentTarget, direction, dx, dy, duration, originalEvent, preventDefault(), stopPropagation() }.
1640
+ * A swipe needs 40px of travel (more along one axis than the other) in under 800ms; a tap moves less than 10px and
1641
+ * ends before 500ms; a longpress is held 500ms without moving. Modifiers: .prevent .stop .once. */
1642
+ const gestureTypes = new Set(['tap', 'longpress', 'swipe', 'swipeleft', 'swiperight', 'swipeup', 'swipedown']);
1643
+ function bindGesture(el, type, mods, scope, expr) {
1644
+ let start = null, timer = null, done = false;
1645
+ const fire = (extra, orig) => {
1646
+ if (done) return;
1647
+ if (mods.includes('once')) done = true;
1648
+ if (mods.includes('prevent')) orig.preventDefault();
1649
+ if (mods.includes('stop')) orig.stopPropagation();
1650
+ invokeHandler(scope, expr, [Object.assign({
1651
+ type, target: orig.target, currentTarget: el, originalEvent: orig,
1652
+ preventDefault: () => orig.preventDefault(), stopPropagation: () => orig.stopPropagation(),
1653
+ }, extra)]);
1654
+ };
1655
+ const clear = () => { if (timer) clearTimeout(timer); timer = null; };
1656
+ // let a vertical scroll keep working, while a horizontal swipe is ours
1657
+ if (type.startsWith('swipe') && el.style) el.style.touchAction = el.style.touchAction || (type === 'swipeup' || type === 'swipedown' ? 'pan-x' : 'pan-y');
1658
+ el.addEventListener('pointerdown', (e) => {
1659
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
1660
+ start = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false };
1661
+ clear();
1662
+ if (type === 'longpress') timer = setTimeout(() => { if (start && !start.moved) { const s = start; start = null; fire({ dx: 0, dy: 0, duration: Date.now() - s.t }, e); } }, 500);
1663
+ });
1664
+ el.addEventListener('pointermove', (e) => {
1665
+ if (!start) return;
1666
+ if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) { start.moved = true; if (type === 'longpress') clear(); }
1667
+ });
1668
+ el.addEventListener('pointercancel', () => { start = null; clear(); });
1669
+ el.addEventListener('pointerup', (e) => {
1670
+ clear();
1671
+ if (!start) return;
1672
+ const s = start; start = null;
1673
+ const dx = e.clientX - s.x, dy = e.clientY - s.y, duration = Date.now() - s.t;
1674
+ if (type === 'tap') { if (!s.moved && duration < 500) fire({ dx, dy, duration }, e); return; }
1675
+ if (type === 'longpress') return;
1676
+ const horizontal = Math.abs(dx) >= Math.abs(dy);
1677
+ if (duration > 800 || Math.max(Math.abs(dx), Math.abs(dy)) < 40) return;
1678
+ const direction = horizontal ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
1679
+ if (type === 'swipe' || type === 'swipe' + direction) fire({ direction, dx, dy, duration }, e);
1680
+ });
1681
+ }
1682
+
1636
1683
  /* ---------- k-model on native elements ---------- */
1637
1684
  const toNumber = (v) => { const n = parseFloat(v); return isNaN(n) ? v : n; };
1638
1685
  const valueOf = (el) => ('_value' in el ? el._value : el.value);
@@ -5669,7 +5716,7 @@
5669
5716
 
5670
5717
  // the text-only helpers the build tool reuses, so built code matches the browser exactly
5671
5718
  Object.defineProperty(Kreftjs, '__internal', {
5672
- value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams },
5719
+ value: { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing, scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, makeKreftDef, compileFn, evaluate, stripTypeScript, stripTsParams, bindEvent },
5673
5720
  });
5674
5721
 
5675
5722
  if (typeof module !== 'undefined' && module.exports) module.exports = Kreftjs;
@@ -515,3 +515,18 @@ tbody tr:hover {
515
515
  animation: none !important;
516
516
  }
517
517
  }
518
+
519
+ /* ---- phones and tablets (also inside the Capacitor iOS / Android apps) ---- */
520
+ body {
521
+ padding-top: env(safe-area-inset-top);
522
+ padding-right: env(safe-area-inset-right);
523
+ padding-bottom: env(safe-area-inset-bottom);
524
+ padding-left: env(safe-area-inset-left);
525
+ -webkit-tap-highlight-color: transparent;
526
+ -webkit-text-size-adjust: 100%;
527
+ }
528
+ button, a, [role="button"], input, select, textarea { touch-action: manipulation; } /* no 300ms click delay, no double-tap zoom */
529
+ @media (pointer: coarse) {
530
+ button, .btn, input, select, textarea { min-height: 44px; } /* a finger needs about 44px */
531
+ input, select, textarea { font-size: 16px; } /* iOS zooms into smaller fields */
532
+ }