ai-or-die 0.1.68 → 0.1.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/public/app.js CHANGED
@@ -194,6 +194,7 @@ class ClaudeCodeWebInterface {
194
194
  this.setupTerminal();
195
195
  this._setupExtraKeys();
196
196
  this._setupOrientationHandler();
197
+ this._setupPwaStandaloneListener();
197
198
  this.setupUI();
198
199
  if (this.voiceInputConfig) this.setupVoiceInput();
199
200
  this.setupPlanDetector();
@@ -691,7 +692,12 @@ class ClaudeCodeWebInterface {
691
692
  caption: imageData.caption || ''
692
693
  });
693
694
  }
694
- }
695
+ },
696
+ // Non-image files pasted from a file manager (clipboardData
697
+ // carries File objects) — route through the generic pipeline.
698
+ // Fires only AFTER the image branch declines, so image paste
699
+ // precedence is unchanged.
700
+ onFilesPaste: (files) => this._attachFiles(files)
695
701
  }
696
702
  );
697
703
  }
@@ -827,6 +833,7 @@ class ClaudeCodeWebInterface {
827
833
  const handleOrientationChange = () => {
828
834
  setTimeout(() => {
829
835
  this.fitTerminal();
836
+ this._polyfillSafeAreaInsets();
830
837
  // Re-evaluate keyboard state
831
838
  if (window.visualViewport && this._keyboardOpen !== undefined) {
832
839
  const heightDiff = window.innerHeight - window.visualViewport.height;
@@ -1091,9 +1098,20 @@ class ClaudeCodeWebInterface {
1091
1098
  }, 3000);
1092
1099
  };
1093
1100
 
1094
- // Attach Image button
1101
+ // Attach File button (id is historical: attachImageBtn). Opens a picker
1102
+ // for ANY file type — images go through the preview flow, other files
1103
+ // upload to .claude-attachments/ and inject `@<path>`.
1095
1104
  const attachBtn = document.getElementById('attachImageBtn');
1096
- if (attachBtn && window.imageHandler) {
1105
+ if (attachBtn && window.genericDropHandler
1106
+ && typeof window.genericDropHandler.triggerFilePicker === 'function') {
1107
+ attachBtn.addEventListener('click', () => {
1108
+ window.genericDropHandler.triggerFilePicker(
1109
+ (files) => this._attachFiles(files),
1110
+ { multiple: true }
1111
+ );
1112
+ });
1113
+ } else if (attachBtn && window.imageHandler) {
1114
+ // Fallback: generic handler unavailable — keep the legacy image-only picker.
1097
1115
  attachBtn.addEventListener('click', () => {
1098
1116
  window.imageHandler.triggerFilePicker((imageData) => {
1099
1117
  this._pendingImageCaption = imageData.caption;
@@ -3419,7 +3437,7 @@ class ClaudeCodeWebInterface {
3419
3437
  }
3420
3438
  } else {
3421
3439
  if (activeTerminal) {
3422
- activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach Image instead.\x1b[0m\r\n');
3440
+ activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach File instead.\x1b[0m\r\n');
3423
3441
  }
3424
3442
  }
3425
3443
  } catch (err) {
@@ -3428,8 +3446,15 @@ class ClaudeCodeWebInterface {
3428
3446
  break;
3429
3447
  }
3430
3448
  case 'attachImage': {
3431
- const attachSocket = activeSocket;
3432
- if (window.imageHandler) {
3449
+ // Generalized to any file type (action id is historical).
3450
+ if (window.genericDropHandler
3451
+ && typeof window.genericDropHandler.triggerFilePicker === 'function') {
3452
+ window.genericDropHandler.triggerFilePicker(
3453
+ (files) => this._attachFiles(files),
3454
+ { multiple: true }
3455
+ );
3456
+ } else if (window.imageHandler) {
3457
+ const attachSocket = activeSocket;
3433
3458
  window.imageHandler.triggerFilePicker((imageData) => {
3434
3459
  this._pendingImageCaption = imageData.caption;
3435
3460
  const msg = JSON.stringify({
@@ -3660,6 +3685,140 @@ class ClaudeCodeWebInterface {
3660
3685
  || navigator.standalone === true;
3661
3686
  }
3662
3687
 
3688
+ // Shared attachment router for the non-drop surfaces (attach button,
3689
+ // context-menu, paste). Partitions the selected files: anything that passes
3690
+ // the image allowlist goes through the EXISTING image preview → image_upload
3691
+ // path (unchanged), everything else is routed to the generic drop pipeline
3692
+ // (upload to .claude-attachments/ + `@<path>` injection). Drag-and-drop does
3693
+ // NOT use this — it already partitions internally in generic-drop-handler.
3694
+ _attachFiles(files) {
3695
+ if (!files) return;
3696
+ const list = Array.prototype.slice.call(files);
3697
+ if (!list.length) return;
3698
+ const ih = window.imageHandler;
3699
+ const isImg = (f) => !!(ih && typeof ih.isAcceptedImageType === 'function'
3700
+ && ih.isAcceptedImageType(f.type));
3701
+ const images = list.filter(isImg);
3702
+ const others = list.filter((f) => !isImg(f));
3703
+
3704
+ // Capture the socket at attach-initiation time. The image preview modal
3705
+ // is async (user-driven); the active session/socket can change while it
3706
+ // is open. Sending on a captured target avoids the upload landing on a
3707
+ // different session (mirrors the context-menu's existing capture).
3708
+ const targetSocket = this.socket;
3709
+
3710
+ // Images: reuse the existing single-preview flow. The modal handles one
3711
+ // image at a time, so if several images are selected we attach the first
3712
+ // and tell the user rather than silently dropping the rest.
3713
+ if (images.length && ih && typeof ih.showImagePreview === 'function') {
3714
+ if (images.length > 1 && window.feedback && typeof window.feedback.info === 'function') {
3715
+ window.feedback.info('Only the first image is attached — attach images one at a time.');
3716
+ }
3717
+ ih.showImagePreview(images[0], (imageData) => {
3718
+ this._pendingImageCaption = imageData.caption;
3719
+ if (targetSocket && targetSocket.readyState === WebSocket.OPEN) {
3720
+ targetSocket.send(JSON.stringify({
3721
+ type: 'image_upload',
3722
+ base64: imageData.base64,
3723
+ mimeType: imageData.mimeType,
3724
+ fileName: imageData.fileName || 'attached-image.png',
3725
+ caption: imageData.caption || ''
3726
+ }));
3727
+ }
3728
+ });
3729
+ }
3730
+
3731
+ // Non-images: shared generic pipeline (upload + @path inject).
3732
+ if (others.length && this._genericDropHandler
3733
+ && typeof this._genericDropHandler.dispatchFiles === 'function') {
3734
+ this._genericDropHandler.dispatchFiles(others);
3735
+ } else if (others.length && window.feedback && typeof window.feedback.error === 'function') {
3736
+ window.feedback.error('File upload is not available right now.');
3737
+ }
3738
+ }
3739
+
3740
+ _setupPwaStandaloneListener() {
3741
+ const apply = () => {
3742
+ const standalone = this._isInstalledPWA();
3743
+ document.documentElement.classList.toggle('pwa-standalone', standalone);
3744
+ if (standalone) this._polyfillSafeAreaInsets();
3745
+ };
3746
+ apply();
3747
+ try {
3748
+ const queries = [
3749
+ '(display-mode: standalone)',
3750
+ '(display-mode: fullscreen)',
3751
+ '(display-mode: minimal-ui)',
3752
+ '(display-mode: window-controls-overlay)',
3753
+ ];
3754
+ queries.forEach((q) => {
3755
+ const mql = window.matchMedia(q);
3756
+ if (mql.addEventListener) mql.addEventListener('change', apply);
3757
+ else if (mql.addListener) mql.addListener(apply);
3758
+ });
3759
+ } catch (_) { /* ignore */ }
3760
+ }
3761
+
3762
+ _polyfillSafeAreaInsets() {
3763
+ // WebKit bug: env(safe-area-inset-top|bottom) sometimes returns 0px in
3764
+ // iOS PWA standalone on Dynamic Island / notched devices. Probe the
3765
+ // actual env() value per axis; if zero on a notch-class iOS device in
3766
+ // portrait, substitute the known defaults (59px island, 34px home
3767
+ // indicator). Results are exposed as the --safe-area-inset-* variables
3768
+ // that tokens.css (--sa-top/--sa-bottom) and the gated rules consume.
3769
+ if (!document.body) return;
3770
+
3771
+ const root = document.documentElement;
3772
+
3773
+ // Only ever set fake insets in installed-PWA standalone mode. In a
3774
+ // normal browser tab env() is authoritative (and legitimately 0 at the
3775
+ // top), so forcing a fallback there would push content down for no
3776
+ // reason — and the converted `var(--sa-*)` consumers are ungated, so a
3777
+ // stray value WOULD change non-PWA layout. Clear and bail when not PWA.
3778
+ if (!this._isInstalledPWA()) {
3779
+ root.style.removeProperty('--safe-area-inset-top');
3780
+ root.style.removeProperty('--safe-area-inset-bottom');
3781
+ return;
3782
+ }
3783
+
3784
+ const measure = (axis) => {
3785
+ const probe = document.createElement('div');
3786
+ probe.style.cssText = 'position:fixed;left:0;width:1px;pointer-events:none;visibility:hidden;'
3787
+ + (axis === 'top' ? 'top:0;' : 'bottom:0;')
3788
+ + 'height:env(safe-area-inset-' + axis + ');';
3789
+ document.body.appendChild(probe);
3790
+ const px = probe.offsetHeight;
3791
+ document.body.removeChild(probe);
3792
+ return px;
3793
+ };
3794
+
3795
+ const isPortrait = window.matchMedia('(orientation: portrait)').matches;
3796
+ // Notch / Dynamic-Island iPhones are tall (aspect > 2); home-button
3797
+ // iPhones (SE, 8) are ~1.78 and iPads ~1.33. Only those tall devices
3798
+ // have a top cutout + bottom home indicator, so only they get the
3799
+ // fallback when env() reports a (buggy) 0. This keeps the SE/iPad from
3800
+ // gaining a phantom inset.
3801
+ const longSide = Math.max(window.innerWidth, window.innerHeight);
3802
+ const shortSide = Math.min(window.innerWidth, window.innerHeight);
3803
+ const tall = shortSide > 0 && (longSide / shortSide) > 2;
3804
+ const notchClass = isPortrait && this._isIOS() && tall;
3805
+
3806
+ const apply = (axis, fallback) => {
3807
+ const measured = measure(axis);
3808
+ const prop = '--safe-area-inset-' + axis;
3809
+ if (measured > 0) {
3810
+ root.style.setProperty(prop, measured + 'px');
3811
+ } else if (notchClass) {
3812
+ root.style.setProperty(prop, fallback);
3813
+ } else {
3814
+ root.style.removeProperty(prop);
3815
+ }
3816
+ };
3817
+
3818
+ apply('top', '59px');
3819
+ apply('bottom', '34px');
3820
+ }
3821
+
3663
3822
  _isIOS() {
3664
3823
  return /iPad|iPhone|iPod/.test(navigator.userAgent)
3665
3824
  || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
@@ -8,7 +8,7 @@
8
8
  background: var(--surface-secondary);
9
9
  border-top: 1px solid var(--border-default);
10
10
  padding: 0 8px;
11
- padding-bottom: env(safe-area-inset-bottom, 0);
11
+ padding-bottom: var(--sa-bottom);
12
12
  z-index: var(--z-sticky);
13
13
  align-items: center;
14
14
  justify-content: space-around;
@@ -145,7 +145,9 @@
145
145
  position: fixed;
146
146
  bottom: 80px;
147
147
  right: 20px;
148
- z-index: var(--z-modal);
148
+ /* Below the modal layer (--z-modal=400) so this floating control does not
149
+ paint over open dialogs; still above terminal content. */
150
+ z-index: var(--z-sticky);
149
151
  display: none;
150
152
  flex-direction: column;
151
153
  gap: 10px;
@@ -264,7 +266,7 @@
264
266
 
265
267
  @media (max-width: 820px) {
266
268
  .install-btn {
267
- bottom: calc(52px + 20px + env(safe-area-inset-bottom, 0px));
269
+ bottom: calc(52px + 20px + var(--sa-bottom));
268
270
  }
269
271
  }
270
272
 
@@ -75,7 +75,7 @@
75
75
  right: 0;
76
76
  top: auto;
77
77
  border-radius: 12px 12px 0 0;
78
- padding-bottom: env(safe-area-inset-bottom, 0);
78
+ padding-bottom: var(--sa-bottom);
79
79
  min-width: 100%;
80
80
  max-width: 100%;
81
81
  z-index: var(--z-modal);
@@ -0,0 +1,68 @@
1
+ /* safe-area.css — PWA standalone safe-area insets for fixed/absolute overlays.
2
+
3
+ The Dynamic Island fix in mobile.css covers the in-flow surfaces (tab bar,
4
+ file-browser header, mobile menu). Out-of-flow overlays (modals, toasts,
5
+ banners, bottom bars) are anchored to physical screen edges and inherit none
6
+ of that compensation, so they collide with the iOS Dynamic Island (top) and
7
+ home indicator (bottom) when launched as an installed PWA. This file applies
8
+ the insets to those surfaces, in ONE place.
9
+
10
+ Everything is gated behind `html.pwa-standalone` (set by the early-paint
11
+ script + matchMedia listener in app.js) so normal browser-tab and
12
+ desktop-non-PWA layouts are completely unaffected. Tokens --sa-top/--sa-bottom
13
+ come from tokens.css and are populated by app.js's _polyfillSafeAreaInsets()
14
+ (which works around the WebKit env()===0 bug), falling back to env() then 0.
15
+
16
+ Loaded LAST so these rules win regardless of source order. */
17
+
18
+ /* --- Full-screen modal overlays ---------------------------------------------
19
+ Pad the overlay itself so the flex-centered .modal-content is inset out of
20
+ both the island and the home indicator. box-sizing keeps the overlay
21
+ full-viewport while the padding shrinks the centering box. */
22
+ html.pwa-standalone .settings-modal,
23
+ html.pwa-standalone .session-modal,
24
+ html.pwa-standalone .plan-modal,
25
+ html.pwa-standalone .folder-browser-modal,
26
+ html.pwa-standalone .shortcuts-modal,
27
+ html.pwa-standalone .image-preview-modal,
28
+ html.pwa-standalone .terminal-overlay {
29
+ box-sizing: border-box;
30
+ padding-top: var(--sa-top);
31
+ padding-bottom: var(--sa-bottom);
32
+ }
33
+
34
+ /* Clamp modal content to the padded area so a tall modal (e.g. Settings)
35
+ can't overflow back up into the island. dvh tracks the live viewport. */
36
+ html.pwa-standalone .modal-content {
37
+ max-height: calc(100dvh - var(--sa-top) - var(--sa-bottom) - 40px);
38
+ }
39
+
40
+ /* Full-height slide-in side panel (file browser). Its header already gets the
41
+ top inset via mobile.css; pad the bottom so the file list clears the home
42
+ indicator. box-sizing keeps the panel viewport-height. */
43
+ html.pwa-standalone .file-browser-panel {
44
+ box-sizing: border-box;
45
+ padding-bottom: var(--sa-bottom);
46
+ }
47
+
48
+ /* --- Top-anchored transient surfaces ---------------------------------------- */
49
+ html.pwa-standalone .toast-container {
50
+ top: calc(52px + var(--sa-top));
51
+ }
52
+ html.pwa-standalone .banner-base {
53
+ padding-top: calc(8px + var(--sa-top));
54
+ }
55
+ html.pwa-standalone .notif-permission-prompt {
56
+ top: calc(60px + var(--sa-top));
57
+ }
58
+ html.pwa-standalone .fb-find-panel {
59
+ top: calc(80px + var(--sa-top));
60
+ }
61
+
62
+ /* --- Bottom-anchored bars: lift above the home indicator -------------------- */
63
+ html.pwa-standalone .extra-keys-bar {
64
+ bottom: var(--sa-bottom);
65
+ }
66
+ html.pwa-standalone .input-overlay {
67
+ bottom: calc(16px + var(--sa-bottom));
68
+ }
@@ -28,11 +28,11 @@ class ExtraKeys {
28
28
  { label: 'End', data: '\x1b[F' },
29
29
  { label: 'PgUp', data: '\x1b[5~' },
30
30
  { label: 'PgDn', data: '\x1b[6~' },
31
- { label: '\u2190', data: '\x1b[D', aria: 'Left arrow' },
32
- { label: '\u2192', data: '\x1b[C', aria: 'Right arrow' },
33
- { label: '\u2191', data: '\x1b[A', aria: 'Up arrow' },
34
- { label: '\u2193', data: '\x1b[B', aria: 'Down arrow' },
35
- { label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard' },
31
+ { label: '\u2190', data: '\x1b[D', aria: 'Left arrow', title: 'Left arrow' },
32
+ { label: '\u2192', data: '\x1b[C', aria: 'Right arrow', title: 'Right arrow' },
33
+ { label: '\u2191', data: '\x1b[A', aria: 'Up arrow', title: 'Up arrow' },
34
+ { label: '\u2193', data: '\x1b[B', aria: 'Down arrow', title: 'Down arrow' },
35
+ { label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard', title: 'Dismiss keyboard' },
36
36
  ];
37
37
 
38
38
  const row2Keys = [
@@ -86,13 +86,13 @@ class ExtraKeys {
86
86
  btn.className = 'extra-key';
87
87
  btn.textContent = key.label;
88
88
  btn.setAttribute('aria-label', key.aria || key.label);
89
+ if (key.title) btn.setAttribute('title', key.title);
89
90
 
90
91
  if (key.dismiss) {
91
92
  btn.classList.add('extra-key-dismiss');
92
93
  btn.addEventListener('click', () => this._dismiss());
93
94
  } else if (key.handler) {
94
95
  btn.classList.add('extra-key-clipboard');
95
- if (key.title) btn.setAttribute('title', key.title);
96
96
  btn.addEventListener('click', () => {
97
97
  if (key.handler === 'copy') {
98
98
  this._handleCopy();
@@ -365,15 +365,50 @@
365
365
  return {
366
366
  cancelInFlight: cancelInFlight,
367
367
  destroy: destroy,
368
+ // Public entry point so non-drop surfaces (attach button, paste) can
369
+ // route a FileList/array of File objects through the EXACT same pipeline:
370
+ // image/* → onImageDrop, everything else → upload + @path inject, with the
371
+ // same MAX_FILES_PER_DROP cap and bounded-parallel queue.
372
+ dispatchFiles: dispatchDrop,
368
373
  };
369
374
  }
370
375
 
376
+ // ---------------------------------------------------------------------------
377
+ // triggerFilePicker — open a hidden <input type="file"> with NO accept filter
378
+ // (any file type), used by the attach button / context menu. Mirrors
379
+ // image-handler.js's picker minus the image-only constraint. Resolves the
380
+ // selected files via the onFiles callback. Browser-only.
381
+ // ---------------------------------------------------------------------------
382
+ function triggerFilePicker(onFiles, opts) {
383
+ if (typeof document === 'undefined') return;
384
+ opts = opts || {};
385
+ var input = document.createElement('input');
386
+ input.type = 'file';
387
+ if (opts.multiple) input.multiple = true;
388
+ input.style.display = 'none';
389
+
390
+ function cleanup() {
391
+ if (input.parentNode) input.parentNode.removeChild(input);
392
+ }
393
+ input.addEventListener('change', function () {
394
+ var files = input.files ? Array.prototype.slice.call(input.files) : [];
395
+ if (files.length && typeof onFiles === 'function') onFiles(files);
396
+ cleanup();
397
+ });
398
+ // Safety net: if the dialog is cancelled, no change fires — sweep the
399
+ // detached node shortly after to avoid leaking it.
400
+ document.body.appendChild(input);
401
+ input.click();
402
+ setTimeout(cleanup, 60000);
403
+ }
404
+
371
405
  // ---------------------------------------------------------------------------
372
406
  // Exports
373
407
  // ---------------------------------------------------------------------------
374
408
 
375
409
  var exportsObj = {
376
410
  attachGenericDropHandler: attachGenericDropHandler,
411
+ triggerFilePicker: triggerFilePicker,
377
412
  isImageMime: isImageMime,
378
413
  sanitizeBasename: sanitizeBasename,
379
414
  buildAtPathInjection: buildAtPathInjection,
@@ -115,6 +115,45 @@ function detectImageInClipboard(clipboardData) {
115
115
  return null;
116
116
  }
117
117
 
118
+ /**
119
+ * Collect NON-image File objects from a paste clipboard (files copied from a
120
+ * file manager surface as clipboardData.files / items with kind === 'file').
121
+ * Accepted image types are excluded — those go through the image preview flow.
122
+ * Used by onPaste's fall-through so non-image paste reaches the generic upload
123
+ * pipeline. Returns [] when there are no such files (normal text paste).
124
+ *
125
+ * @param {DataTransfer} clipboardData
126
+ * @returns {File[]}
127
+ */
128
+ function collectNonImageFiles(clipboardData) {
129
+ var out = [];
130
+ if (!clipboardData) return out;
131
+
132
+ if (clipboardData.files && clipboardData.files.length > 0) {
133
+ for (var i = 0; i < clipboardData.files.length; i++) {
134
+ var f = clipboardData.files[i];
135
+ if (f && !isAcceptedImageType(f.type)) out.push(f);
136
+ }
137
+ // `files` is the authoritative FileList when present; `items` mirrors the
138
+ // same files (plus non-file entries), so scanning both would double-count.
139
+ // Fall back to `items` ONLY when `files` yielded nothing — same precedence
140
+ // as detectImageInClipboard above.
141
+ if (out.length) return out;
142
+ }
143
+
144
+ if (clipboardData.items && clipboardData.items.length > 0) {
145
+ for (var j = 0; j < clipboardData.items.length; j++) {
146
+ var item = clipboardData.items[j];
147
+ if (item.kind === 'file' && !isAcceptedImageType(item.type)) {
148
+ var asFile = item.getAsFile();
149
+ if (asFile) out.push(asFile);
150
+ }
151
+ }
152
+ }
153
+
154
+ return out;
155
+ }
156
+
118
157
  // ---------------------------------------------------------------------------
119
158
  // Blob → base64
120
159
  // ---------------------------------------------------------------------------
@@ -504,6 +543,20 @@ function attachImageHandler(terminal, containerEl, options) {
504
543
  e.preventDefault();
505
544
  e.stopPropagation();
506
545
  showImagePreview(image, options.onImageReady);
546
+ return;
547
+ }
548
+ // No image — if non-image files were pasted (e.g. copied from a file
549
+ // manager) and a handler is wired, route them to the generic pipeline.
550
+ // Purely additive: when there are no such files this is a no-op and the
551
+ // normal text paste proceeds untouched.
552
+ if (typeof options.onFilesPaste === 'function') {
553
+ var nonImageFiles = collectNonImageFiles(e.clipboardData);
554
+ if (nonImageFiles.length) {
555
+ e.preventDefault();
556
+ e.stopPropagation();
557
+ options.onFilesPaste(nonImageFiles);
558
+ return;
559
+ }
507
560
  }
508
561
  // No image found — let the normal text paste proceed
509
562
  }
@@ -636,6 +689,7 @@ var imageHandlerExports = {
636
689
  mimeToExtension: mimeToExtension,
637
690
  formatFileSize: formatFileSize,
638
691
  detectImageInClipboard: detectImageInClipboard,
692
+ collectNonImageFiles: collectNonImageFiles,
639
693
  blobToBase64: blobToBase64,
640
694
  showImagePreview: showImagePreview,
641
695
  triggerFilePicker: triggerFilePicker,
@@ -42,6 +42,24 @@
42
42
  } catch (_) { /* ignore */ }
43
43
  })();
44
44
  </script>
45
+
46
+ <!-- Detect PWA standalone mode before first paint so safe-area-top rules can apply
47
+ without a layout-shift flash. Class lives on <html> because <body> isn't parsed yet. -->
48
+ <script>
49
+ (function() {
50
+ try {
51
+ var mm = window.matchMedia;
52
+ var isStandalone = (mm && (
53
+ mm('(display-mode: standalone)').matches
54
+ || mm('(display-mode: fullscreen)').matches
55
+ || mm('(display-mode: minimal-ui)').matches
56
+ || mm('(display-mode: window-controls-overlay)').matches
57
+ ))
58
+ || navigator.standalone === true;
59
+ if (isStandalone) document.documentElement.classList.add('pwa-standalone');
60
+ } catch (_) { /* ignore */ }
61
+ })();
62
+ </script>
45
63
  <link rel="preload" href="fonts/MesloLGSNerdFont-Regular.woff2" as="font" type="font/woff2" crossorigin>
46
64
  <link rel="preload" href="fonts/MesloLGSNerdFont-Bold.woff2" as="font" type="font/woff2" crossorigin>
47
65
  <link rel="stylesheet" href="fonts.css">
@@ -68,6 +86,8 @@
68
86
  <link rel="stylesheet" href="components/extra-keys.css">
69
87
  <link rel="stylesheet" href="mobile.css">
70
88
  <link rel="stylesheet" href="style.css">
89
+ <!-- Loaded last: PWA standalone safe-area overrides win over component CSS. -->
90
+ <link rel="stylesheet" href="components/safe-area.css">
71
91
  <link rel="preconnect" href="https://fonts.googleapis.com">
72
92
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
73
93
  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
@@ -143,7 +163,7 @@
143
163
  <line x1="9" y1="14" x2="15" y2="14"/>
144
164
  </svg>
145
165
  </button>
146
- <button class="tab-attach-image" id="attachImageBtn" title="Attach Image" aria-label="Attach image">
166
+ <button class="tab-attach-image" id="attachImageBtn" title="Attach File" aria-label="Attach file">
147
167
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
148
168
  stroke="currentColor" stroke-width="2">
149
169
  <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
@@ -292,7 +312,7 @@
292
312
  <div class="ctx-sep" role="separator"></div>
293
313
  <div class="ctx-item" data-action="attachImage" role="menuitem" tabindex="-1">
294
314
  <span class="ctx-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>
295
- <span>Attach Image...</span>
315
+ <span>Attach File...</span>
296
316
  </div>
297
317
  <div class="ctx-sep" role="separator"></div>
298
318
  <div class="ctx-item" data-action="selectAll" role="menuitem" tabindex="-1">
@@ -2,17 +2,39 @@
2
2
  Component-level responsive styles belong in their respective component CSS files.
3
3
  This file contains ONLY cross-cutting mobile concerns that span multiple components. */
4
4
 
5
- /* Safe area insets for notched devices (iPhone X+, etc.) */
5
+ /* Safe area insets for notched devices (iPhone X+, etc.)
6
+ Top-axis padding is gated to PWA standalone mode (html.pwa-standalone) because
7
+ apple-mobile-web-app-status-bar-style="black-translucent" only causes the
8
+ Dynamic Island / status-bar collision when launched from the home screen.
9
+ In normal mobile-Safari/Edge tab mode the browser chrome already occupies that
10
+ space, so adding padding-top there would visibly push content down for no gain.
11
+ The CSS variable --safe-area-inset-top is populated by a JS polyfill in app.js
12
+ to work around the known WebKit bug where env(safe-area-inset-top) sometimes
13
+ returns 0px in iOS PWA standalone; env() is kept as the secondary fallback. */
6
14
  @supports (padding: env(safe-area-inset-top)) {
7
15
  .session-tabs-bar {
8
16
  padding-left: max(10px, env(safe-area-inset-left));
9
17
  padding-right: max(10px, env(safe-area-inset-right));
10
18
  }
19
+ html.pwa-standalone .session-tabs-bar {
20
+ padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
21
+ }
22
+ html.pwa-standalone .file-browser-panel .file-browser-header {
23
+ padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
24
+ }
25
+ /* Baseline (any env()-capable browser) preserves the long-standing menu
26
+ inset; in tab mode env(safe-area-inset-top) is 0 so this is a no-op,
27
+ but it guards against any non-PWA notched case the standalone rule
28
+ below wouldn't cover. The standalone rule wins by specificity and
29
+ routes through the JS-populated --safe-area-inset-top variable. */
11
30
  .mobile-menu {
12
31
  padding-top: env(safe-area-inset-top);
13
32
  }
33
+ html.pwa-standalone .mobile-menu {
34
+ padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
35
+ }
14
36
  .mode-switcher {
15
- bottom: max(80px, calc(80px + env(safe-area-inset-bottom)));
37
+ bottom: max(80px, calc(80px + var(--sa-bottom)));
16
38
  right: max(20px, env(safe-area-inset-right));
17
39
  }
18
40
  }
@@ -183,12 +205,12 @@
183
205
 
184
206
  /* Account for bottom nav height */
185
207
  #app {
186
- padding-bottom: calc(52px + env(safe-area-inset-bottom, 0px));
208
+ padding-bottom: calc(52px + var(--sa-bottom));
187
209
  }
188
210
 
189
211
  /* Move mode switcher above bottom nav */
190
212
  .mode-switcher {
191
- bottom: calc(62px + env(safe-area-inset-bottom, 0px));
213
+ bottom: calc(62px + var(--sa-bottom));
192
214
  }
193
215
  }
194
216
 
@@ -127,6 +127,17 @@
127
127
  --z-input-overlay: 550;
128
128
  --z-auth: 9999;
129
129
  --z-max: 9999;
130
+
131
+ /* --- Safe-area insets (PWA standalone / notched devices) ---
132
+ Consumed by fixed/absolute overlays so they clear the iOS Dynamic Island
133
+ (top) and home indicator (bottom). The JS polyfill in app.js sets
134
+ --safe-area-inset-top/-bottom as VARIABLES to work around the WebKit bug
135
+ where env(safe-area-inset-*) returns 0px in iOS standalone; we fall back to
136
+ env() then 0. In normal (non-standalone) mode these resolve to 0, and the
137
+ consuming rules are gated behind html.pwa-standalone anyway, so there is no
138
+ effect on browser-tab or desktop-non-PWA layouts. */
139
+ --sa-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
140
+ --sa-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
130
141
  }
131
142
 
132
143
  /* ============================================================
@@ -132,19 +132,33 @@ function sanitizeFileName(name) {
132
132
  throw new Error('File name is required');
133
133
  }
134
134
 
135
- // Strip path separators, null bytes, and control characters
135
+ // Strip path separators, null bytes, and control characters. Windows-first
136
+ // (primary deployment target): also strip the characters NTFS forbids in a
137
+ // file name — `< > : " | ? *`. Stripping ':' additionally neutralizes NTFS
138
+ // Alternate Data Streams (`name:stream`) and any stray drive-letter colon.
136
139
  let sanitized = name
137
140
  .replace(/[/\\]/g, '')
141
+ .replace(/[<>:"|?*]/g, '')
138
142
  .replace(/\0/g, '')
139
143
  .replace(/[\x01-\x1f\x7f]/g, '');
140
144
 
141
- // Trim whitespace and dots from start/end
145
+ // Trim whitespace and dots from start/end (also covers Windows' rule that a
146
+ // file name may not end in a dot or space).
142
147
  sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, '');
143
148
 
144
149
  if (!sanitized) {
145
150
  throw new Error('File name is empty after sanitization');
146
151
  }
147
152
 
153
+ // Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) are
154
+ // illegal as a file's base name regardless of extension — `COM1.tar.gz` is
155
+ // still the COM1 device. Windows matches on the stem before the first dot,
156
+ // case-insensitively. Prefix with '_' to neutralize while staying readable.
157
+ const stem = sanitized.split('.')[0];
158
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) {
159
+ sanitized = '_' + sanitized;
160
+ }
161
+
148
162
  if (sanitized.length > 255) {
149
163
  const ext = path.extname(sanitized);
150
164
  sanitized = sanitized.slice(0, 255 - ext.length) + ext;