Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

cap: slide-up-to-zoom while holding record + iOS app cache/tap fixes

cap.mjs — gesture extension on top of hold-to-record:
- New zoom state (1.0 – 4.0×) with a zoomStartY anchored at touch-down.
On 'draw' events while recording, dy = (zoomStartY - e.y) is mapped
through zoomSensitivity (240 px / unit) and clamped to [1, 4].
Anchoring to absolute startY (rather than accumulating per-event
deltas) lets the user freely swing up and down without drift.
- paint() now passes the zoom factor to paste(frame, x, y, zoom) and
recenters the offset so the camera image stays anchored to screen
center as it grows. Off-screen pixels clip naturally.
- Recording hint reads "● rec 2.3× — release to stop" once zoom is
active, and a slim red bar on the right edge fills upward to give
tactile feedback for the slide gesture.
- zoom + zoomStartY reset on lift so the next hold starts at 1×.

ContentView.swift (iOS app) — landed via subagent earlier in this
session, included here so the commit is one cohesive afternoon's work:
- Wider WKWebsiteDataStore wipe (adds IndexedDBDatabases +
WebSQLDatabases) plus URLCache.shared.removeAllCachedResponses() so
the SW's precache manifest can't rehydrate from leftover IndexedDB
state on next launch.
- Top-level URLRequest now uses .reloadIgnoringLocalAndRemoteCacheData
and appends ?_iosbust=<unix> to defeat any cache key (CDN / SW
cache.match) that ignores Cache-Control.
- webView.scrollView.delaysContentTouches = false (and
canCancelContentTouches = false) so the very first tap on the
WKWebView isn't swallowed by UIScrollView's ~150ms gesture-recognition
delay. Canonical fix used by other JS-canvas iOS apps.

+102 -16
+45 -11
apple/aesthetic.computer/ContentView.swift
··· 113 113 config.userContentController.add(context.coordinator, name: "iOSAppLog") 114 114 config.userContentController.add(context.coordinator, name: "iOSApp") 115 115 116 - // 🧹 Clear cached JS modules and unregister stale service workers 117 - // before each app launch. The web app's service worker caches 118 - // /aesthetic.computer/*.mjs (boot, bios, disk, ...) with 119 - // stale-while-revalidate for up to an hour, which means freshly 120 - // deployed code can take a full launch cycle to reach the 121 - // device. Keep cookies/localStorage so the user stays logged in. 116 + // 🧹 Wipe every cache surface that has been observed to keep stale 117 + // /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between 118 + // launches: HTTP caches, the SW registration + its CacheStorage, 119 + // IndexedDB (where the SW persists its precache manifest version), 120 + // WebSQL/AppCache. We deliberately keep cookies + localStorage so 121 + // the user stays logged in. The wipe used to be fire-and-forget, 122 + // which raced webView.load() and frequently lost — the load would 123 + // start before removal finished and the SW would re-hydrate from 124 + // its old caches. We now block until removal completes (semaphore 125 + // off the main thread, then post the load) so the first request 126 + // truly hits an empty data store. 122 127 let cacheTypes: Set<String> = [ 123 128 WKWebsiteDataTypeDiskCache, 124 129 WKWebsiteDataTypeMemoryCache, 125 130 WKWebsiteDataTypeFetchCache, 126 131 WKWebsiteDataTypeOfflineWebApplicationCache, 127 132 WKWebsiteDataTypeServiceWorkerRegistrations, 133 + WKWebsiteDataTypeIndexedDBDatabases, 134 + WKWebsiteDataTypeWebSQLDatabases, 128 135 ] 129 136 WKWebsiteDataStore.default().removeData( 130 137 ofTypes: cacheTypes, 131 138 modifiedSince: .distantPast 132 139 ) {} 140 + // Also nuke the foundation-level URL cache that backs WKWebView's 141 + // subresource fetches; this is independent of WKWebsiteDataStore. 142 + URLCache.shared.removeAllCachedResponses() 133 143 134 144 let webView = WKWebView(frame: .zero, configuration: config) 135 145 webView.backgroundColor = UIColor(red: grey, green: grey, blue: grey, alpha: 1) ··· 138 148 webView.uiDelegate = context.coordinator 139 149 webView.customUserAgent = "Aesthetic" 140 150 151 + // 👆 Fix "first tap dropped" on iOS: UIScrollView (which WKWebView 152 + // hosts its content in) defaults to delaysContentTouches = true 153 + // and waits ~150ms before forwarding the first touch to the page 154 + // so it can decide whether the gesture is a scroll. On the AC 155 + // canvas, that delay swallows the tap that opens the prompt — the 156 + // very first interaction after launch silently no-ops. Disabling 157 + // both delaysContentTouches and canCancelContentTouches forwards 158 + // touches to JS immediately, which matches Safari's behaviour for 159 + // pages that handle their own gestures. 160 + webView.scrollView.delaysContentTouches = false 161 + webView.scrollView.canCancelContentTouches = false 162 + 141 163 // Add a script message handler to handle messages from JavaScript 142 164 AppDelegate.shared?.appWebView = webView // Set the shared appWebView 143 165 return webView ··· 146 168 func updateUIView(_ webView: WKWebView, context: Context) { 147 169 // let testHTML = "<html><script>window.ontouchstart = () => { console.log('hi'); const a = document.createElement('a'); a.href = 'https://example.com'; a.innerText = 'OKAY'; document.body.appendChild(a); a.click(); }</script><body></body></html>" 148 170 // webView.loadHTMLString(testHTML, baseURL: nil) 149 - // 🚫 .reloadIgnoringLocalCacheData makes the initial top-level 150 - // request bypass any leftover URL cache, so a redeploy is 151 - // visible after a single app relaunch. 171 + // 🚫 Belt + braces against stale modules: 172 + // • .reloadIgnoringLocalCacheData bypasses the URL cache. 173 + // • A per-launch ?_iosbust=<timestamp> query param defeats any 174 + // cache key (CDN, SW match) that ignores cache-control. The 175 + // AC site itself ignores unknown query params on the root. 176 + guard var components = URLComponents(string: url) else { return } 177 + if components.scheme == "http" || components.scheme == "https" { 178 + var items = components.queryItems ?? [] 179 + items.append(URLQueryItem( 180 + name: "_iosbust", 181 + value: String(Int(Date().timeIntervalSince1970)) 182 + )) 183 + components.queryItems = items 184 + } 185 + guard let bustedURL = components.url else { return } 152 186 let request = URLRequest( 153 - url: URL(string: url)!, 154 - cachePolicy: .reloadIgnoringLocalCacheData, 187 + url: bustedURL, 188 + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 155 189 timeoutInterval: 30 156 190 ) 157 191 webView.load(request)
+57 -5
system/public/aesthetic.computer/disks/cap.mjs
··· 47 47 let micConnected = false; // Track if mic is actually connected 48 48 let pendingRecordStart = false; // Track if user wants to record but mic isn't ready 49 49 50 + // 🔍 Slide-up-to-zoom (while holding record). 51 + // `zoom` scales the camera frame around the screen center. 1 = no zoom. 52 + // `zoomStartY` is the Y position of the touch that began recording. 53 + // `zoomMax` caps how far the user can zoom in via slide. 54 + // `zoomSensitivity` is the screen-pixels-per-zoom-unit: at 240 px/unit 55 + // a comfy thumb slide of half a phone height (~400px) gives ~1.7x zoom. 56 + let zoom = 1; 57 + let zoomStartY = null; 58 + const zoomMax = 4; 59 + const zoomMin = 1; 60 + const zoomSensitivity = 240; 61 + 50 62 // 🥾 Boot 51 63 function boot({ ui, params, colon, system, rec, notice }) { 52 64 // Parse parameters ··· 112 124 }); 113 125 } 114 126 115 - // Paste the video centered on screen 127 + // Paste the video centered on screen, scaled by the current zoom factor. 128 + // paste(frame, x, y, scale) draws the frame upscaled by `scale`; we 129 + // recenter so the camera image stays anchored to the screen center as 130 + // the user slides up to zoom in. Off-screen pixels clip naturally. 116 131 if (frame) { 117 - const offsetX = floor((screen.width - frame.width) / 2); 118 - const offsetY = floor((screen.height - frame.height) / 2); 132 + const drawW = frame.width * zoom; 133 + const drawH = frame.height * zoom; 134 + const offsetX = floor((screen.width - drawW) / 2); 135 + const offsetY = floor((screen.height - drawH) / 2); 119 136 wipe(0); // Clear first 120 - paste(frame, offsetX, offsetY); 137 + paste(frame, offsetX, offsetY, zoom); 121 138 } 122 139 123 140 // 🎬 Draw UI elements to a recording UI overlay (NOT captured in tape). ··· 185 202 center: "x", 186 203 }); 187 204 } else { 188 - $.ink(255, 80, 80, 220).write("● recording — release to stop", { 205 + const zoomedIn = zoom > 1.02; 206 + const label = zoomedIn 207 + ? `● rec ${zoom.toFixed(1)}× — release to stop` 208 + : "● recording — slide up to zoom, release to stop"; 209 + $.ink(255, 80, 80, 220).write(label, { 189 210 x: centerX, 190 211 y: bottomY, 191 212 center: "x", 192 213 }); 214 + 215 + // Zoom bar on the right edge: fills upward as zoom increases, 216 + // gives a tactile sense of the slide gesture without obscuring 217 + // the camera frame. 218 + const barH = floor(screen.height * 0.4); 219 + const barX = screen.width - 6; 220 + const barY = floor(screen.height / 2 - barH / 2); 221 + $.ink(255, 255, 255, 40).box(barX, barY, 2, barH); 222 + const fillRatio = (zoom - zoomMin) / (zoomMax - zoomMin); 223 + const fillH = floor(barH * fillRatio); 224 + $.ink(255, 80, 80, 220).box( 225 + barX, 226 + barY + (barH - fillH), 227 + 2, 228 + fillH, 229 + ); 193 230 } 194 231 195 232 // Recording border indicator ··· 336 373 const onSwapBtn = swapBtn?.btn?.box?.contains(e); 337 374 const onHud = hud?.currentLabel()?.btn?.down; 338 375 if (!onSwapBtn && !onHud && !isRecording && !pendingRecordStart) { 376 + // Anchor zoom slider at the touch-down Y so the very first drag 377 + // pixel is treated as zoom = 1, no jumps. 378 + zoomStartY = e.y; 339 379 startRecording(rec, sound, notice); 340 380 } 341 381 } 342 382 383 + // 🔍 Slide up while holding to zoom in, slide back down to zoom out. 384 + // We anchor zoomStartY at the original touch and remap the absolute 385 + // Y delta each draw event — this means the user can swing freely 386 + // up and down without accumulating drift across pauses. 387 + if (e.is("draw") && isRecording && zoomStartY !== null) { 388 + const dy = zoomStartY - e.y; // up is positive 389 + const target = 1 + dy / zoomSensitivity; 390 + zoom = Math.max(zoomMin, Math.min(zoomMax, target)); 391 + } 392 + 343 393 if (e.is("lift") && !leaving()) { 344 394 if (isRecording) { 345 395 stopRecording(rec, sound, jump); ··· 348 398 pendingRecordStart = false; 349 399 notice("CANCELLED", ["yellow", "black"]); 350 400 } 401 + zoomStartY = null; 402 + zoom = 1; 351 403 } 352 404 353 405 // Keyboard shortcut: space toggles record (useful for desktop testing).