feat: redesign mobile app with tactile UI

This commit is contained in:
Hermes Agent
2026-07-09 04:57:47 +00:00
parent 7eb9648eb7
commit d4e841a28c
12 changed files with 451 additions and 191 deletions
@@ -9,7 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-haptics')
}
@@ -1,3 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../../../node_modules/@capacitor/haptics/android')
+2 -1
View File
@@ -20,7 +20,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@capacitor/android": "^7.4.0",
"@capacitor/core": "^7.4.0"
"@capacitor/core": "^7.4.0",
"@capacitor/haptics": "^7.0.2"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Flame, Gem, Heart, Wifi, WifiOff } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js';
import type { ScreenId } from '../../app/navigation.js';
import { BottomNav } from '../bottom-nav/BottomNav.js';
@@ -9,6 +10,14 @@ type AppShellProps = {
onNavigate: (screen: ScreenId) => void;
};
const screenTitles: Record<ScreenId, { title: string; kicker: string }> = {
chat: { title: 'Quest', kicker: 'Hermes training path' },
files: { title: 'Backpack', kicker: 'Workspace loot' },
terminal: { title: 'Power move', kicker: 'Run a command' },
activity: { title: 'League', kicker: 'Companion stats' },
settings: { title: 'Nest', kicker: 'Connection setup' },
};
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
const { client } = useCompanion();
const [online, setOnline] = useState(false);
@@ -30,20 +39,32 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)
};
}, [client]);
const title = useMemo(() => screenTitles[activeScreen], [activeScreen]);
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Hermes Mobile</p>
<h1>Agent command center</h1>
<div className="phone-app">
<div className="app-shell">
<header className="app-header">
<div className="avatar-badge" aria-hidden="true"></div>
<div className="app-header__copy">
<p className="eyebrow">{title.kicker}</p>
<h1>{title.title}</h1>
</div>
<div className="top-stats" aria-label="Hermes status summary">
<span className="stat-chip stat-chip--fire"><Flame size={16} fill="currentColor" />7</span>
<span className="stat-chip stat-chip--gem"><Gem size={16} fill="currentColor" />550</span>
<span className="stat-chip stat-chip--heart"><Heart size={16} fill="currentColor" />3</span>
</div>
</header>
<div className={`connection-banner ${online ? 'connection-banner--online' : ''}`}>
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Companion online — ready for quests' : 'Companion offline — check Settings'}</span>
</div>
<div className={`status-pill ${online ? 'status-pill--online' : ''}`}>
<span className="status-pill__dot" />
{online ? 'Online' : 'Offline'}
</div>
</header>
<main className="app-main">{children}</main>
<BottomNav activeScreen={activeScreen} onNavigate={onNavigate} />
<main className="app-main">{children}</main>
<BottomNav activeScreen={activeScreen} onNavigate={onNavigate} />
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import type { ScreenId } from '../../app/navigation.js';
import { navItems } from '../../app/navigation.js';
@@ -6,6 +7,14 @@ type BottomNavProps = {
onNavigate: (screen: ScreenId) => void;
};
async function tap(): Promise<void> {
try {
await Haptics.impact({ style: ImpactStyle.Light });
} catch {
// Browser preview / unsupported devices: ignore.
}
}
export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
@@ -18,10 +27,13 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
aria-current={active ? 'page' : undefined}
className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`}
key={item.id}
onClick={() => onNavigate(item.id)}
onClick={() => {
void tap();
onNavigate(item.id);
}}
type="button"
>
<Icon aria-hidden="true" size={20} strokeWidth={2.4} />
<span className="bottom-nav__icon"><Icon aria-hidden="true" size={22} strokeWidth={3} /></span>
<span>{item.label}</span>
</button>
);
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Activity, RefreshCw, Trophy } from 'lucide-react';
import type { HealthResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js';
@@ -22,20 +23,28 @@ export function ActivityScreen() {
return (
<section className="screen stack-screen">
<div className="screen-heading">
<article className="quest-card quest-card--compact quest-card--gold">
<div className="quest-orb quest-orb--gold"><Trophy size={32} strokeWidth={3} /></div>
<div>
<p className="eyebrow">Activity</p>
<h2>Status</h2>
<p className="eyebrow">League board</p>
<h2>Companion status</h2>
<p>{status}</p>
</div>
<button className="small-button" onClick={() => void refresh()} type="button">Refresh</button>
<button className="round-action" aria-label="Refresh status" onClick={() => void refresh()} type="button"><RefreshCw size={22} /></button>
</article>
<div className="stat-board">
<article><strong>{health?.checks.hermes.cliAvailable ? 'Ready' : '—'}</strong><span>Hermes CLI</span></article>
<article><strong>{health ? `${Math.round(health.uptimeSeconds)}s` : '—'}</strong><span>Uptime</span></article>
<article><strong>{health?.mode ?? '—'}</strong><span>Mode</span></article>
</div>
<article className="panel-card">
<div className="panel-title"><Activity size={20} /> Details</div>
<dl className="status-grid">
<dt>Companion URL</dt><dd>{settings.baseUrl}</dd>
<dt>Status</dt><dd>{status}</dd>
<dt>Hermes CLI</dt><dd>{health?.checks.hermes.cliAvailable ? `Found at ${health.checks.hermes.cliPath}` : 'Unknown / unavailable'}</dd>
<dt>Workspace</dt><dd>{health?.config.workspaceRoot ?? 'Unknown'}</dd>
<dt>Uptime</dt><dd>{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}</dd>
</dl>
</article>
</section>
@@ -1,5 +1,5 @@
import { FormEvent, useState } from 'react';
import { SendHorizontal } from 'lucide-react';
import { Bot, CheckCircle2, SendHorizontal, Sparkles } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js';
type ChatMessage = {
@@ -42,17 +42,32 @@ export function ChatScreen() {
return (
<section className="screen chat-screen">
<div className="hero-card">
<p className="eyebrow">Chat</p>
<h2>Send Hermes a task from your phone.</h2>
<p>Prompts are sent to the companion server, which invokes the local Hermes CLI when available.</p>
<article className="quest-card quest-card--hero">
<div className="quest-orb"><Bot size={34} strokeWidth={3} /></div>
<div>
<p className="eyebrow">Daily quest</p>
<h2>Give Hermes one clean mission.</h2>
<p>Short, direct prompts work best. The companion runs them on this machine and brings back the result.</p>
</div>
</article>
<div className="lesson-path" aria-label="Suggested mission path">
<button className="path-node path-node--done" type="button"><CheckCircle2 size={22} /> Check a service</button>
<button className="path-node path-node--current" type="button"><Sparkles size={22} /> Ask Hermes</button>
<button className="path-node" type="button"><Bot size={22} /> Review result</button>
</div>
<div className="message-list" aria-live="polite">
{messages.length === 0 ? <p className="empty-note">No messages yet. Ask Hermes to inspect, edit, or summarize something.</p> : null}
{messages.length === 0 ? (
<article className="empty-lesson">
<span className="empty-lesson__mascot">🪽</span>
<strong>No mission yet</strong>
<p>Try: check PM2 status or summarize the latest logs.</p>
</article>
) : null}
{messages.map((message, index) => (
<article className={`message message--${message.role}`} key={`${message.role}-${index}`}>
<span>{message.role}</span>
<span>{message.role === 'user' ? 'You' : 'Hermes'}</span>
<p>{message.content}</p>
</article>
))}
@@ -61,12 +76,10 @@ export function ChatScreen() {
{error ? <p className="error-card">{error}</p> : null}
<form className="composer" onSubmit={submitPrompt}>
<textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} value={prompt} />
<div className="composer__actions">
<button className="send-button" disabled={busy || !prompt.trim()} type="submit">
{busy ? 'Sending…' : 'Send'} <SendHorizontal size={18} />
</button>
</div>
<textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="What should Hermes do?" rows={4} value={prompt} />
<button className="primary-button composer__send" disabled={busy || !prompt.trim()} type="submit">
{busy ? 'Working…' : 'Start quest'} <SendHorizontal size={20} />
</button>
</form>
</section>
);
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { FileText, Folder, RefreshCw, Save, StepBack } from 'lucide-react';
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js';
@@ -76,26 +77,31 @@ export function FilesScreen() {
return (
<section className="screen stack-screen">
<div className="screen-heading">
<article className="quest-card quest-card--compact">
<div>
<p className="eyebrow">Workspace</p>
<h2>Files</h2>
<p className="eyebrow">Backpack</p>
<h2>Workspace files</h2>
<p>Tap folders to go deeper. Files open into a mobile editor.</p>
</div>
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
</div>
<button className="round-action" aria-label="Refresh files" onClick={() => void loadDirectory()} type="button"><RefreshCw size={22} /></button>
</article>
<article className="panel-card">
<article className="panel-card file-panel">
<div className="path-bar">
<button disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button">Up</button>
<button className="secondary-button" disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button"><StepBack size={18} /> Up</button>
<span>{path}</span>
</div>
<div className="file-list">
{entries.map((entry) => (
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button">
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span>
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
</button>
))}
{entries.map((entry) => {
const Icon = entry.type === 'directory' ? Folder : FileText;
return (
<button className={`file-row file-row--${entry.type}`} key={entry.path} onClick={() => void openEntry(entry)} type="button">
<span className="file-row__icon"><Icon size={24} fill="currentColor" /></span>
<span className="file-row__name">{entry.name}</span>
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
</button>
);
})}
</div>
</article>
@@ -106,7 +112,7 @@ export function FilesScreen() {
<p className="eyebrow">Editing</p>
<h3>{activeFile.path}</h3>
</div>
<button className="small-button" disabled={busy} onClick={() => void saveFile()} type="button">Save</button>
<button className="primary-button primary-button--small" disabled={busy} onClick={() => void saveFile()} type="button"><Save size={18} /> Save</button>
</div>
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
</article>
@@ -1,4 +1,5 @@
import { FormEvent, useState } from 'react';
import { KeyRound, Link, PlugZap, Save } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js';
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
@@ -29,18 +30,24 @@ export function SettingsScreen() {
}
return (
<section className="screen stack-screen">
<h2>Settings</h2>
<form className="settings-card" onSubmit={save}>
<label htmlFor="server-url">Companion server URL</label>
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} />
<label htmlFor="access-key">Access key</label>
<input id="access-key" onChange={(event) => setAccessKey(event.target.value)} placeholder="hm_…" type="password" value={accessKey} />
<div className="composer__actions">
<button className="small-button" type="submit">Save</button>
<button className="send-button" onClick={() => void testConnection()} type="button">Test connection</button>
<section className="screen stack-screen settings-screen">
<article className="quest-card quest-card--compact quest-card--green">
<div className="quest-orb quest-orb--green"><PlugZap size={32} strokeWidth={3} /></div>
<div>
<p className="eyebrow">Nest setup</p>
<h2>Pair your companion</h2>
<p>{status}</p>
</div>
</article>
<form className="settings-card" onSubmit={save}>
<label htmlFor="server-url"><Link size={18} /> Companion server URL</label>
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} />
<label htmlFor="access-key"><KeyRound size={18} /> Access key</label>
<input id="access-key" onChange={(event) => setAccessKey(event.target.value)} placeholder="hm_…" type="password" value={accessKey} />
<div className="button-row">
<button className="secondary-button" type="submit"><Save size={18} /> Save</button>
<button className="primary-button" onClick={() => void testConnection()} type="button">Test <PlugZap size={19} /></button>
</div>
<p>{status}</p>
</form>
</section>
);
@@ -1,4 +1,5 @@
import { FormEvent, useState } from 'react';
import { Play, TerminalSquare, Zap } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js';
export function TerminalScreen() {
@@ -30,19 +31,22 @@ export function TerminalScreen() {
}
return (
<section className="screen stack-screen">
<div className="screen-heading">
<section className="screen stack-screen terminal-screen">
<article className="quest-card quest-card--terminal">
<div className="quest-orb quest-orb--blue"><TerminalSquare size={34} strokeWidth={3} /></div>
<div>
<p className="eyebrow">Remote shell</p>
<h2>Terminal</h2>
<p className="eyebrow">Power move</p>
<h2>Run one safe command.</h2>
<p>Commands execute through the companion server inside the configured workspace.</p>
</div>
</div>
<Zap className="spark-corner" size={34} fill="currentColor" />
</article>
<form className="panel-card terminal-form" onSubmit={runCommand}>
<label htmlFor="cwd">Working directory</label>
<input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} />
<label htmlFor="command">Command</label>
<textarea id="command" onChange={(event) => setCommand(event.target.value)} rows={4} value={command} />
<button className="send-button" disabled={busy || !command.trim()} type="submit">{busy ? 'Running…' : 'Run command'}</button>
<button className="primary-button" disabled={busy || !command.trim()} type="submit">{busy ? 'Running…' : 'Run command'} <Play size={20} fill="currentColor" /></button>
</form>
<pre className="terminal-output">{output || 'Command output appears here.'}</pre>
</section>
+298 -124
View File
@@ -1,180 +1,354 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900;1000&display=swap');
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #11131f;
color: #fff8ec;
color-scheme: light;
font-family: Nunito, ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
background: oklch(0.94 0.038 132);
color: oklch(0.25 0.04 150);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--green: oklch(0.72 0.22 142);
--green-dark: oklch(0.56 0.2 145);
--blue: oklch(0.66 0.18 246);
--blue-dark: oklch(0.5 0.16 250);
--gold: oklch(0.84 0.16 83);
--orange: oklch(0.75 0.17 55);
--red: oklch(0.66 0.2 26);
--ink: oklch(0.27 0.036 153);
--muted: oklch(0.5 0.035 150);
--paper: oklch(0.995 0.01 110);
--surface: oklch(0.97 0.018 118);
--line: oklch(0.87 0.035 125);
--shadow: oklch(0.78 0.06 132);
}
* { box-sizing: border-box; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body, #root { min-height: 100%; }
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 20% 10%, rgba(255, 184, 77, 0.24), transparent 34rem),
radial-gradient(circle at 80% 0%, rgba(102, 92, 255, 0.24), transparent 28rem),
#11131f;
radial-gradient(circle at 50% -10%, oklch(0.96 0.09 130), transparent 20rem),
linear-gradient(180deg, oklch(0.95 0.045 132), oklch(0.91 0.05 142));
overscroll-behavior-y: none;
}
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
button { cursor: pointer; touch-action: manipulation; }
button:disabled { cursor: not-allowed; opacity: 0.55; filter: saturate(0.6); }
.phone-app {
width: 100%;
min-height: 100vh;
display: grid;
place-items: stretch center;
}
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 34rem);
grid-template-rows: auto auto 1fr auto;
width: min(100%, 30rem);
min-height: 100vh;
margin: 0 auto;
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom));
}
.app-header, .screen-heading, .composer__actions, .path-bar {
display: flex;
align-items: center;
padding: max(0.75rem, env(safe-area-inset-top)) 0.9rem max(0.7rem, env(safe-area-inset-bottom));
gap: 0.75rem;
}
.app-header, .screen-heading, .path-bar { justify-content: space-between; }
.app-header { align-items: flex-start; padding: 0.75rem 0 1rem; }
.app-header h1, .screen h2, .hero-card h2, .editor-card h3 { margin: 0; line-height: 1.05; }
.app-header h1 { max-width: 12rem; font-size: clamp(1.5rem, 7vw, 2.35rem); }
.screen h2 { font-size: 2rem; }
.editor-card h3 { max-width: 14rem; overflow-wrap: anywhere; }
.app-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
padding-top: 0.15rem;
}
.avatar-badge {
width: 3.3rem;
height: 3.3rem;
display: grid;
place-items: center;
border: 3px solid oklch(0.78 0.18 142);
border-radius: 1.25rem;
background: linear-gradient(180deg, oklch(0.82 0.22 140), var(--green));
box-shadow: 0 0.34rem 0 var(--green-dark);
color: white;
font-size: 1.6rem;
}
.app-header__copy { min-width: 0; }
.eyebrow {
margin: 0 0 0.4rem;
color: #ffca6a;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
margin: 0 0 0.05rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 1000;
letter-spacing: 0.085em;
text-transform: uppercase;
}
.app-header h1, .quest-card h2, .screen h2, .editor-card h3 { margin: 0; line-height: 0.98; letter-spacing: -0.035em; }
.app-header h1 { font-size: 1.85rem; font-weight: 1000; color: var(--ink); }
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 0.45rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 248, 236, 0.8);
font-size: 0.72rem;
font-weight: 700;
}
.status-pill__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #ff6a6a;
box-shadow: 0 0 1rem rgba(255, 106, 106, 0.9);
}
.status-pill--online .status-pill__dot {
background: #7cffb1;
box-shadow: 0 0 1rem rgba(124, 255, 177, 0.9);
}
.app-main { overflow: auto; padding: 0.5rem 0 1rem; }
.screen { display: grid; gap: 1rem; }
.hero-card, .composer, .panel-card, .message, .error-card, .settings-card, .terminal-output {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(21, 24, 39, 0.78);
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.24);
backdrop-filter: blur(18px);
}
.hero-card, .panel-card, .settings-card, .error-card { padding: 1rem; }
.hero-card h2 { font-size: clamp(2rem, 11vw, 3.65rem); letter-spacing: -0.06em; }
.hero-card p:last-child, .panel-card p, .settings-card p, .empty-note { margin: 0.8rem 0 0; color: rgba(255, 248, 236, 0.72); line-height: 1.5; }
.composer, .terminal-form, .settings-card { display: grid; gap: 0.85rem; padding: 0.75rem; }
.composer textarea, .settings-card input, .terminal-form input, .terminal-form textarea, .editor-card textarea {
width: 100%;
border: 0;
border-radius: 1rem;
outline: 0;
background: rgba(255, 255, 255, 0.08);
color: #fff8ec;
padding: 0.9rem 1rem;
}
.composer textarea { min-height: 9rem; resize: vertical; }
.editor-card textarea { min-height: 18rem; resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.85rem; }
textarea::placeholder, input::placeholder { color: rgba(255, 248, 236, 0.42); }
.send-button, .small-button, .path-bar button {
.top-stats { display: flex; flex-direction: column; gap: 0.28rem; }
.stat-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.7rem;
border: 0;
gap: 0.18rem;
min-width: 3.15rem;
min-height: 1.55rem;
border: 2px solid currentColor;
border-radius: 999px;
background: var(--paper);
font-size: 0.78rem;
font-weight: 1000;
line-height: 1;
}
.stat-chip--fire { color: var(--orange); }
.stat-chip--gem { color: var(--blue); }
.stat-chip--heart { color: var(--red); }
.connection-banner {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.7rem;
border: 2px solid oklch(0.82 0.04 35);
border-radius: 1rem;
padding: 0.55rem 0.75rem;
background: oklch(0.96 0.045 44);
color: oklch(0.48 0.12 35);
box-shadow: 0 0.28rem 0 oklch(0.83 0.05 40);
font-weight: 900;
font-size: 0.86rem;
}
.connection-banner--online {
border-color: oklch(0.78 0.16 145);
background: oklch(0.95 0.075 145);
color: oklch(0.43 0.14 145);
box-shadow: 0 0.28rem 0 oklch(0.76 0.09 145);
}
.send-button { gap: 0.45rem; margin-left: auto; padding: 0 1.05rem; background: #ffca6a; color: #17110a; }
.small-button, .path-bar button { padding: 0 0.9rem; background: rgba(255, 255, 255, 0.1); color: #fff8ec; }
.app-main {
overflow: auto;
padding: 0.25rem 0.08rem 0.4rem;
scrollbar-width: none;
}
.app-main::-webkit-scrollbar { display: none; }
.screen { display: grid; gap: 1rem; animation: screen-in 220ms cubic-bezier(.2,.8,.2,1); }
.stack-screen { align-content: start; }
@keyframes screen-in { from { opacity: 0; transform: translateY(0.5rem) scale(0.995); } to { opacity: 1; transform: none; } }
.quest-card, .panel-card, .settings-card, .message, .empty-lesson, .terminal-output, .stat-board article {
border: 2px solid var(--line);
border-radius: 1.35rem;
background: var(--paper);
box-shadow: 0 0.38rem 0 var(--shadow);
}
.quest-card {
position: relative;
display: flex;
align-items: center;
gap: 0.95rem;
padding: 1rem;
overflow: hidden;
}
.quest-card--hero {
align-items: flex-start;
min-height: 9.5rem;
background:
radial-gradient(circle at 92% 16%, oklch(0.9 0.12 90), transparent 5rem),
var(--paper);
}
.quest-card--compact { justify-content: space-between; }
.quest-card--terminal { background: linear-gradient(135deg, oklch(0.96 0.045 245), var(--paper)); }
.quest-card--gold { background: linear-gradient(135deg, oklch(0.97 0.08 85), var(--paper)); }
.quest-card--green { background: linear-gradient(135deg, oklch(0.96 0.07 145), var(--paper)); }
.quest-card h2 { color: var(--ink); font-size: clamp(1.55rem, 8vw, 2.35rem); font-weight: 1000; }
.quest-card p:not(.eyebrow) { margin: 0.45rem 0 0; color: var(--muted); font-size: 0.94rem; font-weight: 800; line-height: 1.25; }
.quest-orb {
flex: 0 0 auto;
width: 4.15rem;
height: 4.15rem;
display: grid;
place-items: center;
border: 3px solid var(--green-dark);
border-radius: 50%;
background: var(--green);
color: white;
box-shadow: 0 0.36rem 0 var(--green-dark);
}
.quest-orb--blue { background: var(--blue); border-color: var(--blue-dark); box-shadow: 0 0.36rem 0 var(--blue-dark); }
.quest-orb--gold { background: var(--gold); border-color: oklch(0.66 0.14 78); box-shadow: 0 0.36rem 0 oklch(0.66 0.14 78); color: oklch(0.34 0.08 70); }
.quest-orb--green { background: var(--green); }
.spark-corner { position: absolute; right: 1rem; top: 1rem; color: oklch(0.84 0.15 85); transform: rotate(12deg); }
.lesson-path { display: grid; gap: 0.6rem; padding: 0 0.65rem; }
.path-node {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: 3.2rem;
border: 2px solid var(--line);
border-radius: 999px;
padding: 0 1rem;
background: var(--surface);
color: var(--muted);
box-shadow: 0 0.28rem 0 var(--shadow);
font-weight: 1000;
}
.path-node:nth-child(2) { justify-self: center; }
.path-node:nth-child(3) { justify-self: end; }
.path-node--done { color: var(--green-dark); background: oklch(0.95 0.07 145); }
.path-node--current { color: white; background: var(--green); border-color: var(--green-dark); box-shadow: 0 0.34rem 0 var(--green-dark); }
.panel-card, .settings-card { padding: 0.9rem; }
.panel-title { display: inline-flex; align-items: center; gap: 0.45rem; margin-bottom: 0.8rem; color: var(--ink); font-weight: 1000; }
.screen-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
.screen-heading h3 { max-width: 13rem; overflow-wrap: anywhere; font-size: 1.25rem; font-weight: 1000; color: var(--ink); }
.composer, .terminal-form, .settings-card { display: grid; gap: 0.85rem; }
.composer {
position: sticky;
bottom: 0;
border: 2px solid var(--line);
border-radius: 1.35rem;
padding: 0.75rem;
background: var(--paper);
box-shadow: 0 0.38rem 0 var(--shadow);
}
.composer textarea, .settings-card input, .terminal-form input, .terminal-form textarea, .editor-card textarea {
width: 100%;
border: 2px solid var(--line);
border-radius: 1rem;
outline: 0;
background: oklch(0.97 0.015 120);
color: var(--ink);
padding: 0.9rem 1rem;
font-weight: 800;
box-shadow: inset 0 0.12rem 0 oklch(0.89 0.02 120);
}
.composer textarea { min-height: 7.2rem; resize: none; }
.editor-card textarea { min-height: 18rem; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.84rem; }
textarea::placeholder, input::placeholder { color: oklch(0.62 0.03 140); }
label { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--muted); font-size: 0.82rem; font-weight: 1000; text-transform: uppercase; letter-spacing: 0.03em; }
.primary-button, .secondary-button, .round-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 0;
font-weight: 1000;
transition: transform 90ms ease, box-shadow 90ms ease, filter 90ms ease;
}
.primary-button {
min-height: 3.25rem;
border-radius: 1rem;
padding: 0 1.1rem;
background: var(--green);
color: white;
box-shadow: 0 0.38rem 0 var(--green-dark);
}
.primary-button--small { min-height: 2.75rem; border-radius: 0.9rem; }
.secondary-button {
min-height: 2.85rem;
border-radius: 0.9rem;
padding: 0 0.9rem;
background: var(--surface);
color: var(--ink);
box-shadow: 0 0.3rem 0 var(--shadow);
}
.round-action {
flex: 0 0 auto;
width: 3.1rem;
height: 3.1rem;
border-radius: 1rem;
background: var(--blue);
color: white;
box-shadow: 0 0.34rem 0 var(--blue-dark);
}
.primary-button:active, .secondary-button:active, .round-action:active, .path-node:active, .file-row:active, .bottom-nav__item:active {
transform: translateY(0.25rem);
box-shadow: 0 0.08rem 0 color-mix(in oklch, currentColor 35%, var(--shadow));
}
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.composer__send { width: 100%; }
.message-list, .file-list { display: grid; gap: 0.65rem; }
.empty-lesson { display: grid; justify-items: center; padding: 1.1rem; text-align: center; }
.empty-lesson__mascot { font-size: 2.7rem; line-height: 1; }
.empty-lesson strong { margin-top: 0.35rem; color: var(--ink); font-size: 1.25rem; font-weight: 1000; }
.empty-lesson p { margin: 0.25rem 0 0; color: var(--muted); font-weight: 800; }
.message { padding: 0.85rem; }
.message span, .status-grid dt, .terminal-form label { color: #89e5ff; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
.message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.45; }
.message--user { background: rgba(255, 202, 106, 0.14); }
.error-card { color: #ffd0d0; }
.message span { color: var(--green-dark); font-size: 0.78rem; font-weight: 1000; text-transform: uppercase; }
.message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.35; font-weight: 800; color: var(--ink); }
.message--user { margin-left: 1.5rem; background: oklch(0.95 0.07 145); border-color: oklch(0.8 0.13 145); }
.message--assistant { margin-right: 1.5rem; background: oklch(0.97 0.035 90); }
.error-card { margin: 0; border-color: oklch(0.83 0.09 25); background: oklch(0.96 0.045 25); color: oklch(0.45 0.13 25); padding: 0.85rem; font-weight: 900; }
.path-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: rgba(255, 248, 236, 0.72); }
.path-bar { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 0.7rem; margin-bottom: 0.8rem; }
.path-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 900; }
.file-row {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
width: 100%;
border: 0;
border-radius: 1rem;
padding: 0.85rem;
background: rgba(255, 255, 255, 0.07);
color: #fff8ec;
min-height: 3.85rem;
border: 2px solid var(--line);
border-radius: 1.05rem;
padding: 0.7rem;
background: var(--surface);
color: var(--ink);
text-align: left;
box-shadow: 0 0.28rem 0 var(--shadow);
}
.file-row small { color: rgba(255, 248, 236, 0.58); }
.file-row__icon {
width: 2.35rem;
height: 2.35rem;
display: grid;
place-items: center;
border-radius: 0.8rem;
background: oklch(0.92 0.08 85);
color: oklch(0.62 0.14 75);
}
.file-row--directory .file-row__icon { background: oklch(0.93 0.075 245); color: var(--blue); }
.file-row__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 1000; }
.file-row small { color: var(--muted); font-weight: 1000; }
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; margin: 0; }
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: rgba(255, 248, 236, 0.82); }
.terminal-output { min-height: 12rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #d7f8ff; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.82rem; }
.status-grid { display: grid; grid-template-columns: 7rem 1fr; gap: 0.7rem; margin: 0; }
.status-grid dt { color: var(--muted); font-size: 0.78rem; font-weight: 1000; text-transform: uppercase; }
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-weight: 900; }
.stat-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.55rem; }
.stat-board article { display: grid; place-items: center; min-height: 4.7rem; padding: 0.55rem; text-align: center; }
.stat-board strong { color: var(--green-dark); font-size: 1.2rem; font-weight: 1000; }
.stat-board span { color: var(--muted); font-size: 0.72rem; font-weight: 1000; text-transform: uppercase; }
.terminal-output { min-height: 12rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: oklch(0.32 0.07 250); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.82rem; line-height: 1.45; }
.bottom-nav {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.25rem;
border: 1px solid rgba(255, 255, 255, 0.12);
gap: 0.28rem;
border: 2px solid var(--line);
border-radius: 1.35rem;
padding: 0.35rem;
background: rgba(16, 18, 30, 0.9);
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
padding: 0.38rem;
background: var(--paper);
box-shadow: 0 0.42rem 0 var(--shadow);
}
.bottom-nav__item {
display: grid;
place-items: center;
gap: 0.2rem;
min-height: 3.4rem;
gap: 0.15rem;
min-height: 3.75rem;
border: 0;
border-radius: 1rem;
background: transparent;
color: rgba(255, 248, 236, 0.58);
font-size: 0.67rem;
font-weight: 800;
color: oklch(0.58 0.035 150);
font-size: 0.64rem;
font-weight: 1000;
}
.bottom-nav__icon { display: grid; place-items: center; }
.bottom-nav__item--active, .bottom-nav__item[aria-current='page'] { background: oklch(0.94 0.075 145); color: var(--green-dark); }
.bottom-nav__item--active, .bottom-nav__item[aria-current='page'] { background: rgba(255, 202, 106, 0.16); color: #ffca6a; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+10
View File
@@ -44,6 +44,7 @@
"dependencies": {
"@capacitor/android": "^7.4.0",
"@capacitor/core": "^7.4.0",
"@capacitor/haptics": "^7.0.2",
"@hermes-mobile/shared": "file:../../packages/shared",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
@@ -410,6 +411,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/haptics": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-7.0.5.tgz",
"integrity": "sha512-ujXuUkL8UG1YLhuJo/q+jiHr0K5JmclGZHmV1RttmiUBRG1XpC4Fc4gXUJyEX+6IoyfwWhgtPvJFRUB31HYnRQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",