Skip to content

Commit 82a4bd7

Browse files
author
Hana
committed
feat(app): NellFace Phase 3 — five panels + tab switcher
The shoji-styled left column from the mockups (mock-ups/app-interface/ nell_face_example_1..5.png), all reading the same /persona/state poll already wired in Phase 2. ## What landed Brain side: /persona/state gains a connection block: { provider, model, last_heartbeat_at } provider read from PersonaConfig.provider, model resolved via a v1 default-per-provider table (claude-cli → sonnet, ollama → the qwen2.5-abliterated default, fake → fake), last_heartbeat_at read from heartbeat_state.json. Fail-soft: missing files → null. App side (app/src/components/): - ui.tsx shared primitives — Bar (with progress fill + label), SectionLabel, Divider, Toggle, PanelShell. - panels/ InnerWeatherPanel.tsx top-N emotions sorted desc + body energy/ temp summary (mockup #1). BodyPanel.tsx full body block — energy/temp/exhaustion + body_emotions filtered >0.4 + session/ contact metadata (mockup #2). InteriorPanel.tsx dream/research/heartbeat/reflex paragraphs (mockup #3). Absent sections render nothing — silence is meaningful. SoulPanel.tsx one crystallization quote with love_type tag, resonance, date, why_it_matters. ConnectionPanel.tsx bridge mode + provider/model/heartbeat, integrations toggles (Obsidian/IPC stubbed disabled), window settings (always-on-top + reduced-motion live-toggleable). - LeftPanel.tsx container with icon-column tab switcher, renders the active panel; matches the mockup's small icon stack near the avatar. - styles.css user-controlled reduced-motion via data-reduced-motion="true" on <html>. - App.tsx wires LeftPanel + always-on-top + reduced-motion state. ## Phase 4-5 still ahead - Phase 4: install wizard as a separate Tauri window, ported from mock-ups/wizard-interface/Nell Wizard.html. Bridge auto-spawn from the app side (so users don't have to run nell supervisor manually). Persona selection persistence. - Phase 5: refine emotion-vector → expression mapping; per-persona face catalogue overrides; expression variant rotation on idle. Tests: 1386 -> 1388 (+2 — connection block populated + missing-file safe). All builds green: tsc, vite, cargo.
1 parent 34b8127 commit 82a4bd7

12 files changed

Lines changed: 827 additions & 25 deletions

File tree

app/src/App.tsx

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,23 @@ import { useEffect, useState } from "react";
22
import { fetchPersonaState, type PersonaState } from "./bridge";
33
import { NellAvatar } from "./components/NellAvatar";
44
import { ChatPanel } from "./components/ChatPanel";
5+
import { LeftPanel } from "./components/LeftPanel";
56

67
const STATE_POLL_MS = 5000;
78

89
/**
9-
* NellFace shell — three columns: TBD-left-panel / NellAvatar / ChatPanel.
10+
* NellFace shell — three columns: LeftPanel / NellAvatar / ChatPanel.
1011
*
11-
* Phase 2 (this commit) ships avatar + chat working end-to-end against
12-
* the bridge. Phase 3 fills in the left panels (Inner Weather, Body,
13-
* Recent Interior, Soul, Connection) reading the same persona state.
12+
* Left panel rotates through Inner Weather / Body / Recent Interior /
13+
* Soul / Connection via the icon column. All five read the same
14+
* /persona/state poll.
1415
*/
1516
export default function App() {
1617
const [state, setState] = useState<PersonaState | null>(null);
1718
const [stateError, setStateError] = useState<string | null>(null);
19+
const [alwaysOnTop, setAlwaysOnTop] = useState(false);
20+
const [reducedMotion, setReducedMotion] = useState(false);
1821

19-
// Poll /persona/state every 5s. Switching to SSE/WS later when the
20-
// backend exposes a streaming surface.
2122
useEffect(() => {
2223
let cancelled = false;
2324
async function tick() {
@@ -39,39 +40,50 @@ export default function App() {
3940
};
4041
}, []);
4142

43+
// User-controlled reduced motion — sets a data attribute on <html>
44+
// that styles.css picks up. Phase 4 will sync this with PersonaConfig.
45+
useEffect(() => {
46+
document.documentElement.dataset.reducedMotion = reducedMotion ? "true" : "false";
47+
}, [reducedMotion]);
48+
4249
return (
4350
<div
4451
style={{
4552
display: "flex",
4653
alignItems: "center",
4754
justifyContent: "center",
48-
gap: 36,
55+
gap: 28,
4956
width: "100vw",
5057
height: "100vh",
5158
}}
5259
>
53-
{/* Left column — placeholder until Phase 3 panels land */}
54-
<div
55-
style={{
56-
width: 220,
57-
minHeight: 280,
58-
opacity: 0.3,
59-
fontSize: 10,
60-
color: "var(--mauve)",
61-
fontFamily: "var(--font-disp)",
62-
letterSpacing: "0.08em",
63-
textTransform: "uppercase",
64-
padding: 8,
65-
}}
66-
>
67-
{stateError ? `state: ${stateError}` : "panels — phase 3"}
68-
</div>
60+
<LeftPanel
61+
state={state}
62+
alwaysOnTop={alwaysOnTop}
63+
reducedMotion={reducedMotion}
64+
onAlwaysOnTopChange={setAlwaysOnTop}
65+
onReducedMotionChange={setReducedMotion}
66+
/>
6967

70-
{/* Avatar */}
7168
<NellAvatar state={state} />
7269

73-
{/* Chat */}
7470
<ChatPanel />
71+
72+
{stateError && (
73+
<div
74+
style={{
75+
position: "absolute",
76+
bottom: 8,
77+
left: 8,
78+
fontSize: 10,
79+
color: "var(--crimson)",
80+
fontFamily: "var(--font-disp)",
81+
opacity: 0.7,
82+
}}
83+
>
84+
state: {stateError}
85+
</div>
86+
)}
7587
</div>
7688
);
7789
}

app/src/bridge.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export interface PersonaState {
5757
reflex: string | null;
5858
};
5959
soul_highlight: SoulHighlight | null;
60+
connection: {
61+
provider: string | null;
62+
model: string | null;
63+
last_heartbeat_at: string | null;
64+
};
6065
mode: "live" | "bridge_down" | "provider_down" | "offline";
6166
}
6267

app/src/components/LeftPanel.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useState } from "react";
2+
import type { PersonaState } from "../bridge";
3+
import { InnerWeatherPanel } from "./panels/InnerWeatherPanel";
4+
import { BodyPanel } from "./panels/BodyPanel";
5+
import { InteriorPanel } from "./panels/InteriorPanel";
6+
import { SoulPanel } from "./panels/SoulPanel";
7+
import { ConnectionPanel } from "./panels/ConnectionPanel";
8+
9+
type Tab = "weather" | "body" | "interior" | "soul" | "connection";
10+
11+
interface Props {
12+
state: PersonaState | null;
13+
alwaysOnTop: boolean;
14+
reducedMotion: boolean;
15+
onAlwaysOnTopChange: (next: boolean) => void;
16+
onReducedMotionChange: (next: boolean) => void;
17+
}
18+
19+
const TABS: Array<{ id: Tab; label: string; icon: string }> = [
20+
{ id: "weather", label: "Inner Weather", icon: "◐" },
21+
{ id: "body", label: "Body", icon: "○" },
22+
{ id: "interior", label: "Recent Interior", icon: "✦" },
23+
{ id: "soul", label: "Soul", icon: "❀" },
24+
{ id: "connection", label: "Connection", icon: "≡" },
25+
];
26+
27+
export function LeftPanel({
28+
state,
29+
alwaysOnTop,
30+
reducedMotion,
31+
onAlwaysOnTopChange,
32+
onReducedMotionChange,
33+
}: Props) {
34+
const [tab, setTab] = useState<Tab>("weather");
35+
return (
36+
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
37+
{renderPanel(tab, state, {
38+
alwaysOnTop,
39+
reducedMotion,
40+
onAlwaysOnTopChange,
41+
onReducedMotionChange,
42+
})}
43+
<div
44+
style={{
45+
display: "flex",
46+
flexDirection: "column",
47+
gap: 6,
48+
paddingTop: 4,
49+
}}
50+
>
51+
{TABS.map((t) => (
52+
<button
53+
key={t.id}
54+
onClick={() => setTab(t.id)}
55+
title={t.label}
56+
aria-label={t.label}
57+
style={{
58+
width: 26,
59+
height: 26,
60+
borderRadius: 6,
61+
background: tab === t.id ? "var(--accent)" : "rgba(234,222,218,0.12)",
62+
color: tab === t.id ? "var(--linen)" : "var(--mauve)",
63+
border: tab === t.id ? "1px solid var(--accent)" : "1px solid var(--border-dk)",
64+
fontSize: 13,
65+
transition: "all 0.18s ease",
66+
display: "flex",
67+
alignItems: "center",
68+
justifyContent: "center",
69+
}}
70+
>
71+
{t.icon}
72+
</button>
73+
))}
74+
</div>
75+
</div>
76+
);
77+
}
78+
79+
interface PanelOpts {
80+
alwaysOnTop: boolean;
81+
reducedMotion: boolean;
82+
onAlwaysOnTopChange: (next: boolean) => void;
83+
onReducedMotionChange: (next: boolean) => void;
84+
}
85+
86+
function renderPanel(tab: Tab, state: PersonaState | null, opts: PanelOpts) {
87+
switch (tab) {
88+
case "weather":
89+
return <InnerWeatherPanel state={state} />;
90+
case "body":
91+
return <BodyPanel state={state} />;
92+
case "interior":
93+
return <InteriorPanel state={state} />;
94+
case "soul":
95+
return <SoulPanel state={state} />;
96+
case "connection":
97+
return (
98+
<ConnectionPanel
99+
state={state}
100+
alwaysOnTop={opts.alwaysOnTop}
101+
reducedMotion={opts.reducedMotion}
102+
onAlwaysOnTopChange={opts.onAlwaysOnTopChange}
103+
onReducedMotionChange={opts.onReducedMotionChange}
104+
/>
105+
);
106+
}
107+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { PersonaState } from "../../bridge";
2+
import { Bar, Divider, PanelShell, SectionLabel } from "../ui";
3+
4+
interface Props {
5+
state: PersonaState | null;
6+
}
7+
8+
/**
9+
* Body — the full body block: energy/temp/exhaustion + body emotions
10+
* (arousal/desire/climax/touch_hunger/comfort_seeking/rest_need) +
11+
* session/contact metadata. Matches mockup nell_face_example_2.png.
12+
*/
13+
export function BodyPanel({ state }: Props) {
14+
const body = state?.body;
15+
return (
16+
<PanelShell>
17+
<SectionLabel>Body</SectionLabel>
18+
{body ? (
19+
<>
20+
<Bar label="Energy" value={body.energy} max={10} formatValue={(v) => `${v}/10`} />
21+
<Bar label="Temp" value={body.temperature} max={9} formatValue={(v) => `${v}/9`} />
22+
<Bar
23+
label="Exhaust"
24+
value={body.exhaustion}
25+
max={10}
26+
formatValue={(v) => `${v}/10`}
27+
/>
28+
<Divider />
29+
<SectionLabel>Body Emotions</SectionLabel>
30+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
31+
{Object.entries(body.body_emotions)
32+
.filter(([, v]) => v > 0.4)
33+
.sort(([, a], [, b]) => b - a)
34+
.map(([name, value]) => (
35+
<Bar key={name} label={name} value={value} max={10} />
36+
))}
37+
{Object.values(body.body_emotions).every((v) => v <= 0.4) && (
38+
<div style={{ fontSize: 11, color: "var(--text-mute)", fontStyle: "italic" }}>
39+
quiet
40+
</div>
41+
)}
42+
</div>
43+
<Divider />
44+
<KeyValue label="Session" value={`${body.session_hours.toFixed(1)}h`} />
45+
<KeyValue
46+
label="Contact"
47+
value={
48+
body.days_since_contact >= 1
49+
? `${body.days_since_contact.toFixed(1)}d`
50+
: `${(body.days_since_contact * 24).toFixed(1)}h`
51+
}
52+
/>
53+
</>
54+
) : (
55+
<div style={{ fontSize: 11, color: "var(--text-mute)", fontStyle: "italic" }}>
56+
body offline
57+
</div>
58+
)}
59+
</PanelShell>
60+
);
61+
}
62+
63+
function KeyValue({ label, value }: { label: string; value: string }) {
64+
return (
65+
<div
66+
style={{
67+
display: "flex",
68+
justifyContent: "space-between",
69+
alignItems: "baseline",
70+
fontSize: 11.5,
71+
marginBottom: 3,
72+
}}
73+
>
74+
<span style={{ color: "var(--text-mid)" }}>{label}</span>
75+
<span style={{ color: "var(--text)", fontFamily: "var(--font-disp)" }}>{value}</span>
76+
</div>
77+
);
78+
}

0 commit comments

Comments
 (0)