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" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-haptics')
} }
@@ -1,3 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor') 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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@capacitor/android": "^7.4.0", "@capacitor/android": "^7.4.0",
"@capacitor/core": "^7.4.0" "@capacitor/core": "^7.4.0",
"@capacitor/haptics": "^7.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.2", "@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 { useCompanion } from '../../api/CompanionContext.js';
import type { ScreenId } from '../../app/navigation.js'; import type { ScreenId } from '../../app/navigation.js';
import { BottomNav } from '../bottom-nav/BottomNav.js'; import { BottomNav } from '../bottom-nav/BottomNav.js';
@@ -9,6 +10,14 @@ type AppShellProps = {
onNavigate: (screen: ScreenId) => void; 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) { export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
const { client } = useCompanion(); const { client } = useCompanion();
const [online, setOnline] = useState(false); const [online, setOnline] = useState(false);
@@ -30,20 +39,32 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)
}; };
}, [client]); }, [client]);
const title = useMemo(() => screenTitles[activeScreen], [activeScreen]);
return ( return (
<div className="phone-app">
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<div> <div className="avatar-badge" aria-hidden="true"></div>
<p className="eyebrow">Hermes Mobile</p> <div className="app-header__copy">
<h1>Agent command center</h1> <p className="eyebrow">{title.kicker}</p>
<h1>{title.title}</h1>
</div> </div>
<div className={`status-pill ${online ? 'status-pill--online' : ''}`}> <div className="top-stats" aria-label="Hermes status summary">
<span className="status-pill__dot" /> <span className="stat-chip stat-chip--fire"><Flame size={16} fill="currentColor" />7</span>
{online ? 'Online' : 'Offline'} <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> </div>
</header> </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>
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
<BottomNav activeScreen={activeScreen} onNavigate={onNavigate} /> <BottomNav activeScreen={activeScreen} onNavigate={onNavigate} />
</div> </div>
</div>
); );
} }
@@ -1,3 +1,4 @@
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import type { ScreenId } from '../../app/navigation.js'; import type { ScreenId } from '../../app/navigation.js';
import { navItems } from '../../app/navigation.js'; import { navItems } from '../../app/navigation.js';
@@ -6,6 +7,14 @@ type BottomNavProps = {
onNavigate: (screen: ScreenId) => void; 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) { export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
return ( return (
<nav className="bottom-nav" aria-label="Primary navigation"> <nav className="bottom-nav" aria-label="Primary navigation">
@@ -18,10 +27,13 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`} className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`}
key={item.id} key={item.id}
onClick={() => onNavigate(item.id)} onClick={() => {
void tap();
onNavigate(item.id);
}}
type="button" 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> <span>{item.label}</span>
</button> </button>
); );
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Activity, RefreshCw, Trophy } from 'lucide-react';
import type { HealthResponse } from '@hermes-mobile/shared'; import type { HealthResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js'; import { useCompanion } from '../../api/CompanionContext.js';
@@ -22,20 +23,28 @@ export function ActivityScreen() {
return ( return (
<section className="screen stack-screen"> <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> <div>
<p className="eyebrow">Activity</p> <p className="eyebrow">League board</p>
<h2>Status</h2> <h2>Companion status</h2>
<p>{status}</p>
</div> </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> </div>
<article className="panel-card"> <article className="panel-card">
<div className="panel-title"><Activity size={20} /> Details</div>
<dl className="status-grid"> <dl className="status-grid">
<dt>Companion URL</dt><dd>{settings.baseUrl}</dd> <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>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>Workspace</dt><dd>{health?.config.workspaceRoot ?? 'Unknown'}</dd>
<dt>Uptime</dt><dd>{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}</dd>
</dl> </dl>
</article> </article>
</section> </section>
@@ -1,5 +1,5 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { SendHorizontal } from 'lucide-react'; import { Bot, CheckCircle2, SendHorizontal, Sparkles } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js'; import { useCompanion } from '../../api/CompanionContext.js';
type ChatMessage = { type ChatMessage = {
@@ -42,17 +42,32 @@ export function ChatScreen() {
return ( return (
<section className="screen chat-screen"> <section className="screen chat-screen">
<div className="hero-card"> <article className="quest-card quest-card--hero">
<p className="eyebrow">Chat</p> <div className="quest-orb"><Bot size={34} strokeWidth={3} /></div>
<h2>Send Hermes a task from your phone.</h2> <div>
<p>Prompts are sent to the companion server, which invokes the local Hermes CLI when available.</p> <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>
<div className="message-list" aria-live="polite"> <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) => ( {messages.map((message, index) => (
<article className={`message message--${message.role}`} key={`${message.role}-${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> <p>{message.content}</p>
</article> </article>
))} ))}
@@ -61,12 +76,10 @@ export function ChatScreen() {
{error ? <p className="error-card">{error}</p> : null} {error ? <p className="error-card">{error}</p> : null}
<form className="composer" onSubmit={submitPrompt}> <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} /> <textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="What should Hermes do?" rows={4} value={prompt} />
<div className="composer__actions"> <button className="primary-button composer__send" disabled={busy || !prompt.trim()} type="submit">
<button className="send-button" disabled={busy || !prompt.trim()} type="submit"> {busy ? 'Working…' : 'Start quest'} <SendHorizontal size={20} />
{busy ? 'Sending…' : 'Send'} <SendHorizontal size={18} />
</button> </button>
</div>
</form> </form>
</section> </section>
); );
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FileText, Folder, RefreshCw, Save, StepBack } from 'lucide-react';
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared'; import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js'; import { useCompanion } from '../../api/CompanionContext.js';
@@ -76,26 +77,31 @@ export function FilesScreen() {
return ( return (
<section className="screen stack-screen"> <section className="screen stack-screen">
<div className="screen-heading"> <article className="quest-card quest-card--compact">
<div> <div>
<p className="eyebrow">Workspace</p> <p className="eyebrow">Backpack</p>
<h2>Files</h2> <h2>Workspace files</h2>
</div> <p>Tap folders to go deeper. Files open into a mobile editor.</p>
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
</div> </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"> <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> <span>{path}</span>
</div> </div>
<div className="file-list"> <div className="file-list">
{entries.map((entry) => ( {entries.map((entry) => {
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button"> const Icon = entry.type === 'directory' ? Folder : FileText;
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span> 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> <small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
</button> </button>
))} );
})}
</div> </div>
</article> </article>
@@ -106,7 +112,7 @@ export function FilesScreen() {
<p className="eyebrow">Editing</p> <p className="eyebrow">Editing</p>
<h3>{activeFile.path}</h3> <h3>{activeFile.path}</h3>
</div> </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> </div>
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} /> <textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
</article> </article>
@@ -1,4 +1,5 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { KeyRound, Link, PlugZap, Save } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js'; import { useCompanion } from '../../api/CompanionContext.js';
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js'; import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
@@ -29,18 +30,24 @@ export function SettingsScreen() {
} }
return ( return (
<section className="screen stack-screen"> <section className="screen stack-screen settings-screen">
<h2>Settings</h2> <article className="quest-card quest-card--compact quest-card--green">
<form className="settings-card" onSubmit={save}> <div className="quest-orb quest-orb--green"><PlugZap size={32} strokeWidth={3} /></div>
<label htmlFor="server-url">Companion server URL</label> <div>
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} /> <p className="eyebrow">Nest setup</p>
<label htmlFor="access-key">Access key</label> <h2>Pair your companion</h2>
<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>
</div>
<p>{status}</p> <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>
</form> </form>
</section> </section>
); );
@@ -1,4 +1,5 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { Play, TerminalSquare, Zap } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js'; import { useCompanion } from '../../api/CompanionContext.js';
export function TerminalScreen() { export function TerminalScreen() {
@@ -30,19 +31,22 @@ export function TerminalScreen() {
} }
return ( return (
<section className="screen stack-screen"> <section className="screen stack-screen terminal-screen">
<div className="screen-heading"> <article className="quest-card quest-card--terminal">
<div className="quest-orb quest-orb--blue"><TerminalSquare size={34} strokeWidth={3} /></div>
<div> <div>
<p className="eyebrow">Remote shell</p> <p className="eyebrow">Power move</p>
<h2>Terminal</h2> <h2>Run one safe command.</h2>
</div> <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}> <form className="panel-card terminal-form" onSubmit={runCommand}>
<label htmlFor="cwd">Working directory</label> <label htmlFor="cwd">Working directory</label>
<input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} /> <input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} />
<label htmlFor="command">Command</label> <label htmlFor="command">Command</label>
<textarea id="command" onChange={(event) => setCommand(event.target.value)} rows={4} value={command} /> <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> </form>
<pre className="terminal-output">{output || 'Command output appears here.'}</pre> <pre className="terminal-output">{output || 'Command output appears here.'}</pre>
</section> </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 { :root {
color-scheme: dark; color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Nunito, ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
background: #11131f; background: oklch(0.94 0.038 132);
color: #fff8ec; color: oklch(0.25 0.04 150);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -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 { body {
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: background:
radial-gradient(circle at 20% 10%, rgba(255, 184, 77, 0.24), transparent 34rem), radial-gradient(circle at 50% -10%, oklch(0.96 0.09 130), transparent 20rem),
radial-gradient(circle at 80% 0%, rgba(102, 92, 255, 0.24), transparent 28rem), linear-gradient(180deg, oklch(0.95 0.045 132), oklch(0.91 0.05 142));
#11131f; overscroll-behavior-y: none;
} }
button, input, textarea { font: inherit; } button, input, textarea { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; touch-action: manipulation; }
button:disabled { cursor: not-allowed; opacity: 0.55; } 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 { .app-shell {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto auto 1fr auto;
width: min(100%, 34rem); width: min(100%, 30rem);
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom)); padding: max(0.75rem, env(safe-area-inset-top)) 0.9rem max(0.7rem, env(safe-area-inset-bottom));
}
.app-header, .screen-heading, .composer__actions, .path-bar {
display: flex;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.app-header, .screen-heading, .path-bar { justify-content: space-between; } .app-header {
.app-header { align-items: flex-start; padding: 0.75rem 0 1rem; } display: grid;
grid-template-columns: auto 1fr auto;
.app-header h1, .screen h2, .hero-card h2, .editor-card h3 { margin: 0; line-height: 1.05; } align-items: center;
.app-header h1 { max-width: 12rem; font-size: clamp(1.5rem, 7vw, 2.35rem); } gap: 0.7rem;
.screen h2 { font-size: 2rem; } padding-top: 0.15rem;
.editor-card h3 { max-width: 14rem; overflow-wrap: anywhere; } }
.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 { .eyebrow {
margin: 0 0 0.4rem; margin: 0 0 0.05rem;
color: #ffca6a; color: var(--muted);
font-size: 0.75rem; font-size: 0.72rem;
font-weight: 800; font-weight: 1000;
letter-spacing: 0.13em; letter-spacing: 0.085em;
text-transform: uppercase; 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 { .top-stats { display: flex; flex-direction: column; gap: 0.28rem; }
display: inline-flex; .stat-chip {
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 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 2.7rem; gap: 0.18rem;
border: 0; min-width: 3.15rem;
min-height: 1.55rem;
border: 2px solid currentColor;
border-radius: 999px; 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-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; } .app-main {
.small-button, .path-bar button { padding: 0 0.9rem; background: rgba(255, 255, 255, 0.1); color: #fff8ec; } 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; } .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 { padding: 0.85rem; }
.message span, .status-grid dt, .terminal-form label { color: #89e5ff; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; } .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.45; } .message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.35; font-weight: 800; color: var(--ink); }
.message--user { background: rgba(255, 202, 106, 0.14); } .message--user { margin-left: 1.5rem; background: oklch(0.95 0.07 145); border-color: oklch(0.8 0.13 145); }
.error-card { color: #ffd0d0; } .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 { .file-row {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%; width: 100%;
border: 0; min-height: 3.85rem;
border-radius: 1rem; border: 2px solid var(--line);
padding: 0.85rem; border-radius: 1.05rem;
background: rgba(255, 255, 255, 0.07); padding: 0.7rem;
color: #fff8ec; background: var(--surface);
color: var(--ink);
text-align: left; 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 { display: grid; grid-template-columns: 7rem 1fr; gap: 0.7rem; margin: 0; }
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: rgba(255, 248, 236, 0.82); } .status-grid dt { color: var(--muted); font-size: 0.78rem; font-weight: 1000; text-transform: uppercase; }
.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 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 { .bottom-nav {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 0.25rem; gap: 0.28rem;
border: 1px solid rgba(255, 255, 255, 0.12); border: 2px solid var(--line);
border-radius: 1.35rem; border-radius: 1.35rem;
padding: 0.35rem; padding: 0.38rem;
background: rgba(16, 18, 30, 0.9); background: var(--paper);
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.35); box-shadow: 0 0.42rem 0 var(--shadow);
backdrop-filter: blur(18px);
} }
.bottom-nav__item { .bottom-nav__item {
display: grid; display: grid;
place-items: center; place-items: center;
gap: 0.2rem; gap: 0.15rem;
min-height: 3.4rem; min-height: 3.75rem;
border: 0; border: 0;
border-radius: 1rem; border-radius: 1rem;
background: transparent; background: transparent;
color: rgba(255, 248, 236, 0.58); color: oklch(0.58 0.035 150);
font-size: 0.67rem; font-size: 0.64rem;
font-weight: 800; 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": { "dependencies": {
"@capacitor/android": "^7.4.0", "@capacitor/android": "^7.4.0",
"@capacitor/core": "^7.4.0", "@capacitor/core": "^7.4.0",
"@capacitor/haptics": "^7.0.2",
"@hermes-mobile/shared": "file:../../packages/shared", "@hermes-mobile/shared": "file:../../packages/shared",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -410,6 +411,15 @@
"tslib": "^2.1.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",