drexler 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/ui/App.tsx CHANGED
@@ -326,12 +326,7 @@ export function App({
326
326
  const petActivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
327
327
  const petDecayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
328
328
 
329
- const petEnv = useMemo((): Environment => {
330
- const h = new Date().getHours();
331
- if (h >= 9 && h < 18) return "office";
332
- if (h >= 6 && h < 23) return "home";
333
- return "outdoors";
334
- }, []);
329
+ const petEnv: Environment = "office";
335
330
 
336
331
  const PET_MESSAGES = useMemo(() => ({
337
332
  feed: [
@@ -1157,7 +1152,7 @@ export function App({
1157
1152
  const isBusy =
1158
1153
  requestInFlight || streaming !== null || thinking !== null || synergyEvent !== null;
1159
1154
  const headerStatus = isBusy ? "streaming" : deskStatus;
1160
- const renderDealDeskHeader = (width: number) => (
1155
+ const renderDealDeskHeader = (width: number, marginBottom = 1) => (
1161
1156
  <DealDeskHeader
1162
1157
  mood={mood}
1163
1158
  messageCount={msgCount}
@@ -1165,7 +1160,7 @@ export function App({
1165
1160
  compact={isCompact}
1166
1161
  notice={!introActive ? deskNotice ?? undefined : undefined}
1167
1162
  maxWidth={Math.max(1, width)}
1168
- marginBottom={introActive ? 0 : 1}
1163
+ marginBottom={marginBottom}
1169
1164
  />
1170
1165
  );
1171
1166
  const dealDeskHeader = renderDealDeskHeader(chromeWidth);
@@ -1198,7 +1193,7 @@ export function App({
1198
1193
  bar={introActive ? intro.bar : undefined}
1199
1194
  barColor={introActive ? introBarColor : undefined}
1200
1195
  mascotStatus={introActive ? intro.status : undefined}
1201
- dealDesk={renderDealDeskHeader}
1196
+ dealDesk={(width) => renderDealDeskHeader(width, 0)}
1202
1197
  />
1203
1198
  </Box>
1204
1199
  ) : showFallbackPetPanel ? (
@@ -171,7 +171,7 @@ const RIGHT_COLUMN_PAD_RIGHT = 1;
171
171
  const LEFT_PANEL_MIN_COPY = 24;
172
172
  const PET_STATS_MIN_WIDTH = 24;
173
173
  const PET_STATS_MAX_WIDTH = 58;
174
- const PET_SPLIT_DIVIDER_HEIGHT = 12;
174
+ const PET_SPLIT_DIVIDER_HEIGHT = 15;
175
175
  const PET_SPLIT_DIVIDER_ROWS: number[] = Array.from(
176
176
  { length: PET_SPLIT_DIVIDER_HEIGHT },
177
177
  (_, i) => i,
@@ -846,13 +846,14 @@ function PetSceneReadout({
846
846
  return (
847
847
  <Box flexDirection="column" width={safeWidth} alignItems="center">
848
848
  <Text bold color={t.primaryLight}>
849
- {fitDisplayText(`Drexler Pet Desk [${env}]`, safeWidth)}
849
+ {fitDisplayText("Drexler Pet Desk [office]", safeWidth)}
850
850
  </Text>
851
851
  <PetScene
852
852
  stats={stats}
853
853
  activity={activity}
854
854
  env={env}
855
855
  isPaused={isPaused}
856
+ width={safeWidth}
856
857
  />
857
858
  </Box>
858
859
  );
@@ -938,12 +939,10 @@ function PetDashboardStatBar({
938
939
  function PetStatsReadout({
939
940
  stats,
940
941
  activity,
941
- env,
942
942
  width,
943
943
  }: {
944
944
  stats: PetStats;
945
945
  activity: PetActivity;
946
- env: Environment;
947
946
  width: number;
948
947
  }) {
949
948
  const t = useTheme();
@@ -1007,7 +1006,7 @@ function PetStatsReadout({
1007
1006
  color={t.primaryLight}
1008
1007
  />
1009
1008
  <PetStatsBodyLine
1010
- text={`activity ${activityLabel} · env ${env}`}
1009
+ text={`activity ${activityLabel} · office`}
1011
1010
  width={panelWidth}
1012
1011
  color={t.dim}
1013
1012
  />
@@ -1130,7 +1129,6 @@ function PetDashboard({
1130
1129
  <PetStatsReadout
1131
1130
  stats={stats}
1132
1131
  activity={activity}
1133
- env={env}
1134
1132
  width={Math.min(PET_STATS_MAX_WIDTH, layout.dealDesk.width)}
1135
1133
  />
1136
1134
  </Box>
@@ -1141,7 +1139,6 @@ function PetDashboard({
1141
1139
  <PetStatsReadout
1142
1140
  stats={stats}
1143
1141
  activity={activity}
1144
- env={env}
1145
1142
  width={Math.max(COMPACT_PET_PANEL_MIN_WIDTH, layout.tips.width)}
1146
1143
  />
1147
1144
  </Box>
@@ -1,7 +1,13 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
3
  import { getPetMood, type PetActivity, type PetStats } from "../pet/petState.ts";
4
- import { fitDisplayText } from "./graphemes.ts";
4
+ import {
5
+ BRIEFCASE_FINAL,
6
+ MASCOT_WIDTH,
7
+ renderMascotLines,
8
+ type MascotState,
9
+ } from "./MascotFrame.tsx";
10
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
5
11
  import { useTheme } from "./ThemeContext.tsx";
6
12
  import { type Theme } from "./themes.ts";
7
13
 
@@ -12,28 +18,17 @@ export type Environment = "office" | "home" | "outdoors";
12
18
 
13
19
  const PANEL_BORDER_COLUMNS = 2;
14
20
  const PANEL_PADDING_COLUMNS = 2;
15
- const CONTENT = 32;
16
- const SPRITE_W = 8;
17
-
18
- const R_SKY = 0;
19
- const R_BGA = 1;
20
- const R_BGB = 2;
21
- const R_DECO = 3;
22
- const R_SP0 = 4; // sprite occupies rows 4–9
23
- const R_FLOOR = 10;
24
- const SCENE_ROWS = 11;
25
- export const PET_SCENE_WIDTH = CONTENT;
26
-
27
- // Fixed sprite X — no left/right walking
28
- const SPRITE_X: Record<PetActivity, number> = {
29
- idle: 12, eating: 3, playing: 12, working: 18,
30
- sleeping: 12, praised: 12, vibing: 12,
31
- };
21
+ const SCENE_ROWS = 14;
22
+ const R_WALL = 0;
23
+ const R_WINDOW_TOP = 1;
24
+ const R_WINDOW_BOTTOM = 3;
25
+ const R_ACTIVITY = 4;
26
+ const R_MASCOT_START = 5;
27
+ const R_DESK_SURFACE = R_MASCOT_START + BRIEFCASE_FINAL.length;
28
+ const R_DESK_FRONT = R_DESK_SURFACE + 1;
29
+
30
+ export const PET_SCENE_WIDTH = 52;
32
31
 
33
- // ─── helpers ─────────────────────────────────────────────────────────────────
34
- // All scene glyphs (box-drawing + ASCII) are BMP single-units, so string
35
- // splicing is safe and avoids the per-call char-array allocation `[...base]`
36
- // would do on the hot frame loop.
37
32
  function place(base: string, text: string, x: number): string {
38
33
  if (x < 0 || x >= base.length) return base;
39
34
  const end = Math.min(base.length, x + text.length);
@@ -41,321 +36,284 @@ function place(base: string, text: string, x: number): string {
41
36
  return base.slice(0, x) + fit + base.slice(end);
42
37
  }
43
38
 
44
- // Hoisted constant: floor pattern for home doesn't depend on frame.
45
- const HOME_CARPET = Array.from({ length: CONTENT }, (_, i) =>
46
- i % 4 === 0 ? "░" : i % 4 === 2 ? "▒" : "─",
47
- ).join("");
48
-
49
- // ─── sprite (8 wide × 6 tall) ────────────────────────────────────────────────
50
- function buildSprite(activity: PetActivity, frame: number): string[] {
51
- const smash = activity === "working" && frame % 32 >= 26;
52
-
53
- let eyes: string;
54
- if (smash) eyes = "║ X X ║";
55
- else switch (activity) {
56
- case "sleeping": eyes = "║ - - ║"; break;
57
- case "working": eyes = "║ / \\ ║"; break;
58
- case "playing": eyes = frame % 6 < 3 ? "║ * * ║" : "║ ◆ ◆ ║"; break;
59
- case "vibing": eyes = frame % 4 < 2 ? "║ ~ ~ ║" : "║ ◆ ◆ ║"; break;
60
- case "eating": eyes = frame % 4 < 2 ? "║ o o ║" : "║ ◆ ◆ ║"; break;
61
- case "praised": eyes = "║ ◆ ◆ ║"; break;
62
- default: eyes = frame % 22 === 0 ? "║ - - ║" : "║ ◆ ◆ ║"; break;
63
- }
64
-
65
- // Flex top during playing peak
66
- const top = (activity === "playing" && frame % 6 >= 3) ? "╠═╩══╩═╣" : "╔═╩══╩═╗";
67
-
68
- let lock: string;
69
- if (smash) lock = "║ ║**║ ║";
70
- else switch (activity) {
71
- case "eating": lock = frame % 2 === 0 ? "║ ║>>║ ║" : "║ ║<<║ ║"; break;
72
- case "sleeping": lock = "║ ║"; break;
73
- case "praised": lock = frame % 3 === 0 ? "║ ║**║ ║" : "║ ║$$║ ║"; break;
74
- case "vibing": lock = frame % 4 < 2 ? "║ ║~~║ ║" : "║ ║$$║ ║"; break;
75
- case "playing": lock = frame % 4 < 2 ? "║ ║!!║ ║" : "║ ║$$║ ║"; break;
76
- default: lock = "║ ║$$║ ║"; break;
77
- }
78
-
79
- return [
80
- " ╔══╗ ",
81
- top,
82
- eyes,
83
- activity === "sleeping" ? "║ ║" : "║ ╔══╗ ║",
84
- lock,
85
- "╚══════╝",
86
- ];
39
+ function blankRow(width: number): string {
40
+ return " ".repeat(width);
87
41
  }
88
42
 
89
- // ─── environments (simplified) ───────────────────────────────────────────────
90
-
91
- function drawOffice(rows: string[], frame: number, stats: PetStats): void {
92
- const hour = new Date().getHours();
93
- const isDay = hour >= 6 && hour < 20;
94
-
95
- rows[R_SKY] = "─".repeat(CONTENT);
96
-
97
- // Window left (6 wide) + deals board right (12 wide at x=20)
98
- const sky = isDay
99
- ? (frame % 10 < 5 ? "─^──" : "──^─")
100
- : (frame % 10 < 5 ? "─*──" : "──*─");
101
- rows[R_BGA] = place(rows[R_BGA], `╭${sky}╮`, 0);
102
- rows[R_BGB] = place(rows[R_BGB], "╰────╯", 0);
43
+ function padDisplayText(input: string, width: number): string {
44
+ const safeWidth = Math.max(1, width);
45
+ const fitted = fitDisplayText(input, safeWidth);
46
+ return `${fitted}${" ".repeat(Math.max(0, safeWidth - displayWidth(fitted)))}`;
47
+ }
103
48
 
104
- const dl = String(Math.round(stats.deals)).padStart(3);
105
- rows[R_BGA] = place(rows[R_BGA], "╔═DEALS════╗", 20);
106
- rows[R_BGB] = place(rows[R_BGB], `║ DL:${dl}% ║`, 20);
49
+ function overlayFitted(row: string, text: string, x: number, width: number): string {
50
+ return place(row, padDisplayText(text, width), x);
51
+ }
107
52
 
108
- // Desk (rows 7–9, right: 10 wide at x=22)
109
- rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "╔════════╗", 22);
110
- rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "║ ║", 22);
111
- rows[R_SP0 + 5] = place(rows[R_SP0 + 5], "╚════════╝", 22);
53
+ function centerText(row: string, text: string): string {
54
+ const safeText = fitDisplayText(text, row.length);
55
+ const x = Math.max(0, Math.floor((row.length - displayWidth(safeText)) / 2));
56
+ return place(row, safeText, x);
57
+ }
112
58
 
113
- // Floor with coffee
114
- rows[R_FLOOR] = "─".repeat(CONTENT);
115
- const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
116
- rows[R_FLOOR] = place(rows[R_FLOOR], cup, CONTENT - 5);
59
+ function sceneBoxTop(title: string, width: number): string {
60
+ const safeWidth = Math.max(4, width);
61
+ const label = ` ${fitDisplayText(title, Math.max(1, safeWidth - 4))} `;
62
+ const ruleWidth = Math.max(0, safeWidth - 2 - displayWidth(label));
63
+ return `╭${label}${"─".repeat(ruleWidth)}╮`;
117
64
  }
118
65
 
119
- function drawHome(rows: string[], frame: number, stats: PetStats): void {
120
- const hour = new Date().getHours();
121
- const isDay = hour >= 6 && hour < 20;
66
+ function sceneBoxBody(text: string, width: number): string {
67
+ const safeWidth = Math.max(4, width);
68
+ return `│${padDisplayText(text, safeWidth - 2)}│`;
69
+ }
122
70
 
123
- rows[R_SKY] = "─".repeat(CONTENT);
71
+ function sceneBoxBottom(width: number): string {
72
+ const safeWidth = Math.max(4, width);
73
+ return `╰${"─".repeat(safeWidth - 2)}╯`;
74
+ }
124
75
 
125
- // Window left (8 wide)
126
- rows[R_BGA] = place(rows[R_BGA], "╭──────╮", 0);
127
- const yard = isDay
128
- ? (frame % 8 < 4 ? "│ ~~~~ │" : "│~~~~~ │")
129
- : (frame % 8 < 4 ? "│ ** │" : "│ * * │");
130
- rows[R_BGB] = place(rows[R_BGB], yard, 0);
76
+ function placeSceneBox(
77
+ rows: string[],
78
+ row: number,
79
+ x: number,
80
+ width: number,
81
+ title: string,
82
+ body: string,
83
+ ): void {
84
+ rows[row] = place(rows[row] ?? "", sceneBoxTop(title, width), x);
85
+ rows[row + 1] = place(rows[row + 1] ?? "", sceneBoxBody(body, width), x);
86
+ rows[row + 2] = place(rows[row + 2] ?? "", sceneBoxBottom(width), x);
87
+ }
131
88
 
132
- // TV right (10 wide at x=22)
133
- const tvContent = ["[~~~]", "[~~ ]", "[ ~~]", "[~~~]"][frame % 4] ?? "[~~~]";
134
- rows[R_BGA] = place(rows[R_BGA], "╔══TV════╗", 22);
135
- rows[R_BGB] = place(rows[R_BGB], `║${tvContent} ║`, 22);
89
+ function cupForEnergy(energy: number): string {
90
+ if (energy > 60) return "coffee [c~]";
91
+ if (energy > 30) return "coffee [c-]";
92
+ return "coffee [c_]";
93
+ }
136
94
 
137
- // Couch (rows 6–8, right: 10 wide at x=22)
138
- rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "╭────────╮", 22);
139
- rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "│ ≈≈≈≈≈≈ │", 22);
140
- rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "╰════════╯", 22);
95
+ function progressTicker(frame: number): string {
96
+ const dots = ".............";
97
+ const idx = frame % dots.length;
98
+ return `[${dots.slice(0, idx)}o${dots.slice(idx + 1)}]`;
99
+ }
141
100
 
142
- // Carpet floor (precomputed; placement only varies with stats)
143
- rows[R_FLOOR] = HOME_CARPET;
144
- const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
145
- rows[R_FLOOR] = place(rows[R_FLOOR], cup, 9);
101
+ function buildActivityLine(activity: PetActivity, frame: number): string {
102
+ switch (activity) {
103
+ case "eating":
104
+ return frame % 4 < 2
105
+ ? "deal snack memo routed"
106
+ : "lunch marked to market";
107
+ case "playing":
108
+ return frame % 6 < 3
109
+ ? "boardroom putting drill"
110
+ : "team morale accretive";
111
+ case "working":
112
+ return `term sheet live ${progressTicker(frame)}`;
113
+ case "sleeping":
114
+ return frame % 4 < 2
115
+ ? "lights dim · conference-line nap"
116
+ : "zzz · calendar defended";
117
+ case "praised":
118
+ return frame % 2 === 0
119
+ ? "bonus memo approved * * *"
120
+ : "* * * board applause logged";
121
+ case "vibing":
122
+ return ["lo-fi deal room ~ ~ ~", "~ ~ valuation waves ~ ~"][frame % 2]!;
123
+ default:
124
+ return "calendar clear · office quiet";
125
+ }
146
126
  }
147
127
 
148
- function drawOutdoors(rows: string[], frame: number, _stats: PetStats): void {
149
- const hour = new Date().getHours();
150
- const isDay = hour >= 6 && hour < 20;
128
+ function mascotStateForActivity(activity: PetActivity, frame: number): MascotState {
129
+ switch (activity) {
130
+ case "eating":
131
+ return {
132
+ walls: "on",
133
+ brows: "raised",
134
+ eyes: "open",
135
+ showLock: true,
136
+ dollars: frame % 4 < 2 ? "dim" : "on",
137
+ };
138
+ case "playing":
139
+ return {
140
+ walls: "on",
141
+ brows: frame % 6 < 3 ? "raised" : "normal",
142
+ eyes: "open",
143
+ showLock: true,
144
+ dollars: "on",
145
+ };
146
+ case "working":
147
+ return {
148
+ walls: "on",
149
+ brows: "focused",
150
+ eyes: "open",
151
+ showLock: true,
152
+ dollars: frame % 8 < 4 ? "on" : "dim",
153
+ };
154
+ case "sleeping":
155
+ return {
156
+ walls: "dim",
157
+ brows: "hidden",
158
+ eyes: "closed",
159
+ showLock: true,
160
+ dollars: "dim",
161
+ };
162
+ case "praised":
163
+ return {
164
+ walls: "on",
165
+ brows: "raised",
166
+ eyes: "open",
167
+ showLock: true,
168
+ dollars: "on",
169
+ };
170
+ case "vibing":
171
+ return {
172
+ walls: "on",
173
+ brows: "flat",
174
+ eyes: "open",
175
+ showLock: true,
176
+ dollars: frame % 4 < 2 ? "dim" : "on",
177
+ };
178
+ default:
179
+ return {
180
+ walls: "on",
181
+ brows: "normal",
182
+ eyes: frame > 0 && frame % 22 === 0 ? "closed" : "open",
183
+ showLock: true,
184
+ dollars: "on",
185
+ };
186
+ }
187
+ }
151
188
 
152
- // Sky: dot texture + sun/moon + drifting cloud
153
- rows[R_SKY] = ". . . . . . . . . . . . . . . .".slice(0, CONTENT);
154
- rows[R_SKY] = place(rows[R_SKY], isDay ? "[O]" : "[*]", 1);
155
- const cloudX = 10 + Math.floor((Math.sin(frame * 0.08) * 0.5 + 0.5) * 10);
156
- rows[R_SKY] = place(rows[R_SKY], frame % 8 < 4 ? "(~~)" : "( ~)", cloudX);
189
+ function drawOfficeBackground(rows: string[], width: number, frame: number, stats: PetStats): void {
190
+ const wallLabel = "DREXLER OFFICE";
191
+ const dealPct = `${Math.round(stats.deals).toString().padStart(3)}%`;
192
+ rows[R_WALL] = centerText("─".repeat(width), wallLabel);
193
+ rows[R_WALL] = place(
194
+ rows[R_WALL],
195
+ fitDisplayText(`pipe ${dealPct}`, Math.max(1, width - 2)),
196
+ Math.max(0, width - displayWidth(`pipe ${dealPct}`) - 1),
197
+ );
157
198
 
158
- // Single tree (left, rows 1–2)
159
- rows[R_BGA] = place(rows[R_BGA], " /|\\", 0);
160
- rows[R_BGB] = place(rows[R_BGB], " |||", 0);
199
+ const windowWidth = Math.min(22, Math.max(16, Math.floor(width * 0.36)));
200
+ const boardWidth = Math.min(28, Math.max(20, width - windowWidth - 6));
201
+ const boardX = Math.max(windowWidth + 4, width - boardWidth - 2);
202
+ const marketPulse = frame % 10 < 5 ? "market tape + + +" : "market tape + +";
203
+
204
+ placeSceneBox(rows, R_WINDOW_TOP, 1, windowWidth, "Market Window", marketPulse);
205
+ placeSceneBox(
206
+ rows,
207
+ R_WINDOW_TOP,
208
+ boardX,
209
+ Math.min(boardWidth, width - boardX),
210
+ "Deal Board",
211
+ `DL:${dealPct} fees:${Math.round(stats.happiness)}%`,
212
+ );
161
213
 
162
- // Grass floor (subtle shimmer)
163
- rows[R_FLOOR] = Array.from({ length: CONTENT }, (_, i) =>
164
- (i + frame) % 9 === 0 ? "w" : "─"
165
- ).join("");
214
+ rows[R_ACTIVITY] = centerText(
215
+ "─".repeat(width),
216
+ buildActivityLine("idle", frame),
217
+ );
166
218
  }
167
219
 
168
- function drawBackground(rows: string[], env: Environment, frame: number, stats: PetStats): void {
169
- if (env === "office") drawOffice(rows, frame, stats);
170
- else if (env === "home") drawHome(rows, frame, stats);
171
- else drawOutdoors(rows, frame, stats);
172
- }
220
+ function drawActivityAccents(
221
+ rows: string[],
222
+ width: number,
223
+ activity: PetActivity,
224
+ frame: number,
225
+ mascotX: number,
226
+ ): void {
227
+ rows[R_ACTIVITY] = centerText(
228
+ "─".repeat(width),
229
+ buildActivityLine(activity, frame),
230
+ );
173
231
 
174
- // ─── fluid activity particles ─────────────────────────────────────────────────
175
- function drawParticles(rows: string[], activity: PetActivity, frame: number): void {
176
- const sx = SPRITE_X[activity];
232
+ const mascotRight = mascotX + MASCOT_WIDTH;
233
+ const leftAccentX = Math.max(1, mascotX - 10);
234
+ const rightAccentX = Math.min(width - 10, mascotRight + 2);
177
235
 
178
236
  switch (activity) {
179
- case "sleeping": {
180
- // z chain rising above sprite — each z climbs one row per 2 frames
181
- const chars: ["z", "z", "Z"] = ["z", "z", "Z"];
182
- for (let i = 0; i < 3; i++) {
183
- const age = (frame + i * 4) % 14;
184
- if (age < 10) {
185
- const py = R_DECO - Math.floor(age / 3);
186
- const px = sx + 3 + i;
187
- if (py >= 0 && px < CONTENT) rows[py] = place(rows[py], chars[i] ?? "z", px);
188
- }
189
- }
237
+ case "eating":
238
+ rows[R_DESK_SURFACE] = place(rows[R_DESK_SURFACE], "[$ memo]", rightAccentX);
190
239
  break;
191
- }
192
-
193
- case "playing": {
194
- // Arms progressively extend each phase
195
- const phase = frame % 6;
196
- if (phase >= 1) {
197
- rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "\\", Math.max(0, sx - 1));
198
- rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "/", sx + SPRITE_W);
199
- }
200
- if (phase >= 2) {
201
- rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 2));
202
- rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
203
- }
204
- if (phase >= 3) {
205
- rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 3));
206
- rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
207
- // Stars burst at peak flex
208
- rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, sx - 4));
209
- rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
210
- rows[R_BGA] = place(rows[R_BGA], "*", Math.max(0, sx - 2));
211
- rows[R_BGA] = place(rows[R_BGA], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
212
- }
240
+ case "playing":
241
+ rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", leftAccentX);
242
+ rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", rightAccentX + 6);
213
243
  break;
214
- }
215
-
216
- case "working": {
217
- const smash = frame % 32 >= 26;
218
- if (smash) {
219
- const sf = frame % 32 - 26;
220
- if (sf < 2) rows[R_DECO] = place(rows[R_DECO], " !!! SMASH !!! ", 7);
221
- else if (sf < 4) { rows[R_BGB] = place(rows[R_BGB], " * B O O M * ", 9); }
222
- else rows[R_BGA] = place(rows[R_BGA], " . . . . ", 10);
223
- } else {
224
- // 3 staggered $ streams raining from sky through deco row
225
- const cols = [7, 14, 25] as const;
226
- for (let i = 0; i < 3; i++) {
227
- const colX = cols[i] ?? 7;
228
- for (const off of [0, 9] as const) {
229
- const age = (frame + i * 6 + off) % 18;
230
- const py = Math.floor(age / 2);
231
- if (py <= R_DECO) rows[py] = place(rows[py], "$", colX);
232
- }
233
- }
234
- }
244
+ case "working":
245
+ rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "$", leftAccentX);
246
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", rightAccentX + 6);
235
247
  break;
236
- }
237
-
238
- case "eating": {
239
- // Deal memo arcs smoothly from right toward sprite
240
- const memos: [string, number][] = [["$", 0], ["%", 4]];
241
- for (const [char, off] of memos) {
242
- const p = (frame + off) % 8;
243
- if (p < 6) {
244
- const endX = sx + SPRITE_W + 1;
245
- const startX = CONTENT - 3;
246
- const px = Math.round(startX - (p / 5) * (startX - endX));
247
- const row = R_SP0 + 2 + (off > 0 ? 1 : 0);
248
- if (px >= endX && row < R_FLOOR) rows[row] = place(rows[row], char, px);
249
- }
250
- }
248
+ case "sleeping":
249
+ rows[R_MASCOT_START] = place(rows[R_MASCOT_START], "z z Z", rightAccentX);
251
250
  break;
252
- }
253
-
254
- case "praised": {
255
- // Expanding star burst radiating outward over 5 frames
256
- const cx = sx + Math.floor(SPRITE_W / 2);
257
- const phase = frame % 5;
258
- if (phase === 0) {
259
- rows[R_DECO] = place(rows[R_DECO], "*", cx);
260
- } else if (phase === 1) {
261
- rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 2));
262
- rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 2));
263
- rows[R_BGB] = place(rows[R_BGB], "*", cx);
264
- } else if (phase === 2) {
265
- rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 4));
266
- rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 4));
267
- rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, cx - 2));
268
- rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, cx + 2));
269
- rows[R_BGA] = place(rows[R_BGA], "*", cx);
270
- } else if (phase === 3) {
271
- rows[R_SKY] = place(rows[R_SKY], "*", Math.max(0, cx - 6));
272
- rows[R_SKY] = place(rows[R_SKY], "*", cx);
273
- rows[R_SKY] = place(rows[R_SKY], "*", Math.min(CONTENT - 1, cx + 6));
274
- }
275
- // phase 4 = clear
251
+ case "praised":
252
+ rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", leftAccentX);
253
+ rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", rightAccentX + 4);
276
254
  break;
277
- }
278
-
279
- case "vibing": {
280
- // Concentric ~ rings expanding each frame, reset at 5
281
- const wave = frame % 5;
282
- if (wave >= 1) {
283
- if (sx - 1 >= 0) rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx - 1);
284
- rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx + SPRITE_W);
285
- }
286
- if (wave >= 2) {
287
- if (sx - 2 >= 0) rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", sx - 2);
288
- rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
289
- if (sx - 1 >= 0) rows[R_DECO] = place(rows[R_DECO], "~", sx - 1);
290
- rows[R_DECO] = place(rows[R_DECO], "~", Math.min(CONTENT - 1, sx + SPRITE_W));
291
- }
292
- if (wave >= 3) {
293
- if (sx - 3 >= 0) rows[R_BGB] = place(rows[R_BGB], "~", sx - 3);
294
- rows[R_BGB] = place(rows[R_BGB], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
295
- }
296
- if (wave >= 4) {
297
- if (sx - 4 >= 0) rows[R_BGA] = place(rows[R_BGA], "~", sx - 4);
298
- rows[R_BGA] = place(rows[R_BGA], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
299
- }
255
+ case "vibing":
256
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", leftAccentX);
257
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", rightAccentX + 4);
258
+ break;
259
+ default:
260
+ rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "[IN]", 2);
261
+ rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "[OUT]", Math.max(2, width - 8));
300
262
  break;
301
- }
302
263
  }
303
264
  }
304
265
 
305
- // ─── deco text above sprite ───────────────────────────────────────────────────
306
- function buildDeco(activity: PetActivity, frame: number): string {
307
- switch (activity) {
308
- case "sleeping": return ["z . .", ". z .", ". . z", "Z Z Z"][frame % 4] ?? "z";
309
- case "eating": return Math.floor(frame / 3) % 2 === 0 ? " nom nom nom " : " NOM NOM NOM ";
310
- case "playing": {
311
- const f = frame % 6;
312
- return f < 2 ? " G A I N S ! " : f < 4 ? " *FLEX MODE ON*" : " CORPORATE PUMP";
313
- }
314
- case "praised": return frame % 2 === 0 ? " * * * * * * *" : " * * * * * * * ";
315
- case "working": {
316
- if (frame % 32 >= 26) return ""; // smash handled by particles
317
- const d = ["[............]","[o...........]","[.o..........]","[..o.........]",
318
- "[...o........]","[....o.......]","[.....o......]","[......o.....]",
319
- "[.......o....]","[........o...]","[.........o..]","[..........o.]",
320
- "[...........o]"];
321
- return d[frame % d.length] ?? "[............]";
322
- }
323
- case "vibing": return ["~ ~ ~ ~ ~ ~ ~"," ~ ~ ~ ~ ~ ~ "," ~ ~ ~ ~ ~ "][frame % 3] ?? "~";
324
- default: return "";
266
+ function drawMascot(rows: string[], width: number, activity: PetActivity, frame: number): number {
267
+ const mascot = renderMascotLines(mascotStateForActivity(activity, frame));
268
+ const mascotX = Math.max(0, Math.floor((width - MASCOT_WIDTH) / 2));
269
+ for (let i = 0; i < mascot.length; i++) {
270
+ rows[R_MASCOT_START + i] = place(
271
+ rows[R_MASCOT_START + i] ?? blankRow(width),
272
+ mascot[i] ?? "",
273
+ mascotX,
274
+ );
325
275
  }
276
+ return mascotX;
277
+ }
278
+
279
+ function drawDesk(rows: string[], width: number, stats: PetStats): void {
280
+ const deskX = width > PET_SCENE_WIDTH ? 2 : 1;
281
+ const deskWidth = Math.max(4, width - deskX * 2);
282
+ const deskInner = Math.max(1, deskWidth - 2);
283
+ const surface = `laptop [▣] papers [///] ${cupForEnergy(stats.energy)}`;
284
+ const front = `DESK pipeline ${Math.round(stats.deals)}% covenants OK`;
285
+
286
+ rows[R_DESK_SURFACE] = place(
287
+ rows[R_DESK_SURFACE],
288
+ `╭${padDisplayText(surface, deskInner)}╮`,
289
+ deskX,
290
+ );
291
+ rows[R_DESK_FRONT] = place(
292
+ rows[R_DESK_FRONT],
293
+ `╰${padDisplayText(front, deskInner)}╯`,
294
+ deskX,
295
+ );
326
296
  }
327
297
 
328
- // ─── full scene builder ───────────────────────────────────────────────────────
329
298
  function buildScene(
330
299
  activity: PetActivity,
331
300
  frame: number,
332
301
  stats: PetStats,
333
- env: Environment,
302
+ width: number,
334
303
  ): string[] {
335
- const rows: string[] = Array.from({ length: SCENE_ROWS }, () => " ".repeat(CONTENT));
336
-
337
- drawBackground(rows, env, frame, stats);
338
- drawParticles(rows, activity, frame);
339
-
340
- const deco = buildDeco(activity, frame);
341
- if (deco) {
342
- const sx = SPRITE_X[activity];
343
- const dx = Math.max(0, Math.min(CONTENT - deco.length, sx - Math.floor((deco.length - SPRITE_W) / 2)));
344
- rows[R_DECO] = place(rows[R_DECO], deco, dx);
345
- }
346
-
347
- const sprite = buildSprite(activity, frame);
348
- const sx = SPRITE_X[activity];
349
- for (let i = 0; i < sprite.length; i++) {
350
- rows[R_SP0 + i] = place(rows[R_SP0 + i], sprite[i] ?? "", sx);
351
- }
352
-
353
- return rows;
304
+ const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
305
+ const rows: string[] = Array.from({ length: SCENE_ROWS }, () => blankRow(sceneWidth));
306
+
307
+ drawOfficeBackground(rows, sceneWidth, frame, stats);
308
+ drawDesk(rows, sceneWidth, stats);
309
+ const mascotX = drawMascot(rows, sceneWidth, activity, frame);
310
+ drawActivityAccents(rows, sceneWidth, activity, frame, mascotX);
311
+ return rows.map((row) => overlayFitted(blankRow(sceneWidth), row, 0, sceneWidth));
354
312
  }
355
313
 
356
314
  // ─── row colors ───────────────────────────────────────────────────────────────
357
315
  function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
358
- if (i >= R_SP0 && i < R_SP0 + 6) {
316
+ if (i >= R_MASCOT_START && i < R_MASCOT_START + BRIEFCASE_FINAL.length) {
359
317
  if (activity === "sleeping") return t.dim;
360
318
  if (activity === "praised") return t.primaryLight;
361
319
  if (activity === "eating") return t.warning;
@@ -363,7 +321,7 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
363
321
  if (activity === "working" && frame % 32 >= 26) return t.error;
364
322
  return t.primary;
365
323
  }
366
- if (i === R_DECO) {
324
+ if (i === R_ACTIVITY) {
367
325
  if (activity === "working" && frame % 32 >= 26) return t.error;
368
326
  if (activity === "sleeping") return t.dim;
369
327
  if (activity === "praised") return t.warning;
@@ -371,8 +329,9 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
371
329
  if (activity === "playing") return t.primaryLight;
372
330
  return t.primaryLight;
373
331
  }
374
- if (i === R_FLOOR) return t.primaryDim;
375
- if (i === R_SKY) return t.dim;
332
+ if (i === R_DESK_SURFACE || i === R_DESK_FRONT) return t.primaryDim;
333
+ if (i === R_WALL) return t.dim;
334
+ if (i >= R_WINDOW_TOP && i <= R_WINDOW_BOTTOM) return t.primaryDim;
376
335
  return t.primaryDim;
377
336
  }
378
337
 
@@ -434,6 +393,7 @@ interface PetSceneProps {
434
393
  activity: PetActivity;
435
394
  env?: Environment;
436
395
  isPaused?: boolean;
396
+ width?: number;
437
397
  }
438
398
 
439
399
  interface CompactPetPanelProps extends PetSceneProps {
@@ -442,12 +402,10 @@ interface CompactPetPanelProps extends PetSceneProps {
442
402
 
443
403
  function usePetFrame({
444
404
  activity,
445
- env,
446
405
  isPaused,
447
406
  dead,
448
407
  }: {
449
408
  activity: PetActivity;
450
- env: Environment;
451
409
  isPaused: boolean;
452
410
  dead: boolean;
453
411
  }) {
@@ -455,7 +413,7 @@ function usePetFrame({
455
413
 
456
414
  useEffect(() => {
457
415
  setFrame(0);
458
- }, [activity, env]);
416
+ }, [activity]);
459
417
 
460
418
  useEffect(() => {
461
419
  // Skip frame ticks when paused or when the pet has died — DeathScreen
@@ -474,23 +432,23 @@ function usePetFrame({
474
432
  export function PetScene({
475
433
  stats,
476
434
  activity,
477
- env = "office",
478
435
  isPaused = false,
436
+ width = PET_SCENE_WIDTH,
479
437
  }: PetSceneProps) {
480
438
  const t = useTheme();
439
+ const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
481
440
  const frame = usePetFrame({
482
441
  activity,
483
- env,
484
442
  isPaused,
485
443
  dead: stats.dead === true,
486
444
  });
487
445
  const scene = useMemo(
488
- () => buildScene(activity, frame, stats, env),
489
- [activity, frame, stats, env],
446
+ () => buildScene(activity, frame, stats, sceneWidth),
447
+ [activity, frame, sceneWidth, stats],
490
448
  );
491
449
 
492
450
  return (
493
- <Box flexDirection="column" width={PET_SCENE_WIDTH}>
451
+ <Box flexDirection="column" width={sceneWidth} flexShrink={0}>
494
452
  {scene.map((row, i) => (
495
453
  <Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
496
454
  ))}
@@ -546,7 +504,6 @@ function pickWorstStat(stats: PetStats): WorstStat {
546
504
  function CompactPetPanelView({
547
505
  stats,
548
506
  activity,
549
- env = "office",
550
507
  isPaused = false,
551
508
  width,
552
509
  }: CompactPetPanelProps) {
@@ -566,7 +523,7 @@ function CompactPetPanelView({
566
523
 
567
524
  const mood = getPetMood(stats);
568
525
  const profile = compactStatProfile(stats);
569
- const activityCopy = activity === "idle" ? env : `${env} / ${activity}`;
526
+ const activityCopy = activity === "idle" ? "office" : `office / ${activity}`;
570
527
  const title = "Drexler Pet Desk";
571
528
  const statLine = [
572
529
  `happy ${STAT_LEVEL_LABEL[profile.happiness]}`,