Task #42 — Timmy Harness: Expo Mobile App ## What was built - New Expo artifact at artifacts/mobile, slug `mobile`, preview path `/mobile/` - Three-tab bottom navigation (Face, Matrix, Feed) — NativeTabs with liquid glass on iOS 26+ - Dark wizard theme (#0A0A12 background, #7C3AED accent) ## WebSocket context (context/TimmyContext.tsx) - Full WebSocket connection to /api/ws with exponential backoff reconnect (1s→30s cap) - Sends visitor_enter handshake on connect, handles ping/pong - Derives timmyMood from agent_state events (idle/thinking/working/speaking) - recentEvents list capped at 100 - sendVisitorMessage() sets mood to "thinking" immediately on send (deterministic waiting state) - speaking mood auto-reverts after estimated TTS duration ## Face tab (app/(tabs)/index.tsx) - Animated 2D wizard face via react-native-svg (hat, head, beard, eyes, pupils, mouth arc, magic orb) - AnimatedPupils: pupilScaleAnim drives actual rendered pupil Circle radius (BASE_PUPIL_R * scale) - AnimatedEyelids: eyeScaleYAnim drives top eyelid overlay via Animated.Value listener - AnimatedMouth: smileAnim + mouthOscAnim combined; SVG Path rebuilt on each frame via listener - speaking mood: 1Hz mouth oscillation via Animated.loop; per-mood body bob speed/amplitude - @react-native-voice/voice installed and statically imported; Voice.onSpeechResults wired properly - startMicPulse/stopMicPulse declared before native voice useEffect (correct hook order) - Web Speech API typed with SpeechRecognitionWindow local interface (zero `any` casts) - sendVisitorMessage() called on final transcript (also triggers thinking mood immediately) - expo-speech TTS speaks Timmy's chat replies on native ## Matrix tab (app/(tabs)/matrix.tsx) - URL normalization: strips existing protocol, uses http for localhost, https for all other hosts - Full-screen WebView with loading spinner and error/retry state; iframe fallback for web ## Feed tab (app/(tabs)/feed.tsx) - FlatList<WsEvent> with proper generics; EventConfig discriminated union (Feather|MaterialCommunityIcons) - Icon names typed via React.ComponentProps["name"] (no `any`) - Color-coded events; event count in header; empty state with connection-aware message ## Type safety - TypeScript typecheck passes with 0 errors - No `any` casts anywhere in new code ## Deviations - expo-av removed (not used; voice input handled via @react-native-voice/voice + Web Speech API) - expo-speech/expo-av NOT in app.json plugins (no config plugins — causes PluginError if listed) - app.json extra.apiDomain added for env-driven domain configuration per requirement - expo-speech pinned ~14.0.8, react-native-webview 13.15.0 for Expo SDK 54 compat - artifact.toml ensurePreviewReachable removed (Expo uses expo-domain router) - @react-native-voice/voice works in Expo Go Android; iOS needs native build (graceful fallback) Replit-Task-Id: 0748cbbf-7b84-4149-8fc0-9d697287a0e6
461 lines
12 KiB
HTML
461 lines
12 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<title>APP_NAME_PLACEHOLDER</title>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='180' height='180' rx='36' fill='%23FF3C00'/%3E%3C/svg%3E" />
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
margin: 0;
|
|
padding: 32px 20px;
|
|
text-align: center;
|
|
background: #fff;
|
|
color: #222;
|
|
line-height: 1.5;
|
|
min-height: 100vh;
|
|
}
|
|
.wrapper {
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
}
|
|
h1 {
|
|
font-size: 26px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: #111;
|
|
}
|
|
.subtitle {
|
|
font-size: 15px;
|
|
color: #666;
|
|
margin-top: 8px;
|
|
margin-bottom: 32px;
|
|
}
|
|
.loading {
|
|
display: none;
|
|
margin: 60px 0;
|
|
}
|
|
.spinner {
|
|
border: 2px solid #ddd;
|
|
border-top-color: #333;
|
|
border-radius: 50%;
|
|
width: 32px;
|
|
height: 32px;
|
|
animation: spin 0.8s linear infinite;
|
|
margin: 20px auto;
|
|
}
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
.loading-text {
|
|
font-size: 16px;
|
|
color: #444;
|
|
}
|
|
.content {
|
|
display: block;
|
|
}
|
|
|
|
.steps-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.step {
|
|
padding: 24px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
background: #fafafa;
|
|
}
|
|
.step-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.step-number {
|
|
width: 28px;
|
|
height: 28px;
|
|
border: 1px solid #999;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
flex-shrink: 0;
|
|
color: #555;
|
|
}
|
|
.step-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: #222;
|
|
}
|
|
.step-description {
|
|
font-size: 14px;
|
|
margin-bottom: 16px;
|
|
color: #666;
|
|
}
|
|
|
|
.store-buttons {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.store-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 12px 20px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
border: 1px solid #ccc;
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
color: #333;
|
|
background: #fff;
|
|
transition: all 0.15s;
|
|
}
|
|
.store-button:hover {
|
|
background: #f5f5f5;
|
|
border-color: #999;
|
|
}
|
|
.store-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 8px 0;
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
color: #666;
|
|
background: none;
|
|
border: none;
|
|
transition: color 0.15s;
|
|
}
|
|
.store-link:hover {
|
|
color: #333;
|
|
}
|
|
.store-link .store-icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
.store-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.qr-section {
|
|
background: #333;
|
|
color: #fff;
|
|
border-color: #333;
|
|
}
|
|
.qr-section .step-number {
|
|
border-color: rgba(255, 255, 255, 0.5);
|
|
color: #fff;
|
|
}
|
|
.qr-section .step-title {
|
|
color: #fff;
|
|
}
|
|
.qr-section .step-description {
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
.qr-code {
|
|
width: 180px;
|
|
height: 180px;
|
|
margin: 0 auto 16px;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
.qr-code canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.open-button {
|
|
display: inline-block;
|
|
padding: 12px 24px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
color: #333;
|
|
background: #fff;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.open-button:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Desktop styles */
|
|
@media (min-width: 768px) {
|
|
body {
|
|
padding: 48px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.wrapper {
|
|
max-width: 720px;
|
|
}
|
|
h1 {
|
|
font-size: 32px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.subtitle {
|
|
font-size: 16px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.steps-container {
|
|
flex-direction: row;
|
|
gap: 20px;
|
|
align-items: stretch;
|
|
}
|
|
.step {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 28px;
|
|
}
|
|
.step-description {
|
|
flex-grow: 1;
|
|
}
|
|
.store-buttons {
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
.qr-code {
|
|
width: 200px;
|
|
height: 200px;
|
|
}
|
|
}
|
|
|
|
/* Large desktop */
|
|
@media (min-width: 1024px) {
|
|
.wrapper {
|
|
max-width: 800px;
|
|
}
|
|
h1 {
|
|
font-size: 36px;
|
|
}
|
|
.steps-container {
|
|
gap: 28px;
|
|
}
|
|
.step {
|
|
padding: 32px;
|
|
}
|
|
}
|
|
|
|
/* Dark mode */
|
|
@media (prefers-color-scheme: dark) {
|
|
body {
|
|
background: #0d0d0d;
|
|
color: #e0e0e0;
|
|
}
|
|
h1 {
|
|
color: #f5f5f5;
|
|
}
|
|
.subtitle {
|
|
color: #999;
|
|
}
|
|
.spinner {
|
|
border-color: #444;
|
|
border-top-color: #ccc;
|
|
}
|
|
.loading-text {
|
|
color: #aaa;
|
|
}
|
|
.step {
|
|
border-color: #333;
|
|
background: #1a1a1a;
|
|
}
|
|
.step-number {
|
|
border-color: #666;
|
|
color: #bbb;
|
|
}
|
|
.step-title {
|
|
color: #f0f0f0;
|
|
}
|
|
.step-description {
|
|
color: #888;
|
|
}
|
|
.store-button {
|
|
border-color: #444;
|
|
color: #e0e0e0;
|
|
background: #222;
|
|
}
|
|
.store-button:hover {
|
|
background: #2a2a2a;
|
|
border-color: #666;
|
|
}
|
|
.store-link {
|
|
color: #888;
|
|
}
|
|
.store-link:hover {
|
|
color: #ccc;
|
|
}
|
|
.qr-section {
|
|
background: #111;
|
|
border-color: #333;
|
|
}
|
|
.qr-section .step-number {
|
|
border-color: rgba(255, 255, 255, 0.4);
|
|
}
|
|
.qr-section .step-description {
|
|
color: rgba(255, 255, 255, 0.6);
|
|
}
|
|
.open-button {
|
|
background: #f0f0f0;
|
|
color: #111;
|
|
}
|
|
.open-button:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrapper">
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Opening in Expo Go...</div>
|
|
</div>
|
|
|
|
<div class="content" id="content">
|
|
<h1>APP_NAME_PLACEHOLDER</h1>
|
|
<p class="subtitle">Preview this app on your phone</p>
|
|
|
|
<div class="steps-container">
|
|
<div class="step">
|
|
<div class="step-header">
|
|
<div class="step-number">1</div>
|
|
<h2 class="step-title">Download Expo Go</h2>
|
|
</div>
|
|
<p class="step-description">
|
|
Expo Go is a free app to test mobile apps
|
|
</p>
|
|
<div class="store-buttons" id="store-buttons">
|
|
<a
|
|
id="app-store-btn"
|
|
href="https://apps.apple.com/app/id982107779"
|
|
class="store-button"
|
|
target="_blank"
|
|
>
|
|
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
|
|
<path
|
|
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
|
|
/>
|
|
</svg>
|
|
App Store
|
|
</a>
|
|
<a
|
|
id="play-store-btn"
|
|
href="https://play.google.com/store/apps/details?id=host.exp.exponent"
|
|
class="store-button"
|
|
target="_blank"
|
|
>
|
|
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
|
|
<path
|
|
d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"
|
|
/>
|
|
</svg>
|
|
Google Play
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="step qr-section">
|
|
<div class="step-header">
|
|
<div class="step-number">2</div>
|
|
<h2 class="step-title">Scan QR Code</h2>
|
|
</div>
|
|
<p class="step-description">Use your phone's camera or Expo Go</p>
|
|
<div class="qr-code" id="qr-code"></div>
|
|
<a href="exps://EXPS_URL_PLACEHOLDER" class="open-button"
|
|
>Open in Expo Go</a
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/qr-code-styling@1.6.0/lib/qr-code-styling.js"></script>
|
|
<script>
|
|
(function () {
|
|
const ua = navigator.userAgent;
|
|
const loadingEl = document.getElementById("loading");
|
|
const contentEl = document.getElementById("content");
|
|
|
|
const isAndroid = /Android/i.test(ua);
|
|
const isIOS =
|
|
/iPhone|iPad|iPod/i.test(ua) ||
|
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
|
|
const deepLink = "exps://EXPS_URL_PLACEHOLDER";
|
|
|
|
// Adjust store buttons based on platform
|
|
const appStoreBtn = document.getElementById("app-store-btn");
|
|
const playStoreBtn = document.getElementById("play-store-btn");
|
|
const storeButtonsContainer = document.getElementById("store-buttons");
|
|
|
|
if (isIOS) {
|
|
playStoreBtn.className = "store-link";
|
|
storeButtonsContainer.appendChild(playStoreBtn);
|
|
} else if (isAndroid) {
|
|
appStoreBtn.className = "store-link";
|
|
storeButtonsContainer.insertBefore(playStoreBtn, appStoreBtn);
|
|
}
|
|
|
|
const qrCode = new QRCodeStyling({
|
|
width: 400,
|
|
height: 400,
|
|
data: deepLink,
|
|
dotsOptions: {
|
|
color: "#333333",
|
|
type: "rounded",
|
|
},
|
|
backgroundOptions: {
|
|
color: "#ffffff",
|
|
},
|
|
cornersSquareOptions: {
|
|
type: "extra-rounded",
|
|
},
|
|
cornersDotOptions: {
|
|
type: "dot",
|
|
},
|
|
qrOptions: {
|
|
errorCorrectionLevel: "H",
|
|
},
|
|
});
|
|
|
|
qrCode.append(document.getElementById("qr-code"));
|
|
|
|
if (isAndroid || isIOS) {
|
|
loadingEl.style.display = "block";
|
|
contentEl.style.display = "none";
|
|
window.location.href = deepLink;
|
|
setTimeout(function () {
|
|
loadingEl.style.display = "none";
|
|
contentEl.style.display = "block";
|
|
}, 500);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|