@sigit / siti / commits / b7e74a1

Native feel webview

paydii committed Aug 3, 2026 at 17:48 UTC b7e74a19297cac00b9620ecb9055d1e4dee4b643
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 +}