Raw
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 {
24 --bg: #fafafa;
25 --surface: #ffffff;
26 --border: #e8e8e8;
27 --text-primary: #1a1a1a;
28 --text-secondary: #8a8a8a;
29 --accent: #2d2d2d;
30 --accent-hover: #1a1a1a;
31 --user-bubble: #1a1a1a;
32 --user-bubble-text: #ffffff;
33 --ai-bubble: #f0f0f0;
34 --ai-bubble-text: #1a1a1a;
35 --input-bg: #f5f5f5;
36 --radius: 18px;
37 --radius-sm: 8px;
38
39 font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
40 font-size: 15px;
41 line-height: 1.5;
42 -webkit-font-smoothing: antialiased;
43
44 /* Adapt native form controls and the WebView backdrop to the system theme. */
45 color-scheme: light dark;
46 }
47
48 @media (prefers-color-scheme: dark) {
49 :root {
50 --bg: #111111;
51 --surface: #1c1c1c;
52 --border: #2a2a2a;
53 --text-primary: #f0f0f0;
54 --text-secondary: #666666;
55 --accent: #e0e0e0;
56 --accent-hover: #ffffff;
57 --user-bubble: #e8e8e8;
58 --user-bubble-text: #111111;
59 --ai-bubble: #242424;
60 --ai-bubble-text: #f0f0f0;
61 --input-bg: #1c1c1c;
62 }
63 }
64
65 html, body {
66 height: 100%;
67 width: 100%;
68 /* Paint the theme colour across the whole WebView, including the regions
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;
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 {
95 height: 100%;
96 }
97
98 .app {
99 display: flex;
100 flex-direction: column;
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);
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 */
129 .header {
130 display: flex;
131 align-items: center;
132 justify-content: space-between;
133 /* Push the title clear of the status bar; keep the original 20px on devices
134 with no top inset. Honour left/right insets in landscape / on iPad. */
135 padding: calc(env(safe-area-inset-top) + 20px)
136 max(24px, env(safe-area-inset-right)) 16px max(24px, env(safe-area-inset-left));
137 border-bottom: 1px solid var(--border);
138 background: var(--bg);
139 flex-shrink: 0;
140 }
141
142 .header-title {
143 display: flex;
144 flex-direction: column;
145 }
146
147 .header-title h1 {
148 font-size: 17px;
149 font-weight: 600;
150 letter-spacing: -0.2px;
151 color: var(--text-primary);
152 }
153
154 .header-subtitle {
155 font-size: 12px;
156 color: var(--text-secondary);
157 letter-spacing: 0.2px;
158 }
159
160 .status-dot {
161 width: 8px;
162 height: 8px;
163 border-radius: 50%;
164 background: #4caf50;
165 flex-shrink: 0;
166 }
167
168 .status-dot.thinking {
169 background: #ff9800;
170 animation: pulse 1.2s ease-in-out infinite;
171 }
172
173 @keyframes pulse {
174 0%, 100% { opacity: 1; }
175 50% { opacity: 0.4; }
176 }
177
178 /* Messages area */
179 .messages {
180 flex: 1;
181 overflow-y: scroll;
182 -webkit-overflow-scrolling: touch;
183 overscroll-behavior-y: contain;
184 padding: 24px 0;
185 display: flex;
186 flex-direction: column;
187 gap: 2px;
188 scrollbar-width: none;
189 }
190
191 .messages::-webkit-scrollbar {
192 display: none;
193 }
194
195 /* Empty state */
196 .empty-state {
197 flex: 1;
198 display: flex;
199 flex-direction: column;
200 align-items: center;
201 justify-content: center;
202 gap: 8px;
203 padding: 40px;
204 color: var(--text-secondary);
205 text-align: center;
206 }
207
208 .empty-icon {
209 font-size: 32px;
210 margin-bottom: 4px;
211 opacity: 0.4;
212 }
213
214 .empty-state h2 {
215 font-size: 16px;
216 font-weight: 500;
217 color: var(--text-secondary);
218 }
219
220 .empty-state p {
221 font-size: 13px;
222 max-width: 220px;
223 line-height: 1.6;
224 }
225
226 /* Message row */
227 .message-row {
228 display: flex;
229 padding: 4px 24px;
230 }
231
232 .message-row.user {
233 justify-content: flex-end;
234 }
235
236 .message-row.assistant {
237 justify-content: flex-start;
238 }
239
240 .bubble {
241 max-width: 72%;
242 padding: 10px 14px;
243 border-radius: var(--radius);
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 {
256 background: var(--user-bubble);
257 color: var(--user-bubble-text);
258 border-bottom-right-radius: var(--radius-sm);
259 }
260
261 .message-row.assistant .bubble {
262 background: var(--ai-bubble);
263 color: var(--ai-bubble-text);
264 border-bottom-left-radius: var(--radius-sm);
265 }
266
267 /* Typing indicator */
268 .typing-indicator {
269 display: flex;
270 gap: 4px;
271 align-items: center;
272 padding: 12px 14px;
273 }
274
275 .typing-dot {
276 width: 6px;
277 height: 6px;
278 border-radius: 50%;
279 background: var(--text-secondary);
280 animation: bounce 1.2s ease-in-out infinite;
281 }
282
283 .typing-dot:nth-child(2) { animation-delay: 0.2s; }
284 .typing-dot:nth-child(3) { animation-delay: 0.4s; }
285
286 @keyframes bounce {
287 0%, 60%, 100% { transform: translateY(0); }
288 30% { transform: translateY(-4px); }
289 }
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))
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;
303 }
304
305 .input-form {
306 display: flex;
307 align-items: flex-end;
308 gap: 10px;
309 background: var(--input-bg);
310 border: 1px solid var(--border);
311 border-radius: 14px;
312 padding: 10px 10px 10px 16px;
313 transition: border-color 0.15s;
314 }
315
316 .input-form:focus-within {
317 border-color: var(--text-secondary);
318 }
319
320 .input-field {
321 flex: 1;
322 background: transparent;
323 border: none;
324 outline: none;
325 color: var(--text-primary);
326 font-size: 16px; /* prevents iOS auto-zoom on focus */
327 font-family: inherit;
328 line-height: 1.5;
329 resize: none;
330 max-height: 120px;
331 min-height: 22px;
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 {
340 color: var(--text-secondary);
341 }
342
343 .send-button {
344 width: 32px;
345 height: 32px;
346 border-radius: 8px;
347 border: none;
348 background: var(--accent);
349 color: var(--bg);
350 cursor: pointer;
351 display: flex;
352 align-items: center;
353 justify-content: center;
354 flex-shrink: 0;
355 transition: background 0.15s, transform 0.1s;
356 }
357
358 .send-button:active:not(:disabled) {
359 background: var(--accent-hover);
360 transform: scale(0.94);
361 }
362
363 .send-button:disabled {
364 opacity: 0.3;
365 cursor: default;
366 }
367
368 .send-button svg {
369 width: 16px;
370 height: 16px;
371 }
372
373 .input-hint {
374 font-size: 11px;
375 color: var(--text-secondary);
376 text-align: center;
377 margin-top: 10px;
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;
392 align-items: center;
393 gap: 14px;
394 }
395
396 .status-dot.error {
397 background: #e5484d;
398 animation: none;
399 }
400
401 .icon-button {
402 width: 34px;
403 height: 34px;
404 border: none;
405 background: transparent;
406 color: var(--text-secondary);
407 display: flex;
408 align-items: center;
409 justify-content: center;
410 border-radius: var(--radius-sm);
411 cursor: pointer;
412 transition: color 0.15s, background 0.15s, transform 0.1s;
413 }
414
415 .icon-button:active {
416 background: var(--ai-bubble);
417 color: var(--text-primary);
418 transform: scale(0.92);
419 }
420
421 .icon-button svg {
422 width: 20px;
423 height: 20px;
424 }
425
426 /* ── Settings page (slide-up) ───────────────────────────────────────────── */
427 .settings-overlay {
428 position: fixed;
429 inset: 0;
430 z-index: 50;
431 display: flex;
432 flex-direction: column;
433 background: var(--bg);
434 color: var(--text-primary);
435 transform: translateY(100%);
436 visibility: hidden;
437 transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1), visibility 0.28s;
438 }
439
440 .settings-overlay.open {
441 transform: translateY(0);
442 visibility: visible;
443 }
444
445 .settings-header {
446 display: flex;
447 align-items: center;
448 justify-content: space-between;
449 padding: calc(env(safe-area-inset-top) + 18px) 20px 14px;
450 border-bottom: 1px solid var(--border);
451 flex-shrink: 0;
452 }
453
454 .settings-header h1 {
455 font-size: 22px;
456 font-weight: 700;
457 letter-spacing: -0.4px;
458 }
459
460 .settings-done {
461 border: none;
462 background: transparent;
463 color: var(--text-primary);
464 font-family: inherit;
465 font-size: 16px;
466 font-weight: 600;
467 padding: 6px 2px;
468 cursor: pointer;
469 }
470
471 .settings-done:active {
472 opacity: 0.5;
473 }
474
475 .settings-content {
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
484 .settings-section {
485 margin-top: 22px;
486 }
487
488 .section-label {
489 font-size: 12px;
490 font-weight: 600;
491 letter-spacing: 0.6px;
492 text-transform: uppercase;
493 color: var(--text-secondary);
494 padding: 0 8px 8px;
495 }
496
497 .settings-card {
498 background: var(--surface);
499 border: 1px solid var(--border);
500 border-radius: 14px;
501 overflow: hidden;
502 }
503
504 .settings-row {
505 display: flex;
506 align-items: center;
507 justify-content: space-between;
508 gap: 12px;
509 padding: 14px 16px;
510 min-height: 52px;
511 }
512
513 .settings-card.about .settings-row + .settings-row {
514 border-top: 1px solid var(--border);
515 }
516
517 .row-label {
518 font-size: 15px;
519 font-weight: 500;
520 color: var(--text-primary);
521 }
522
523 .row-value {
524 font-size: 15px;
525 color: var(--text-secondary);
526 }
527
528 .row-link {
529 text-decoration: none;
530 }
531
532 .row-link:hover,
533 .row-link:active {
534 color: var(--text-primary);
535 }
536
537 .select-wrap {
538 position: relative;
539 display: flex;
540 align-items: center;
541 }
542
543 .model-select {
544 appearance: none;
545 -webkit-appearance: none;
546 border: none;
547 background: transparent;
548 color: var(--text-secondary);
549 font-family: inherit;
550 font-size: 15px;
551 text-align: right;
552 padding-right: 20px;
553 max-width: 190px;
554 white-space: nowrap;
555 overflow: hidden;
556 text-overflow: ellipsis;
557 cursor: pointer;
558 }
559
560 .model-select:disabled {
561 opacity: 0.5;
562 }
563
564 .select-wrap .chevron {
565 position: absolute;
566 right: 2px;
567 width: 11px;
568 height: 8px;
569 color: var(--text-secondary);
570 pointer-events: none;
571 }
572
573 .model-detail {
574 border-top: 1px solid var(--border);
575 padding: 14px 16px;
576 }
577
578 .model-org {
579 font-size: 12px;
580 font-weight: 600;
581 letter-spacing: 0.2px;
582 color: var(--text-secondary);
583 }
584
585 .model-desc {
586 font-size: 13px;
587 line-height: 1.5;
588 color: var(--text-primary);
589 margin-top: 4px;
590 }
591
592 .model-status {
593 display: flex;
594 align-items: center;
595 gap: 7px;
596 margin-top: 12px;
597 font-size: 13px;
598 font-weight: 500;
599 color: var(--text-secondary);
600 }
601
602 .model-status-dot {
603 width: 7px;
604 height: 7px;
605 border-radius: 50%;
606 background: var(--text-secondary);
607 flex-shrink: 0;
608 }
609
610 .model-status.ready,
611 .model-status.ready .model-status-dot {
612 color: #34c759;
613 }
614 .model-status.ready .model-status-dot {
615 background: #34c759;
616 }
617
618 .model-status.loading,
619 .model-status.loading .model-status-dot {
620 color: #ff9800;
621 }
622 .model-status.loading .model-status-dot {
623 background: #ff9800;
624 animation: pulse 1.2s ease-in-out infinite;
625 }
626
627 .model-status.error,
628 .model-status.error .model-status-dot {
629 color: #e5484d;
630 }
631 .model-status.error .model-status-dot {
632 background: #e5484d;
633 }
634
635 /* Weight-download progress. Determinate only: an indeterminate bar would say
636 no more than the pulsing status dot already does. */
637 .model-progress {
638 margin-top: 10px;
639 height: 4px;
640 border-radius: 2px;
641 /* `--border` rather than a dimmed `--text-secondary`: opacity here would
642 cascade into the fill and mute it too. Tracks both colour schemes. */
643 background: var(--border);
644 overflow: hidden;
645 }
646
647 .model-progress-fill {
648 height: 100%;
649 border-radius: inherit;
650 background: #ff9800;
651 transition: width 0.4s ease-out;
652 }
653
654 @media (prefers-reduced-motion: reduce) {
655 .model-progress-fill {
656 transition: none;
657 }
658 }
659
660 /* ── Supported Models list ─────────────────────────────────────────────── */
661
662 .model-list-row {
663 display: flex;
664 align-items: center;
665 justify-content: space-between;
666 gap: 12px;
667 padding: 12px 16px;
668 min-height: 56px;
669 }
670
671 .model-list-row + .model-list-row {
672 border-top: 1px solid var(--border);
673 }
674
675 .model-list-info {
676 display: flex;
677 flex-direction: column;
678 gap: 3px;
679 min-width: 0;
680 }
681
682 .model-list-name {
683 font-size: 15px;
684 font-weight: 500;
685 color: var(--text-primary);
686 }
687
688 .model-list-meta {
689 font-size: 12px;
690 color: var(--text-secondary);
691 }
692
693 .model-list-actions {
694 display: flex;
695 align-items: center;
696 gap: 6px;
697 flex-shrink: 0;
698 }
699
700 .model-downloaded {
701 display: flex;
702 align-items: center;
703 color: #34c759;
704 }
705
706 .model-action {
707 display: flex;
708 align-items: center;
709 justify-content: center;
710 width: 34px;
711 height: 34px;
712 border: none;
713 border-radius: 50%;
714 background: transparent;
715 cursor: pointer;
716 padding: 0;
717 transition: background 0.15s ease, color 0.15s ease;
718 }
719
720 .model-action.download {
721 color: #0a84ff;
722 }
723
724 .model-action.download:hover:not(:disabled) {
725 background: rgba(10, 132, 255, 0.15);
726 }
727
728 .model-action.remove {
729 color: var(--text-secondary);
730 }
731
732 .model-action.remove:hover:not(:disabled) {
733 background: rgba(229, 72, 77, 0.15);
734 color: #e5484d;
735 }
736
737 .model-action:disabled {
738 opacity: 0.4;
739 cursor: default;
740 }
741
742 .settings-note {
743 font-size: 12px;
744 line-height: 1.5;
745 color: var(--text-secondary);
746 padding: 10px 8px 0;
747 }