Files
timmy-tower/artifacts/mobile/server/templates/landing-page.html
alexpaynex cf1819f34b feat(mobile): scaffold Expo mobile app for Timmy with Face/Matrix/Feed tabs
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
2026-03-19 23:55:16 +00:00

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>