feat: redesign mobile app with tactile UI
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user