drexler 0.2.22 → 0.2.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.23
4
+
5
+ - Redesigned the office pet scene from the ground up against ANSI/TUI art best practices: focal hierarchy, rule-of-thirds composition, single border vocabulary, four-stop brightness ladder, density-gradient backgrounds.
6
+ - One dominant boardroom window now frames an animated city skyline made from half-block silhouettes (`▆▇█`) with lit-window flicker (`█▒` / `█░`) on a per-tower rotating phase.
7
+ - Sky band carries one sun/moon glyph and a slowly drifting cloud. Window top frame shows an in-fiction clock (advances one minute every 5 frames). Window bottom frame restates the activity line plus a single DL% readout — no chrome echo.
8
+ - Mascot is centered with only two desk props: `▭ DREX` nameplate (cursor blinks while working, switches to `▭ zzz` while sleeping) and the steaming `╭c~╮` mug.
9
+ - Single horizon rule replaces the bordered desk strip + floor dots. Steam wisp lives on the horizon row.
10
+ - Activity accents (`z z Z`, `* *`, `$ $`, `~ ~`, `[$]`) live in the empty cells flanking the mascot.
11
+ - Multiple subtle animation channels (skyline flicker, cloud drift, clock, brow/eye/lock, cursor blink, steam wisp, memo rotation) cap at ~3 fps so the scene reads alive without feeling jittery.
12
+
3
13
  ## 0.2.16
4
14
 
5
15
  - Added an interactive pet system: feed, play, work, praise, rest, vibe, name, and profile commands; persistent stats with offline decay; intern→analyst→associate→VP→MD rank ladder driven by lifetime deal accumulation; 90-second cooldowns per action with in-character rejection copy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
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",
@@ -19,16 +19,30 @@ export type Environment = "office" | "home" | "outdoors";
19
19
  const PANEL_BORDER_COLUMNS = 2;
20
20
  const PANEL_PADDING_COLUMNS = 2;
21
21
  const SCENE_ROWS = 18;
22
- const R_WALL = 0;
23
- const R_WINDOW_TOP = 1;
24
- const R_WINDOW_BOTTOM = 4;
25
- const R_ACTIVITY = 5;
26
- const R_MASCOT_START = 6;
27
- const R_DESK_SURFACE = R_MASCOT_START + BRIEFCASE_FINAL.length;
28
- const R_DESK_FRONT = R_DESK_SURFACE + 1;
29
- const R_DESK_DRAWERS = R_DESK_FRONT + 1;
30
- const R_DESK_BOTTOM = R_DESK_DRAWERS + 1;
31
- const R_FLOOR = R_DESK_BOTTOM + 1;
22
+ // Row map for the redesigned office scene.
23
+ // 0 title bar (DREXLER OFFICE · stat readout)
24
+ // 1 window top border
25
+ // 2 sky band (sun/moon, drifting cloud)
26
+ // 3 skyscraper rooflines
27
+ // 4 skyscraper upper-window grid
28
+ // 5 skyscraper lower-window grid
29
+ // 6 window bottom border (date / time stamp)
30
+ // 7 breathing row (negative space anchor)
31
+ // 8-14 mascot (BRIEFCASE_FINAL is 7 rows)
32
+ // 15 desk horizon + " DESK " label
33
+ // 16 desktop props (nameplate, steaming mug)
34
+ // 17 memo / status line
35
+ const R_TITLE = 0;
36
+ const R_WIN_TOP = 1;
37
+ const R_WIN_SKY = 2;
38
+ const R_WIN_TOPS = 3;
39
+ const R_WIN_MID = 4;
40
+ const R_WIN_BASE = 5;
41
+ const R_WIN_BOTTOM = 6;
42
+ const R_MASCOT_START = 8;
43
+ const R_DESK_LINE = R_MASCOT_START + BRIEFCASE_FINAL.length;
44
+ const R_DESK_PROPS = R_DESK_LINE + 1;
45
+ const R_MEMO = R_DESK_PROPS + 1;
32
46
 
33
47
  export const PET_SCENE_WIDTH = 52;
34
48
 
@@ -39,14 +53,6 @@ function place(base: string, text: string, x: number): string {
39
53
  return base.slice(0, x) + fit + base.slice(end);
40
54
  }
41
55
 
42
- function placeSprite(rows: string[], row: number, x: number, sprite: readonly string[]): void {
43
- for (let i = 0; i < sprite.length; i++) {
44
- const targetRow = row + i;
45
- if (targetRow < 0 || targetRow >= rows.length) continue;
46
- rows[targetRow] = place(rows[targetRow] ?? "", sprite[i] ?? "", x);
47
- }
48
- }
49
-
50
56
  function blankRow(width: number): string {
51
57
  return " ".repeat(width);
52
58
  }
@@ -57,13 +63,6 @@ function padDisplayText(input: string, width: number): string {
57
63
  return `${fitted}${" ".repeat(Math.max(0, safeWidth - displayWidth(fitted)))}`;
58
64
  }
59
65
 
60
- function centerPadDisplayText(input: string, width: number): string {
61
- const safeWidth = Math.max(1, width);
62
- const fitted = fitDisplayText(input, safeWidth);
63
- const left = Math.max(0, Math.floor((safeWidth - displayWidth(fitted)) / 2));
64
- return `${" ".repeat(left)}${fitted}${" ".repeat(Math.max(0, safeWidth - left - displayWidth(fitted)))}`;
65
- }
66
-
67
66
  function overlayFitted(row: string, text: string, x: number, width: number): string {
68
67
  return place(row, padDisplayText(text, width), x);
69
68
  }
@@ -74,61 +73,103 @@ function centerText(row: string, text: string): string {
74
73
  return place(row, safeText, x);
75
74
  }
76
75
 
77
- function sceneBoxTop(title: string, width: number): string {
78
- const safeWidth = Math.max(4, width);
79
- const label = ` ${fitDisplayText(title, Math.max(1, safeWidth - 4))} `;
80
- const ruleWidth = Math.max(0, safeWidth - 2 - displayWidth(label));
81
- return `╭${label}${"─".repeat(ruleWidth)}╮`;
76
+ function cupForEnergy(energy: number): string {
77
+ if (energy > 60) return "c~";
78
+ if (energy > 30) return "c-";
79
+ return "c_";
82
80
  }
83
81
 
84
- function sceneBoxBody(text: string, width: number): string {
85
- const safeWidth = Math.max(4, width);
86
- return `│${padDisplayText(text, safeWidth - 2)}│`;
82
+ // Skyscraper recipe. Each entry is one tower placed left-to-right with
83
+ // roof / upper-windows / lower-windows rows of equal width. The window
84
+ // pattern repeats every `period` columns. `period` and `lit` step the
85
+ // flicker over time so the lit windows rotate without ever changing the
86
+ // tower silhouette.
87
+ interface SkyscraperRecipe {
88
+ width: number;
89
+ gap: number;
90
+ // Roofline glyphs. We pad with the building's edge fill below.
91
+ roof: (width: number) => string;
92
+ upper: (width: number, frame: number, period: number) => string;
93
+ lower: (width: number, frame: number, period: number) => string;
94
+ period: number;
95
+ }
96
+
97
+ function repeatPattern(width: number, period: number, picker: (i: number) => string): string {
98
+ let out = "";
99
+ for (let i = 0; i < width; i++) out += picker(i % period);
100
+ return out;
101
+ }
102
+
103
+ function skyscraperTops(width: number): string {
104
+ // ▆▇ produces a fuller roof; flat parapets at the ends keep silhouettes square.
105
+ if (width <= 2) return "▇".repeat(width);
106
+ if (width <= 4) return "▆" + "▇".repeat(width - 2) + "▆";
107
+ return "▆▇" + "▇".repeat(width - 4) + "▇▆";
108
+ }
109
+
110
+ function skyscraperUpper(width: number, frame: number, period: number): string {
111
+ // Two-glyph alternation █▒ with a slow flicker swap that lights one
112
+ // window per tower every 4 frames. ░ reads as "lit" against ▒ "dim".
113
+ const lit = Math.floor(frame / 3) % period;
114
+ return repeatPattern(width, period, (col) => {
115
+ const base = col % 2 === 0 ? "█" : "▒";
116
+ if (col === lit) return col % 2 === 0 ? "█" : "░";
117
+ return base;
118
+ });
87
119
  }
88
120
 
89
- function sceneBoxBottom(width: number): string {
90
- const safeWidth = Math.max(4, width);
91
- return `╰${"─".repeat(safeWidth - 2)}╯`;
121
+ function skyscraperLower(width: number, frame: number, period: number): string {
122
+ // Offset flicker phase from the upper grid so the two rows feel
123
+ // independent without ever looking chaotic.
124
+ const lit = (Math.floor(frame / 3) + 1) % period;
125
+ return repeatPattern(width, period, (col) => {
126
+ const base = col % 2 === 0 ? "█" : "▒";
127
+ if (col === lit) return col % 2 === 0 ? "█" : "░";
128
+ return base;
129
+ });
92
130
  }
93
131
 
94
- function placeBoxLines(
95
- rows: string[],
96
- row: number,
97
- x: number,
98
- width: number,
99
- title: string,
100
- body: readonly string[],
101
- ): void {
102
- rows[row] = place(rows[row] ?? "", sceneBoxTop(title, width), x);
103
- for (let i = 0; i < body.length; i++) {
104
- rows[row + i + 1] = place(
105
- rows[row + i + 1] ?? "",
106
- sceneBoxBody(body[i] ?? "", width),
107
- x,
108
- );
132
+ const SKYLINE_RECIPE: ReadonlyArray<SkyscraperRecipe> = [
133
+ { width: 4, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
134
+ { width: 6, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
135
+ { width: 3, gap: 2, period: 3, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
136
+ { width: 7, gap: 2, period: 6, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
137
+ { width: 4, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
138
+ { width: 5, gap: 3, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
139
+ { width: 3, gap: 2, period: 3, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
140
+ ];
141
+
142
+ function buildSkylineRows(width: number, frame: number): {
143
+ tops: string;
144
+ upper: string;
145
+ lower: string;
146
+ } {
147
+ // Compose left-to-right until we run out of room. Pad with spaces so
148
+ // the silhouette doesn't extend past the inner canvas width.
149
+ let tops = "";
150
+ let upper = "";
151
+ let lower = "";
152
+ let cursor = 0;
153
+ for (const tower of SKYLINE_RECIPE) {
154
+ if (cursor + tower.width > width) break;
155
+ tops += tower.roof(tower.width);
156
+ upper += tower.upper(tower.width, frame, tower.period);
157
+ lower += tower.lower(tower.width, frame, tower.period);
158
+ cursor += tower.width;
159
+ const gap = Math.min(tower.gap, Math.max(0, width - cursor));
160
+ if (gap > 0) {
161
+ tops += " ".repeat(gap);
162
+ upper += " ".repeat(gap);
163
+ lower += " ".repeat(gap);
164
+ cursor += gap;
165
+ }
109
166
  }
110
- rows[row + body.length + 1] = place(
111
- rows[row + body.length + 1] ?? "",
112
- sceneBoxBottom(width),
113
- x,
114
- );
115
- }
116
-
117
- function cupForEnergy(energy: number): string {
118
- if (energy > 60) return "c~";
119
- if (energy > 30) return "c-";
120
- return "c_";
121
- }
122
-
123
- function analogClockSprite(frame: number): readonly string[] {
124
- const hands = [
125
- ["│╲│ │", "│ └─│"],
126
- ["│ │╱│", "│─┘ │"],
127
- ["│ │ │", "│─┼─│"],
128
- ["│╲│ │", "│─┘ │"],
129
- ] as const;
130
- const [upper, lower] = hands[Math.floor(frame / 2) % hands.length] ?? hands[0]!;
131
- return ["╭───╮", upper, lower, "╰───╯"];
167
+ const remainder = Math.max(0, width - cursor);
168
+ return {
169
+ tops: tops + " ".repeat(remainder),
170
+ upper: upper + " ".repeat(remainder),
171
+ lower: lower + " ".repeat(remainder),
172
+ };
132
173
  }
133
174
 
134
175
  function progressTicker(frame: number): string {
@@ -225,129 +266,124 @@ function mascotStateForActivity(activity: PetActivity, frame: number): MascotSta
225
266
  }
226
267
  }
227
268
 
228
- function drawOfficeBackground(rows: string[], width: number, frame: number, stats: PetStats): void {
229
- const wallLabel = "DREXLER OFFICE";
230
- const dealPct = `${Math.round(stats.deals).toString().padStart(3)}%`;
231
- rows[R_WALL] = centerText("─".repeat(width), wallLabel);
232
- rows[R_WALL] = place(
233
- rows[R_WALL],
234
- fitDisplayText(`pipe ${dealPct}`, Math.max(1, width - 2)),
235
- Math.max(0, width - displayWidth(`pipe ${dealPct}`) - 1),
236
- );
237
-
238
- const compact = width < 62;
239
- const windowWidth = compact
240
- ? 17
241
- : Math.min(30, Math.max(20, Math.floor(width * 0.32)));
242
- const boardWidth = compact
243
- ? Math.min(25, Math.max(20, width - windowWidth - 5))
244
- : Math.min(36, Math.max(26, Math.floor(width * 0.36)));
245
- const boardX = Math.max(windowWidth + 3, width - boardWidth - 2);
246
- const windowRight = 1 + windowWidth;
247
- const gapWidth = boardX - windowRight;
248
- const cloud = frame % 6 < 3 ? "(~~)" : " (~~)";
249
- const sun = frame % 12 < 6 ? "\\o/" : "-o-";
250
- const city = frame % 10 < 5 ? "▂▄▆ city" : "▃▅▇ city";
251
- const tape = frame % 8 < 4 ? "▁▃▅▇" : "▂▄▆█";
252
- const cursor = frame % 4 < 2 ? ">" : "*";
253
-
254
- placeBoxLines(rows, R_WINDOW_TOP, 1, windowWidth, "Window", [
255
- `╔╤╤╗ ${sun} ${cloud}`,
256
- `║▥▥║ ${city}`,
257
- ]);
258
- placeBoxLines(
259
- rows,
260
- R_WINDOW_TOP,
261
- boardX,
262
- Math.min(boardWidth, width - boardX),
263
- "Deal Board",
264
- [
265
- `DL ${dealPct} FEE ${Math.round(stats.happiness).toString().padStart(3)}%`,
266
- `PIPE ${tape} $ ${cursor}`,
267
- ],
269
+ function drawTitleBar(rows: string[], width: number, stats: PetStats): void {
270
+ // Single-line title bar: "DREXLER OFFICE ─ … ─ deals 38% ─" with
271
+ // padded glyphs around each segment so the eye reads them as labels
272
+ // on a rule, not a rule running through text. The right-aligned
273
+ // readout names the most-pressing stat. Avoids the previous chrome
274
+ // echo where the same percentage appeared in both the title and the
275
+ // desk strip.
276
+ const label = " DREXLER OFFICE ";
277
+ const worst = pickWorstStat(stats);
278
+ const readout = ` ${worst.key} ${Math.round(worst.value)}% `;
279
+ rows[R_TITLE] = centerText("─".repeat(width), label);
280
+ rows[R_TITLE] = place(
281
+ rows[R_TITLE],
282
+ fitDisplayText(readout, Math.max(1, width - 2)),
283
+ Math.max(0, width - displayWidth(readout) - 1),
268
284
  );
285
+ }
269
286
 
270
- if (gapWidth >= 5) {
271
- const clockX = windowRight + Math.floor((gapWidth - 5) / 2);
272
- placeSprite(rows, R_WINDOW_TOP, clockX, analogClockSprite(frame));
273
- }
287
+ function clockFromFrame(frame: number): string {
288
+ // Slow ambient clock advances roughly one minute every 5 frames.
289
+ const startHour = 9; // boardroom opens at 9 AM corporate time.
290
+ const totalMinutes = startHour * 60 + Math.floor(frame / 5);
291
+ const hour = Math.floor(totalMinutes / 60) % 24;
292
+ const minute = totalMinutes % 60;
293
+ return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
294
+ }
274
295
 
275
- rows[R_ACTIVITY] = centerText(
276
- "─".repeat(width),
277
- buildActivityLine("idle", frame),
296
+ function drawBoardroomWindow(
297
+ rows: string[],
298
+ width: number,
299
+ frame: number,
300
+ stats: PetStats,
301
+ activity: PetActivity,
302
+ ): void {
303
+ // Outer rounded window frame spans nearly the full panel width. The
304
+ // city skyline lives entirely inside the frame; nothing else competes
305
+ // with it for upper-half attention.
306
+ const winX = 1;
307
+ const winWidth = Math.max(20, width - 2);
308
+ const innerWidth = Math.max(8, winWidth - 2);
309
+
310
+ // Top frame carries a quiet label so the eye knows what it's looking at.
311
+ const topLabel = ` Skyline · ${clockFromFrame(frame)} `;
312
+ const topRuleWidth = Math.max(0, innerWidth - displayWidth(topLabel));
313
+ rows[R_WIN_TOP] = place(
314
+ rows[R_WIN_TOP],
315
+ `╭${topLabel}${"─".repeat(topRuleWidth)}╮`,
316
+ winX,
278
317
  );
279
- }
280
318
 
281
- function drawOfficeFurniture(rows: string[], width: number, frame: number): void {
282
- const lampX = 1;
283
- const cabinetX = Math.max(1, width - 8);
284
- const shade = frame % 8 < 4 ? "╱____╲" : "╱ ╲";
285
- const plantTop = frame % 6 < 3 ? " ╲│╱ " : " ╱│╲ ";
286
-
287
- placeSprite(rows, R_MASCOT_START, lampX, [
288
- " ╭──╮ ",
289
- ` ${shade}`,
290
- " ╰─┬──╯",
291
- " │ ",
292
- " ╭─┴─╮ ",
293
- " │IN ",
294
- " ╰───╯ ",
295
- ]);
296
-
297
- placeSprite(rows, R_MASCOT_START, cabinetX, [
298
- plantTop,
299
- " │ ",
300
- " ╭┴╮ ",
301
- "╭FILE╮",
302
- "│▤▤▤│",
303
- "├────┤",
304
- "╰────╯",
305
- ]);
319
+ // Sky row: sun/moon left, drifting cloud right, otherwise empty so
320
+ // the skyline has clean air to breathe.
321
+ const sunGlyph = frame % 24 < 12 ? "☼" : "☾";
322
+ const cloudOffset = (Math.floor(frame / 2) % (innerWidth - 6)) + 2;
323
+ let sky = " ".repeat(innerWidth);
324
+ sky = place(sky, sunGlyph, 1);
325
+ sky = place(sky, "(~~)", cloudOffset);
326
+ rows[R_WIN_SKY] = place(rows[R_WIN_SKY], `│${sky}│`, winX);
327
+
328
+ // Skyscrapers. Centered horizontally inside the inner canvas so the
329
+ // skyline reads as one continuous silhouette.
330
+ const skylineWidth = Math.max(8, innerWidth - 4);
331
+ const { tops, upper, lower } = buildSkylineRows(skylineWidth, frame);
332
+ const padLeft = Math.max(0, Math.floor((innerWidth - skylineWidth) / 2));
333
+ const padRight = Math.max(0, innerWidth - padLeft - skylineWidth);
334
+ const wrapSkylineRow = (row: string): string =>
335
+ `│${" ".repeat(padLeft)}${row}${" ".repeat(padRight)}│`;
336
+ rows[R_WIN_TOPS] = place(rows[R_WIN_TOPS], wrapSkylineRow(tops), winX);
337
+ rows[R_WIN_MID] = place(rows[R_WIN_MID], wrapSkylineRow(upper), winX);
338
+ rows[R_WIN_BASE] = place(rows[R_WIN_BASE], wrapSkylineRow(lower), winX);
339
+
340
+ // Bottom frame restates the current activity for at-a-glance status.
341
+ const bottomLabel = ` ${buildActivityLine(activity, frame)} · DL ${Math.round(stats.deals).toString().padStart(3)}% `;
342
+ const fittedBottom = fitDisplayText(bottomLabel, Math.max(1, innerWidth - 2));
343
+ const bottomRuleWidth = Math.max(0, innerWidth - displayWidth(fittedBottom));
344
+ rows[R_WIN_BOTTOM] = place(
345
+ rows[R_WIN_BOTTOM],
346
+ `╰${fittedBottom}${"─".repeat(bottomRuleWidth)}╯`,
347
+ winX,
348
+ );
306
349
  }
307
350
 
308
351
  function drawActivityAccents(
309
352
  rows: string[],
310
353
  width: number,
311
354
  activity: PetActivity,
312
- frame: number,
355
+ _frame: number,
313
356
  mascotX: number,
314
357
  ): void {
315
- rows[R_ACTIVITY] = centerText(
316
- "─".repeat(width),
317
- buildActivityLine(activity, frame),
318
- );
319
-
358
+ // Accents are now small, single-glyph flourishes positioned in the
359
+ // empty cells immediately flanking the mascot. No competing props,
360
+ // so accents always have clean space to land on.
320
361
  const mascotRight = mascotX + MASCOT_WIDTH;
321
- const leftAccentX = Math.max(1, mascotX - 3);
322
- const fileX = Math.max(1, width - 8);
323
- const rightAccentX = Math.min(fileX - 6, mascotRight + 2);
362
+ const leftAccentX = Math.max(1, mascotX - 4);
363
+ const rightAccentX = Math.min(width - 2, mascotRight + 2);
324
364
 
325
365
  switch (activity) {
326
366
  case "eating":
327
- rows[R_MASCOT_START + 3] = place(
328
- rows[R_MASCOT_START + 3],
329
- "[$]",
330
- Math.max(1, Math.min(fileX - 5, rightAccentX + 1)),
331
- );
367
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "[$]", rightAccentX);
332
368
  break;
333
369
  case "playing":
334
370
  rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", leftAccentX);
335
- rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", Math.min(fileX - 2, rightAccentX + 6));
371
+ rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", rightAccentX);
336
372
  break;
337
373
  case "working":
338
374
  rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "$", leftAccentX);
339
- rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", Math.min(fileX - 2, rightAccentX + 6));
375
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", rightAccentX);
340
376
  break;
341
377
  case "sleeping":
342
378
  rows[R_MASCOT_START] = place(rows[R_MASCOT_START], "z z Z", rightAccentX);
343
379
  break;
344
380
  case "praised":
345
381
  rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", leftAccentX);
346
- rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", Math.min(fileX - 4, rightAccentX + 4));
382
+ rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", rightAccentX);
347
383
  break;
348
384
  case "vibing":
349
385
  rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", leftAccentX);
350
- rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", Math.min(fileX - 4, rightAccentX + 4));
386
+ rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", rightAccentX);
351
387
  break;
352
388
  default:
353
389
  break;
@@ -367,82 +403,55 @@ function drawMascot(rows: string[], width: number, activity: PetActivity, frame:
367
403
  return mascotX;
368
404
  }
369
405
 
370
- function drawDesktopObjects(
406
+ function drawDeskHorizon(rows: string[], width: number): void {
407
+ // One quiet horizon line anchors the mascot in the room. The
408
+ // " DESK " label sits at the rule's center and identifies the
409
+ // working surface without needing a bordered strip below.
410
+ const label = " DREXLER DEAL DESK ";
411
+ const fitted = fitDisplayText(label, Math.max(1, width - 4));
412
+ const labelWidth = displayWidth(fitted);
413
+ const leftRule = Math.max(2, Math.floor((width - labelWidth) / 2));
414
+ const rightRule = Math.max(2, width - leftRule - labelWidth);
415
+ rows[R_DESK_LINE] = `${"─".repeat(leftRule)}${fitted}${"─".repeat(rightRule)}`;
416
+ }
417
+
418
+ function drawDeskProps(
371
419
  rows: string[],
372
420
  width: number,
373
421
  activity: PetActivity,
374
422
  frame: number,
375
423
  stats: PetStats,
376
424
  ): void {
425
+ // Two props only: the mascot nameplate on the left and a coffee mug
426
+ // on the right. Both sit on the desk-props row so they share a
427
+ // baseline with the mascot above and never float.
377
428
  const mascotX = Math.max(0, Math.floor((width - MASCOT_WIDTH) / 2));
378
429
  const mascotRight = mascotX + MASCOT_WIDTH;
379
- const cabinetX = Math.max(1, width - 8);
380
- const laptopX = Math.max(8, mascotX - 9);
381
- const papersX = Math.min(cabinetX - 10, mascotRight + 2);
382
- const mugX = Math.min(cabinetX - 5, mascotRight + 6);
383
- const cursor = frame % 2 === 0 ? "_" : " ";
384
- const screen =
385
- activity === "working"
386
- ? `$>${cursor}DL`
387
- : activity === "sleeping"
388
- ? "zzz..."
389
- : "DREX";
390
- const steam = stats.energy > 30
391
- ? frame % 4 < 2 ? " ((" : " ))"
392
- : " ";
393
- const paperFace = frame % 6 < 3 ? "▱▱▱" : "▰▱▱";
394
-
395
- placeSprite(rows, R_MASCOT_START + 4, laptopX, [
396
- "╭──────╮",
397
- `│${padDisplayText(screen, 6)}│`,
398
- "╰─┬──┬─╯",
399
- ]);
400
-
401
- if (papersX > mascotRight) {
402
- rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], paperFace, papersX);
403
- }
404
-
430
+ const nameplateX = Math.max(2, mascotX - 12);
431
+ const mugX = Math.min(width - 6, mascotRight + 4);
432
+ const cursorAt = activity === "working" && frame % 2 === 0 ? "_" : " ";
433
+ const namePlate = activity === "sleeping" ? "▭ zzz " : `▭ DREX${cursorAt}`;
434
+ rows[R_DESK_PROPS] = place(rows[R_DESK_PROPS], namePlate, nameplateX);
405
435
  if (mugX > mascotRight + 1) {
406
- rows[R_MASCOT_START + 4] = place(rows[R_MASCOT_START + 4], steam, mugX + 1);
407
- rows[R_MASCOT_START + 5] = place(rows[R_MASCOT_START + 5], `╭${cupForEnergy(stats.energy)}╮`, mugX);
408
- rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], "╰──╯", mugX);
436
+ // Steam wisp lives on the desk horizon row, just above the mug.
437
+ // Letting it tick frame-by-frame gives the room one quiet ambient
438
+ // beat without competing with the skyline flicker.
439
+ const steam = stats.energy > 30
440
+ ? frame % 4 < 2 ? " ((" : " ))"
441
+ : "";
442
+ if (steam) {
443
+ rows[R_DESK_LINE] = place(rows[R_DESK_LINE], steam, mugX + 1);
444
+ }
445
+ rows[R_DESK_PROPS] = place(rows[R_DESK_PROPS], `╭${cupForEnergy(stats.energy)}╮`, mugX);
409
446
  }
410
447
  }
411
448
 
412
- function drawDesk(rows: string[], width: number, stats: PetStats): void {
413
- const deskX = width > PET_SCENE_WIDTH ? 2 : 1;
414
- const deskWidth = Math.max(4, width - deskX * 2);
415
- const deskInner = Math.max(1, deskWidth - 2);
416
- const surface = "▱▱▱ ▬▬▬▬▬ COV OK";
417
- const front = `[IN] DREXLER DEAL DESK ║ PIPE ${Math.round(stats.deals)}% [OUT]`;
418
- const drawers = width < 68
419
- ? "│▤▤│ │▤▤│ │▤▤│"
420
- : "│▤▤│ │▤▤│ │▤▤│ │▤▤│";
421
-
422
- rows[R_DESK_SURFACE] = place(
423
- rows[R_DESK_SURFACE],
424
- `╭${padDisplayText(surface, deskInner)}╮`,
425
- deskX,
426
- );
427
- rows[R_DESK_FRONT] = place(
428
- rows[R_DESK_FRONT],
429
- `│${padDisplayText(front, deskInner)}│`,
430
- deskX,
431
- );
432
- rows[R_DESK_DRAWERS] = place(
433
- rows[R_DESK_DRAWERS],
434
- `│${centerPadDisplayText(drawers, deskInner)}│`,
435
- deskX,
436
- );
437
- rows[R_DESK_BOTTOM] = place(
438
- rows[R_DESK_BOTTOM],
439
- `╰${"─".repeat(Math.max(0, deskInner))}╯`,
440
- deskX,
441
- );
442
- rows[R_FLOOR] = centerText(
443
- rows[R_FLOOR],
444
- fitDisplayText("· · · · · · · · · · · · · · · · ·", width),
445
- );
449
+ function drawMemo(rows: string[], width: number, stats: PetStats, frame: number): void {
450
+ // Memo row carries the rotating status message. Centered, dim — the
451
+ // closing punctuation of the scene rather than a competing chrome
452
+ // strip with its own border.
453
+ const memo = ${getStatusMsg(stats, frame)} ·`;
454
+ rows[R_MEMO] = centerText(" ".repeat(width), fitDisplayText(memo, width));
446
455
  }
447
456
 
448
457
  function buildScene(
@@ -454,16 +463,22 @@ function buildScene(
454
463
  const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
455
464
  const rows: string[] = Array.from({ length: SCENE_ROWS }, () => blankRow(sceneWidth));
456
465
 
457
- drawOfficeBackground(rows, sceneWidth, frame, stats);
458
- drawOfficeFurniture(rows, sceneWidth, frame);
466
+ drawTitleBar(rows, sceneWidth, stats);
467
+ drawBoardroomWindow(rows, sceneWidth, frame, stats, activity);
459
468
  const mascotX = drawMascot(rows, sceneWidth, activity, frame);
460
- drawDesktopObjects(rows, sceneWidth, activity, frame, stats);
461
- drawDesk(rows, sceneWidth, stats);
469
+ drawDeskHorizon(rows, sceneWidth);
470
+ drawDeskProps(rows, sceneWidth, activity, frame, stats);
471
+ drawMemo(rows, sceneWidth, stats, frame);
462
472
  drawActivityAccents(rows, sceneWidth, activity, frame, mascotX);
463
473
  return rows.map((row) => overlayFitted(blankRow(sceneWidth), row, 0, sceneWidth));
464
474
  }
465
475
 
466
476
  // ─── row colors ───────────────────────────────────────────────────────────────
477
+ // Four-stop brightness ladder so the eye finds a hierarchy:
478
+ // dim → chrome (title rule, memo)
479
+ // primaryDim → window frame, desk horizon, skyline silhouettes
480
+ // primary → desk props, mascot body
481
+ // primaryLight → mascot eyes + activity accents
467
482
  function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
468
483
  if (i >= R_MASCOT_START && i < R_MASCOT_START + BRIEFCASE_FINAL.length) {
469
484
  if (activity === "sleeping") return t.dim;
@@ -473,23 +488,20 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
473
488
  if (activity === "working" && frame % 32 >= 26) return t.error;
474
489
  return t.primary;
475
490
  }
476
- if (i === R_ACTIVITY) {
491
+ if (i === R_TITLE) return t.dim;
492
+ if (i === R_WIN_SKY) return t.dim;
493
+ if (i === R_WIN_TOPS || i === R_WIN_MID || i === R_WIN_BASE) return t.primaryDim;
494
+ if (i === R_WIN_TOP || i === R_WIN_BOTTOM) return t.primaryDim;
495
+ if (i === R_DESK_LINE) return t.primaryDim;
496
+ if (i === R_DESK_PROPS) return t.primary;
497
+ if (i === R_MEMO) {
477
498
  if (activity === "working" && frame % 32 >= 26) return t.error;
478
499
  if (activity === "sleeping") return t.dim;
479
500
  if (activity === "praised") return t.warning;
480
501
  if (activity === "eating") return t.warning;
481
502
  if (activity === "playing") return t.primaryLight;
482
- return t.primaryLight;
503
+ return t.dim;
483
504
  }
484
- if (
485
- i === R_DESK_SURFACE ||
486
- i === R_DESK_FRONT ||
487
- i === R_DESK_DRAWERS ||
488
- i === R_DESK_BOTTOM ||
489
- i === R_FLOOR
490
- ) return t.primaryDim;
491
- if (i === R_WALL) return t.dim;
492
- if (i >= R_WINDOW_TOP && i <= R_WINDOW_BOTTOM) return t.primaryDim;
493
505
  return t.primaryDim;
494
506
  }
495
507