9 files changed
+247
-13
index.html
+17
-2
@@ -6,13 +6,28 @@
6
<!-- viewport-fit=cover makes the WebView edge-to-edge so the theme colour
7
(painted on html/body) extends behind the status bar and home
8
indicator, and env(safe-area-inset-*) becomes available for insetting
9
- content. -->
9
+ content.
10
+
11
+ The locked scale range is what stops the app from behaving like a web
12
+ page: mobile Safari ignores `user-scalable=no` for accessibility, but
13
+ WKWebView and the Android WebView honour it, so pinch and double-tap
14
+ no longer zoom the UI. Trackpad pinches arrive as separate gesture
15
+ events and are cancelled in src/native.ts. -->
16
<meta
17
name="viewport"
12
- content="width=device-width, initial-scale=1.0, viewport-fit=cover"
18
+ content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
19
/>
20
<!-- Let the WebView and its default backdrop follow the system theme. -->
21
<meta name="color-scheme" content="light dark" />
22
+ <!-- Draw the status bar over the (theme-coloured) app rather than filling
23
+ it with an opaque bar, matching viewport-fit=cover above. -->
24
+ <meta
25
+ name="apple-mobile-web-app-status-bar-style"
26
+ content="black-translucent"
27
+ />
28
+ <!-- Stop iOS from turning phone numbers, dates, and addresses in model
29
+ output into tappable blue "web page" links. -->
30
+ <meta name="format-detection" content="telephone=no,date=no,address=no,email=no" />
31
<meta
32
name="theme-color"
33
content="#fafafa"
src-tauri/tauri.conf.json
+4
-1
@@ -14,7 +14,10 @@
14
{
15
"title": "Siti AI",
16
"width": 800,
17
- "height": 600
17
+ "height": 600,
18
+ "minWidth": 420,
19
+ "minHeight": 480,
20
+ "zoomHotkeysEnabled": false
21
}
22
],
23
"security": {
src-tauri/tauri.prod-android.conf.json
+1
-1
@@ -1,5 +1,5 @@
1
{
2
"productName": "Siti AI",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
"identifier": "ai.siti.Siti"
5
}
src-tauri/tauri.prod-ios.conf.json
+1
-1
@@ -1,5 +1,5 @@
1
{
2
"productName": "Siti AI",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
"identifier": "ai.siti.Siti"
5
}
src-tauri/tauri.prod-macos-appstore.conf.json.example
+1
-1
@@ -1,6 +1,6 @@
1
{
2
"productName": "Siti AI",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
"identifier": "ai.siti.Siti",
5
"bundle": {
6
"icon": ["icons/icon.icns"],
src-tauri/tauri.prod-macos.conf.json.example
+1
-1
@@ -1,6 +1,6 @@
1
{
2
"productName": "Siti AI",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
"identifier": "ai.siti.Siti",
5
"bundle": {
6
"icon": ["icons/icon.icns"],
src/App.css
+80
-6
@@ -1,7 +1,23 @@
1
+/* ── Native shell reset ───────────────────────────────────────────────────
2
+ Everything below the box-sizing line exists to make the WebView stop
3
+ behaving like a browser. `touch-action` is not inherited, so the
4
+ double-tap-to-zoom suppression has to be declared on every element rather
5
+ than once on <body>. Gesture-level zoom, the context menu, and drag-out are
6
+ handled in src/native.ts; these are the parts CSS owns. */
7
*, *::before, *::after {
8
box-sizing: border-box;
9
margin: 0;
10
padding: 0;
11
+
12
+ /* `manipulation` keeps panning and pinch-to-scroll but drops the 300ms
13
+ double-tap-to-zoom gesture, which also removes the tap delay. */
14
+ touch-action: manipulation;
15
+ /* No grey flash behind taps — native controls highlight themselves. */
16
+ -webkit-tap-highlight-color: transparent;
17
+ /* Long-press must not raise the iOS callout bar or the link preview. */
18
+ -webkit-touch-callout: none;
19
+ /* Text and icons are not draggable objects in a native app. */
20
+ -webkit-user-drag: none;
21
}
22
23
:root {
@@ -53,9 +69,26 @@ html, body {
69
behind the status bar and home indicator, so the safe areas blend with the
70
app instead of showing the native (dark) backdrop. */
71
background: var(--bg);
72
+ /* Belt and braces against the document rubber-banding: `overflow: hidden`
73
+ means there is nothing to scroll, and `overscroll-behavior: none` stops
74
+ WKWebView bouncing (and the Android WebView flashing its overscroll glow)
75
+ when a scroll inside .messages reaches its end. Scroll bounce *within* the
76
+ message list is kept — that one is native. */
77
+ overflow: hidden;
78
overscroll-behavior: none;
57
- -webkit-tap-highlight-color: transparent;
58
- touch-action: manipulation;
79
+ /* Never re-flow text on rotation the way mobile browsers do. */
80
+ -webkit-text-size-adjust: 100%;
81
+ text-size-adjust: 100%;
82
+}
83
+
84
+body {
85
+ /* Pin the document so a focused input can never scroll the whole app frame
86
+ out from under the status bar — a WebKit habit with no native equivalent. */
87
+ position: fixed;
88
+ inset: 0;
89
+ /* App chrome is not selectable text; the parts that are opt back in below. */
90
+ user-select: none;
91
+ -webkit-user-select: none;
92
}
93
94
#root {
@@ -65,10 +98,31 @@ html, body {
98
.app {
99
display: flex;
100
flex-direction: column;
68
- height: 100dvh;
101
+ /* --keyboard-inset is published by src/native.ts from visualViewport: on iOS
102
+ the WebView is not resized for the keyboard, so the frame has to shrink
103
+ itself to keep the composer visible, the way a native view would. */
104
+ height: calc(100dvh - var(--keyboard-inset, 0px));
105
background: var(--bg);
106
color: var(--text-primary);
71
- user-select: none;
107
+}
108
+
109
+/* macOS and Windows apps show the arrow cursor over controls; the pointing
110
+ hand is a browser convention for hyperlinks. Text fields keep the I-beam.
111
+ Descendant selectors are deliberate: they have to outrank the per-control
112
+ `cursor: pointer` class rules further down, which a media query alone does
113
+ not do. */
114
+@media (hover: hover) and (pointer: fine) {
115
+ .app button,
116
+ .app select,
117
+ .app a {
118
+ cursor: default;
119
+ }
120
+}
121
+
122
+/* Keep the keyboard-navigation focus ring, drop the one browsers paint after a
123
+ plain click. */
124
+:focus:not(:focus-visible) {
125
+ outline: none;
126
}
127
128
/* Header */
@@ -190,8 +244,12 @@ html, body {
244
font-size: 14px;
245
line-height: 1.55;
246
word-break: break-word;
247
+ /* Message text is real content: allow selection and the system long-press
248
+ menu (Copy / Look Up / Share), which is the native affordance here. */
249
user-select: text;
250
-webkit-user-select: text;
251
+ -webkit-touch-callout: default;
252
+ cursor: text;
253
}
254
255
.message-row.user .bubble {
@@ -232,8 +290,13 @@ html, body {
290
291
/* Input area */
292
.input-area {
293
+ /* The home-indicator inset only needs clearing while the indicator is
294
+ visible; once the keyboard covers it, subtracting --keyboard-inset drives
295
+ the bottom padding back to the plain 20px so the composer sits flush on
296
+ the keyboard instead of floating above a phantom gap. */
297
padding: 16px max(24px, env(safe-area-inset-right))
236
- max(20px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
298
+ max(20px, calc(env(safe-area-inset-bottom) - var(--keyboard-inset, 0px)))
299
+ max(24px, env(safe-area-inset-left));
300
border-top: 1px solid var(--border);
301
background: var(--bg);
302
flex-shrink: 0;
@@ -269,6 +332,8 @@ html, body {
332
padding: 0;
333
user-select: text;
334
-webkit-user-select: text;
335
+ /* The composer needs the system edit menu (Select / Paste). */
336
+ -webkit-touch-callout: default;
337
}
338
339
.input-field::placeholder {
@@ -313,6 +378,14 @@ html, body {
378
letter-spacing: 0.1px;
379
}
380
381
+/* A hardware-keyboard hint under a touch composer is pure web-form residue —
382
+ no native chat app on a phone explains its Return key. */
383
+@media (pointer: coarse) {
384
+ .input-hint {
385
+ display: none;
386
+ }
387
+}
388
+
389
/* Header actions (status dot + settings) */
390
.header-actions {
391
display: flex;
@@ -336,7 +409,6 @@ html, body {
409
justify-content: center;
410
border-radius: var(--radius-sm);
411
cursor: pointer;
339
- -webkit-tap-highlight-color: transparent;
412
transition: color 0.15s, background 0.15s, transform 0.1s;
413
}
414
@@ -404,6 +476,8 @@ html, body {
476
flex: 1;
477
overflow-y: auto;
478
-webkit-overflow-scrolling: touch;
479
+ /* Reaching either end must not chain the scroll out to the document. */
480
+ overscroll-behavior-y: contain;
481
padding: 8px 16px max(24px, env(safe-area-inset-bottom));
482
}
483
src/main.tsx
+5
@@ -1,6 +1,11 @@
1
import React from "react";
2
import ReactDOM from "react-dom/client";
3
import App from "./App";
4
+import { installNativeShellBehaviour } from "./native";
5
+
6
+// Strip the WebView's browser affordances before the first paint, so the shell
7
+// never flashes a web-page gesture (zoom, context menu, drag) at the user.
8
+installNativeShellBehaviour();
9
10
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
11
<React.StrictMode>
src/native.ts
new
+137
@@ -0,0 +1,137 @@
1
+import { openUrl } from "@tauri-apps/plugin-opener";
2
+
3
+/**
4
+ * Native-shell hardening for the system WebView.
5
+ *
6
+ * Tauri renders the UI in WKWebView (macOS/iOS) or the Android WebView, both of
7
+ * which ship with browser affordances that immediately read as "this is a web
8
+ * page" inside a shipped app: pinch-to-zoom, ⌘+/− zoom, the browser context
9
+ * menu, dragging text out of the window, and links navigating the app frame
10
+ * away from the UI.
11
+ *
12
+ * App.css handles everything expressible as a passive style (callouts, tap
13
+ * highlights, double-tap zoom, overscroll). The gestures below can only be
14
+ * suppressed with live listeners, because the WebView acts on them before the
15
+ * page sees a scroll or a click.
16
+ *
17
+ * Returns a teardown function; the listeners live for the lifetime of the app,
18
+ * so nothing calls it outside of React's StrictMode double-invoke in dev.
19
+ */
20
+export function installNativeShellBehaviour(): () => void {
21
+ const offs: Array<() => void> = [];
22
+
23
+ const on = (
24
+ target: EventTarget,
25
+ type: string,
26
+ handler: (e: never) => void,
27
+ opts?: AddEventListenerOptions
28
+ ) => {
29
+ target.addEventListener(type, handler as EventListener, opts);
30
+ offs.push(() => target.removeEventListener(type, handler as EventListener, opts));
31
+ };
32
+
33
+ const swallow = (e: Event) => e.preventDefault();
34
+
35
+ // ── Pinch zoom ────────────────────────────────────────────────────────────
36
+ // WebKit reports trackpad and touch pinches as non-standard `gesture*`
37
+ // events; they bypass `touch-action` and the viewport's `user-scalable=no`,
38
+ // so a two-finger pinch on a Mac (or an iPad trackpad) still scales the UI
39
+ // unless each one is cancelled. Must be non-passive to be cancellable.
40
+ for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
41
+ on(document, type, swallow, { passive: false });
42
+ }
43
+
44
+ // Chromium (Android WebView, and Linux/Windows desktop) maps a trackpad
45
+ // pinch and ctrl+scroll onto a wheel event with `ctrlKey` set.
46
+ on(
47
+ window,
48
+ "wheel",
49
+ (e: WheelEvent) => {
50
+ if (e.ctrlKey || e.metaKey) e.preventDefault();
51
+ },
52
+ { passive: false }
53
+ );
54
+
55
+ // ── Keyboard zoom ─────────────────────────────────────────────────────────
56
+ // `zoomHotkeysEnabled` is off in tauri.conf.json, but the WebView still
57
+ // honours ⌘/Ctrl with +, -, and 0 on some platforms. Only the zoom keys are
58
+ // intercepted — ⌘A/⌘C/⌘V and friends must keep working.
59
+ on(window, "keydown", (e: KeyboardEvent) => {
60
+ if (!(e.metaKey || e.ctrlKey)) return;
61
+ if (["+", "=", "-", "_", "0"].includes(e.key)) e.preventDefault();
62
+ });
63
+
64
+ // ── Browser chrome ────────────────────────────────────────────────────────
65
+ // Suppress the WebView context menu except where it does something a native
66
+ // app would also offer: editing a text field, or copying a selection. Left
67
+ // intact during `pnpm dev` so "Inspect Element" stays reachable.
68
+ if (!import.meta.env.DEV) {
69
+ on(window, "contextmenu", (e: MouseEvent) => {
70
+ const el = e.target as HTMLElement | null;
71
+ const editable =
72
+ el?.closest("input, textarea, [contenteditable='true']") != null;
73
+ const selection = document.getSelection();
74
+ const hasSelection = selection != null && !selection.isCollapsed;
75
+ if (!editable && !hasSelection) e.preventDefault();
76
+ });
77
+ }
78
+
79
+ // Nothing in the app is draggable, and a dropped file would otherwise
80
+ // navigate the WebView away from the UI with no way back.
81
+ on(window, "dragstart", swallow);
82
+ on(window, "dragover", swallow);
83
+ on(window, "drop", swallow);
84
+
85
+ // ── External links ────────────────────────────────────────────────────────
86
+ // A link that navigates the app frame is unrecoverable — there is no back
87
+ // button. Route anything external to the system browser instead. Components
88
+ // that already handle their own links (Settings) call `preventDefault`, so
89
+ // check for that first to avoid opening the URL twice.
90
+ on(window, "click", (e: MouseEvent) => {
91
+ if (e.defaultPrevented) return;
92
+ const anchor = (e.target as HTMLElement | null)?.closest("a");
93
+ const href = anchor?.getAttribute("href");
94
+ if (!href || !/^(https?|mailto):/i.test(href)) return;
95
+ e.preventDefault();
96
+ void openUrl(href);
97
+ });
98
+
99
+ offs.push(installKeyboardInset());
100
+ return () => offs.forEach((off) => off());
101
+}
102
+
103
+/**
104
+ * Keep the composer above the on-screen keyboard.
105
+ *
106
+ * iOS does not resize the WKWebView when the keyboard appears — it only shrinks
107
+ * the visual viewport — so `100dvh` keeps reporting the full screen height and
108
+ * the input ends up underneath the keyboard. Publishing the covered height as
109
+ * `--keyboard-inset` lets App.css shrink the app frame the way a native view
110
+ * controller would. Android WebView resizes the window itself, in which case
111
+ * the measured inset stays 0 and this is a no-op.
112
+ */
113
+function installKeyboardInset(): () => void {
114
+ const vv = window.visualViewport;
115
+ if (!vv) return () => {};
116
+
117
+ const root = document.documentElement;
118
+
119
+ const sync = () => {
120
+ // Height of the screen the keyboard (or any other overlay) covers.
121
+ const covered = window.innerHeight - vv.height - vv.offsetTop;
122
+ root.style.setProperty("--keyboard-inset", `${Math.max(0, Math.round(covered))}px`);
123
+ // Focusing an input makes WebKit scroll the (non-scrollable) document to
124
+ // reveal it, leaving the fixed layout shifted under the status bar.
125
+ window.scrollTo(0, 0);
126
+ };
127
+
128
+ vv.addEventListener("resize", sync);
129
+ vv.addEventListener("scroll", sync);
130
+ sync();
131
+
132
+ return () => {
133
+ vv.removeEventListener("resize", sync);
134
+ vv.removeEventListener("scroll", sync);
135
+ root.style.removeProperty("--keyboard-inset");
136
+ };
137
+}