-
+
- {#if face === 'sleep'}
-
z
- {:else if face === 'calling' || face === 'alarm'}
-
!
- {:else if face === 'happy' || face === 'cheer'}
-
- {/if}
+
diff --git a/src/components/companion/companion-character.svelte.test.ts b/src/components/companion/companion-character.svelte.test.ts
new file mode 100644
index 0000000..e43fd98
--- /dev/null
+++ b/src/components/companion/companion-character.svelte.test.ts
@@ -0,0 +1,50 @@
+/*
+ * The companion draws a skin with its family's character: the box for a box
+ * or no skin, a Jelly, Bubble, Capsule or Retro robot for the others.
+ */
+import { afterEach, expect, test } from 'vitest';
+import { flushSync, mount, unmount } from 'svelte';
+import type { Robot } from '../../lib/robots';
+import CompanionCharacter from './CompanionCharacter.svelte';
+
+let shown: { character: Record
; target: HTMLElement } | null = null;
+
+function show(skin: Robot | null, mood: 'idle' | 'working' | 'calling' = 'idle'): HTMLElement {
+ const target = document.body.appendChild(document.createElement('div'));
+ shown = { character: mount(CompanionCharacter, { target, props: { mood, skin, lively: false } }), target };
+ flushSync();
+ return target.firstElementChild as HTMLElement;
+}
+
+afterEach(() => {
+ if (shown) {
+ unmount(shown.character);
+ shown.target.remove();
+ shown = null;
+ }
+});
+
+const robot = (family: Robot['family']): Robot => ({ family, shape: 1, color: 2, eyes: 1, top: 1 });
+
+test('no skin and a box skin draw the box', () => {
+ expect(show(null).classList.contains('char')).toBe(true);
+ unmount(shown!.character);
+ shown!.target.remove();
+ expect(show(robot('box')).classList.contains('char')).toBe(true);
+});
+
+for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) {
+ test(`a ${family} skin draws the ${family} character, with the mood's face`, () => {
+ const root = show(robot(family), 'working');
+ expect(root.classList.contains(family)).toBe(true);
+ expect(root.classList.contains('f-working')).toBe(true);
+ expect(root.querySelector(`[data-family='${family}']`)).not.toBeNull();
+ expect(root.getAttribute('role')).toBe('img');
+ });
+}
+
+test('a calling robot shows its mark', () => {
+ const root = show(robot('retro'), 'calling');
+ expect(root.classList.contains('f-calling')).toBe(true);
+ expect(root.querySelector('.shout')?.textContent).toBe('!');
+});
diff --git a/src/lib/companion/character.test.ts b/src/lib/companion/character.test.ts
new file mode 100644
index 0000000..0913087
--- /dev/null
+++ b/src/lib/companion/character.test.ts
@@ -0,0 +1,28 @@
+import { expect, test } from 'vitest';
+import { characterOf, faceState, markOf } from './character';
+
+test('each skin is drawn by its family, and no skin by the classic box', () => {
+ for (const family of ['box', 'jelly', 'bubble', 'capsule', 'retro'] as const) {
+ expect(characterOf({ family, shape: 0, color: 0, eyes: 0, top: 0 })).toBe(family);
+ }
+ expect(characterOf(null)).toBe('box');
+ expect(characterOf(undefined)).toBe('box');
+});
+
+test('the drawing blinks at rest, glances at work, hops when it calls, keeps still with its eyes shut', () => {
+ expect(faceState('awake')).toBe('idle');
+ expect(faceState('happy')).toBe('idle');
+ expect(faceState('working')).toBe('working');
+ expect(faceState('calling')).toBe('waiting');
+ expect(faceState('alarm')).toBe('waiting');
+ for (const face of ['sleep', 'calm', 'rest', 'startled', 'yawn'] as const) expect(faceState(face)).toBe('still');
+});
+
+test('a z asleep, a ! when it calls, sparks for joy, nothing otherwise', () => {
+ expect(markOf('sleep')).toBe('z');
+ expect(markOf('calling')).toBe('shout');
+ expect(markOf('alarm')).toBe('shout');
+ expect(markOf('cheer')).toBe('spark');
+ expect(markOf('happy')).toBe('spark');
+ expect(markOf('working')).toBeNull();
+});
diff --git a/src/lib/companion/character.ts b/src/lib/companion/character.ts
new file mode 100644
index 0000000..4400139
--- /dev/null
+++ b/src/lib/companion/character.ts
@@ -0,0 +1,56 @@
+/*
+ * What every animated companion shares, whatever its family: the face
+ * `CompanionCharacter` works out from the mood and its flags, the props it
+ * hands a family's character (`JellyCharacter`, `BubbleCharacter`,
+ * `CapsuleCharacter`, `RetroCharacter`), the state its `RobotFace` drawing
+ * takes, and which character draws a skin. The box is `CompanionCharacter`'s
+ * own drawing.
+ */
+import type { Robot, RobotFamily } from '../robots';
+
+/** What the companion shows it feels now. */
+export type Face = 'awake' | 'sleep' | 'yawn' | 'groove' | 'typing' | 'working' | 'calling' | 'happy' | 'worried' | 'alarm' | 'calm' | 'rest' | 'cheer' | 'game' | 'startled';
+/** An idle gesture now and then. */
+export type Act = 'look' | 'blink' | 'peek' | 'stretch' | 'yawn';
+/** The light's colour: the mood's, or a calm one. */
+export type Tone = 'idle' | 'working' | 'calling' | 'happy' | 'worried';
+/** How `RobotFace` moves on its own: a blink, a glance, a hop, or nothing. */
+export type FaceState = 'idle' | 'working' | 'waiting' | 'still';
+
+/** What `CompanionCharacter` hands the character of a family other than the box. */
+export interface CharacterProps {
+ robot: Robot;
+ face: Face;
+ tone: Tone;
+ /** Where the eyes look, each axis from -1 to 1. */
+ look: { x: number; y: number };
+ /** Poked: a hop. */
+ bouncing: boolean;
+ /** Poked too often: a wobble, eyes spinning. */
+ dizzy: boolean;
+ act: Act | null;
+ size: number;
+ label: string;
+}
+
+/** The faces with the eyes shut or nearly: the drawing keeps still, so the character can close them. */
+const SHUT: ReadonlySet = new Set(['sleep', 'calm', 'rest', 'startled', 'yawn']);
+
+/** The state `RobotFace` takes for a face: it blinks at rest, glances while working, hops while it calls. */
+export function faceState(face: Face): FaceState {
+ if (SHUT.has(face)) return 'still';
+ if (face === 'working') return 'working';
+ return face === 'calling' || face === 'alarm' ? 'waiting' : 'idle';
+}
+
+/** The character that draws a skin: its family's, or the classic box when there is none. */
+export function characterOf(skin: Robot | null | undefined): RobotFamily {
+ return skin?.family ?? 'box';
+}
+
+/** The mark beside the character: a z asleep, a ! when it calls, sparks for joy. */
+export function markOf(face: Face): 'z' | 'shout' | 'spark' | null {
+ if (face === 'sleep') return 'z';
+ if (face === 'calling' || face === 'alarm') return 'shout';
+ return face === 'happy' || face === 'cheer' ? 'spark' : null;
+}
diff --git a/src/lib/companion/companion-crew.test.ts b/src/lib/companion/companion-crew.test.ts
index e4487ad..ff15dfd 100644
--- a/src/lib/companion/companion-crew.test.ts
+++ b/src/lib/companion/companion-crew.test.ts
@@ -6,7 +6,7 @@
import { afterEach, expect, test, vi } from 'vitest';
import type { Account, AgentConversationMessage, AgentMemory, AgentProfile, AgentScope, AgentsSnapshot, ProviderSummary } from '@boite/contracts';
import type { Client } from '@boite/ui/lib/client';
-import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot } from '../robots';
+import { BOX_COLORS, boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots';
import { strings } from '../strings';
import type { Brain } from './brain';
import { ROLE_START } from './brain';
@@ -15,7 +15,6 @@ import { Crew } from './crew.svelte';
import { addReminder, readMemory, readReminders, remember } from './memory';
import { Notes } from './notes.svelte';
import { readCompanionPrefs, writeCompanionPrefs } from './prefs';
-import { toBox } from './skin';
afterEach(() => {
vi.useRealTimers();
@@ -65,33 +64,49 @@ const snapshotOf = (part: Partial) => ({ profiles: [], messages:
// Looks
// ---------------------------------------------------------------------------
-test('an agent stands as a box: its own box, any other robot made one in its colour, or the box its id draws', () => {
+test('an agent stands as its own robot, of any family, or the box its id draws when its picture is text', () => {
expect(skinOf(profile('bots', { avatar: CLASSIC_SKIN }))).toEqual({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 });
const box = { family: 'box', shape: 4, color: 7, eyes: 2, top: 5 } as const;
expect(skinOf(profile('a', { avatar: encodeRobot(box) }))).toEqual(box);
- // A jelly robot keeps its colour (one up, past the classic body) and its top as the accessory.
- expect(skinOf(profile('a', { avatar: encodeRobot({ family: 'jelly', shape: 1, color: 2, eyes: 1, top: 3 }) }))).toMatchObject({ family: 'box', color: 3, top: 3 });
- // No avatar: the robot the Agents page draws for its id, as a box.
- expect(skinOf(profile('seeded'))).toEqual(toBox(seededRobot('seeded')));
+ for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) {
+ const robot = { family, shape: 1, color: 2, eyes: 1, top: 3 };
+ expect(skinOf(profile('a', { avatar: encodeRobot(robot) }))).toEqual(robot);
+ }
+ // No avatar: the robot the Agents page draws for its id.
+ expect(skinOf(profile('seeded'))).toEqual(seededRobot('seeded'));
expect(skinOf(profile('emoji', { avatar: '🦊' }))).toEqual(seededRobot('emoji', 'box'));
});
-test('a fresh look takes a body colour nobody wears, never the classic one, and always an accessory', () => {
- const worn = (color: number) => ({ family: 'box' as const, shape: 0, color, eyes: 0, top: 0 });
- for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) {
- const others = [worn(0), worn(1), worn(2), worn(3)];
+test('a fresh look takes a family and a colour nobody wears, never the classic body, a box always with an accessory', () => {
+ const box = (color: number): Robot => ({ family: 'box', shape: 0, color, eyes: 0, top: 0 });
+ const seeds = Array.from({ length: 40 }, (_, index) => `seed-${index}`);
+ for (const seed of seeds) {
+ const others = [box(0), box(1), box(2), box(3)];
const skin = robotOf('x', freshSkin(seed, others))!;
- expect(skin.family).toBe('box');
- expect(skin.color).toBeGreaterThan(3);
- expect(skin.color).toBeLessThan(BOX_COLORS);
+ expect(skin.family).not.toBe('box');
+ expect(boxColorOf(skin)).toBeGreaterThan(3);
+ expect(freshSkin(seed, others)).toBe(freshSkin(seed, others));
+ }
+ // Over many seeds, every family but the one worn comes up.
+ const drawn = new Set(seeds.map((seed) => robotOf('x', freshSkin(seed, [box(0)]))!.family));
+ expect([...drawn].sort()).toEqual(['bubble', 'capsule', 'jelly', 'retro']);
+ // Every family worn: any family again, a box with an accessory on its lid.
+ const crowd: Robot[] = ROBOT_FAMILIES.map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 }));
+ const boxes = seeds.map((seed) => robotOf('x', freshSkin(seed, crowd))!).filter((skin) => skin.family === 'box');
+ expect(boxes.length).toBeGreaterThan(0);
+ for (const skin of boxes) {
+ expect(skin.color).toBeGreaterThan(0);
expect(skin.top).toBeGreaterThan(0);
expect(skin.top).toBeLessThan(ROBOT_PARTS.box.top);
- expect(freshSkin(seed, others)).toBe(freshSkin(seed, others));
}
- const all = Array.from({ length: BOX_COLORS }, (_, color) => worn(color));
- expect(robotOf('x', freshSkin('crowded', all))!.color).toBeGreaterThan(0);
- const lastFree = all.filter((robot) => robot.color !== 6);
- expect(robotOf('x', freshSkin('one-left', lastFree))!.color).toBe(6);
+ // Every colour worn: still never the classic body.
+ const all = [...crowd, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color))];
+ for (const seed of seeds) expect(boxColorOf(robotOf('x', freshSkin(seed, all))!)).toBeGreaterThan(0);
+ // One box colour left: a jelly takes it, as its own colour one below.
+ const jellies: Robot[] = ['box', 'bubble', 'capsule', 'retro'].map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 }) as Robot);
+ const lastFree = [...jellies, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color)).filter((robot) => robot.color !== 6)];
+ const last = robotOf('x', freshSkin('one-left', lastFree))!;
+ expect(last).toMatchObject({ family: 'jelly', color: 5 });
});
// ---------------------------------------------------------------------------
diff --git a/src/lib/companion/companion.svelte.ts b/src/lib/companion/companion.svelte.ts
index ba046d6..a260aba 100644
--- a/src/lib/companion/companion.svelte.ts
+++ b/src/lib/companion/companion.svelte.ts
@@ -202,7 +202,7 @@ export class Companion {
readonly senses: Senses;
// With no agent yet, a lone character stands, and says so when asked. The
- // first one wears the mascot chosen in Settings, else its own box.
+ // first one wears the mascot chosen in Settings, else its own robot.
readonly members = $derived.by(() =>
this.crew.members.length > 0
? this.crew.members.map((agent, index) => ({ id: agent.id, name: agent.name, skin: (index === 0 && this.prefs.mascot) || skinOf(agent), talk: this.talkOf(agent.id) }))
diff --git a/src/lib/companion/crew.ts b/src/lib/companion/crew.ts
index 2ee78bc..d9b5a71 100644
--- a/src/lib/companion/crew.ts
+++ b/src/lib/companion/crew.ts
@@ -5,34 +5,46 @@
* to it. Pure, so it is tested without a core.
*/
import type { AgentConversationMessage, AgentMemory, AgentProfile, AgentSave, AgentScope, AgentsSnapshot, AgentWork } from '@boite/contracts';
-import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots';
+import { boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, robotOf, seededRobot, type Robot } from '../robots';
import type { Brain } from './brain';
import { namesFact, normalized } from './memory';
-import { toBox, type BoxRobot } from './skin';
/** The name of the agent the companion makes for itself the first time. */
export const FIRST_AGENT_NAME = 'B.O.T.S';
/** The classic companion, as an avatar: the first agent keeps the look the companion always had. */
export const CLASSIC_SKIN = encodeRobot({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 });
-/** The box an agent stands as: its own robot made a box, keeping its colour, or the box its id draws. */
-export function skinOf(profile: Pick): BoxRobot {
- return toBox(robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box'));
+/** The robot an agent stands as: the one the Agents page draws for it, of any family, or the box its id draws when its picture is text. */
+export function skinOf(profile: Pick): Robot {
+ return robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box');
+}
+
+/** A number from 0 to n - 1, the same for a seed on every client. */
+function pickFor(seed: string, n: number): number {
+ let hash = 2166136261;
+ for (const char of seed) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619);
+ return (hash >>> 0) % n;
}
/**
- * A look to tell an agent apart from `others`, as an avatar: the box `seed`
- * draws, on a body colour none of them wears while one is left (never the
- * classic body, which the first agent keeps), with an accessory on its lid.
+ * A look to tell an agent apart from `others`, as an avatar: a robot of a
+ * family none of them is while one is left, any of the five otherwise, drawn
+ * from `seed`. Its colour, seen as a box's (`boxColorOf`), is one none of
+ * them wears while one is left, and never the classic body, which the first
+ * agent keeps; a box also gets an accessory on its lid.
*/
export function freshSkin(seed: string, others: Robot[]): string {
- const drawn = seededRobot(seed, 'box');
- const worn = new Set(others.map((robot) => robot.color));
- const colors = Array.from({ length: BOX_COLORS - 1 }, (_, index) => index + 1);
- const free = colors.filter((color) => !worn.has(color));
- const pool = free.length > 0 ? free : colors;
- const top = drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top;
- return encodeRobot({ ...drawn, color: pool[drawn.color % pool.length]!, top });
+ const taken = new Set(others.map((robot) => robot.family));
+ const families = ROBOT_FAMILIES.filter((family) => !taken.has(family));
+ const pool = families.length > 0 ? families : ROBOT_FAMILIES;
+ const family = pool[pickFor(seed, pool.length)]!;
+ const drawn = seededRobot(seed, family);
+ const worn = new Set(others.map(boxColorOf));
+ const colors = Array.from({ length: robotColors(family) }, (_, color) => color).filter((color) => boxColorOf({ family, color }) !== 0);
+ const free = colors.filter((color) => !worn.has(boxColorOf({ family, color })));
+ const choice = free.length > 0 ? free : colors;
+ const top = family === 'box' && drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top;
+ return encodeRobot({ ...drawn, color: choice[drawn.color % choice.length]!, top });
}
/** The collaboration tools a companion agent gets, as the Agents page gives a new agent. */
diff --git a/src/lib/companion/prefs.ts b/src/lib/companion/prefs.ts
index 8f2807c..00a290a 100644
--- a/src/lib/companion/prefs.ts
+++ b/src/lib/companion/prefs.ts
@@ -10,7 +10,7 @@
* first one leading (`crew.svelte.ts`). The brain fields are what a companion
* agent made from here runs on; each agent then keeps its own.
*/
-import { encodeRobot, parseRobot, type Robot, type RobotFamily } from '../robots';
+import { encodeRobot, parseRobot, ROBOT_FAMILIES, type Robot, type RobotFamily } from '../robots';
/** Along the top of the screen, or `free`: where the user dropped the character. */
export type CompanionAnchor = 'left' | 'center' | 'right' | 'free';
@@ -25,9 +25,9 @@ export type PillRest = 'live' | 'quota' | 'music' | 'timer' | 'robot';
export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const satisfies readonly PillRest[];
/** The character's centre, in fractions of the screen's work area. */
export interface CompanionSpot { x: number; y: number }
-/** The families the island's mascot may come from: Boîte, the box, or a Jelly. */
-export const MASCOT_FAMILIES = ['box', 'jelly'] as const satisfies readonly RobotFamily[];
-export type Mascot = Robot & { family: (typeof MASCOT_FAMILIES)[number] };
+/** The families the island's mascot may come from: every robot Boite draws, and the box. */
+export const MASCOT_FAMILIES: readonly RobotFamily[] = ROBOT_FAMILIES;
+export type Mascot = Robot;
/** The shortcuts Settings offers, in the shell's notation (`platform/desktop.rs`). */
export const COMPANION_HOTKEYS = ['Alt+Shift+Space', 'Ctrl+Alt+Space', 'Ctrl+Shift+Space', 'Ctrl+Alt+B'] as const;
@@ -41,7 +41,7 @@ export interface CompanionPrefs {
openOnHover: boolean;
/** What the pill shows at rest (`IslandBar.svelte`). */
pillRest: PillRest;
- /** The island's character, the first agent's on the desktop; null is that agent's own box (Boîte by default). */
+ /** The island's character, the first agent's on the desktop; null is that agent's own robot (Boîte, the classic box, by default). */
mascot: Mascot | null;
anchor: CompanionAnchor;
/** Where the character was dropped, with `anchor: 'free'`. */
@@ -138,11 +138,11 @@ function spotOf(value: unknown): CompanionSpot | null {
return left === null || top === null ? null : { x: left, y: top };
}
-/** A box or a jelly, each part in its range; anything else is no mascot. */
+/** A robot of a known family, each part in its range; anything else is no mascot. */
function mascotOf(value: unknown): Mascot | null {
if (!value || typeof value !== 'object') return null;
const { family, shape, color, eyes, top } = value as Record;
- if (family !== 'box' && family !== 'jelly') return null;
+ if (!(MASCOT_FAMILIES as readonly unknown[]).includes(family)) return null;
const parts = [shape, color, eyes, top];
if (!parts.every((part) => typeof part === 'number' && Number.isInteger(part) && part >= 0)) return null;
const robot = parseRobot(encodeRobot({ family, shape, color, eyes, top } as Robot));
diff --git a/src/lib/companion/skin.ts b/src/lib/companion/skin.ts
index 9b58439..f64161a 100644
--- a/src/lib/companion/skin.ts
+++ b/src/lib/companion/skin.ts
@@ -1,13 +1,14 @@
/*
- * The companion's skin: the colours and the accessory a box robot gives the
+ * The box's skin: the colours and the accessory a box robot gives the
* companion box, so several agents standing side by side as companions can be
- * told apart. The skin is the agent's robot (`lib/robots.ts`, family `box`),
- * kept in its profile's `avatar`, so the Agents page draws the same look.
+ * told apart. The skin is the agent's robot (`lib/robots.ts`), kept in its
+ * profile's `avatar`, so the Agents page draws the same look; a robot of
+ * another family is drawn by its own character (`lib/companion/character.ts`).
*
* What a caller needs:
* - `seededRobot(seed, 'box')` (robots.ts): the box an id draws.
- * - `isBox(robot)`: whether a robot is a box; `CompanionCharacter` draws the
- * classic companion for anything else, or for no skin at all.
+ * - `isBox(robot)`: whether a robot is a box, which `CompanionCharacter`
+ * paints; no skin at all draws the classic companion.
* - `toBox(robot)`: any robot as a box that keeps its colour and accessory slot.
* - `boxPaint(robot)`: the CSS colours a box wears, for anything drawing it.
*
diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts
index ea21c20..b9d5abf 100644
--- a/src/lib/island/pose.ts
+++ b/src/lib/island/pose.ts
@@ -49,6 +49,8 @@
* (one paused, one whose project is gone) for Settings;
* - `bar=custom`: the bar arranged by hand: two tabs left out, one renamed,
* two apps and a link added, the pin taken away (`bar.ts`);
+ * - `skin=box|jelly|bubble|capsule|retro`: the lead robot wears a robot of
+ * that family as its mascot (`SKIN_POSES`);
* - `edit=1`: with `pose=open` or `pose=deck`, the bar in its edit mode, what
* it can take under it (`deck/BarPalette.svelte`);
* - `pose=decktab&tab=apps|clipboard|wallpaper|system`: one of the deck's
@@ -63,6 +65,7 @@ import { activeLocale } from '@boite/ui/lib/i18n.svelte';
import type { Companion } from '../companion/companion.svelte';
import { nextAt, type Repeat } from '../companion/missions';
import { PILL_RESTS, writeCompanionPrefs, type PillRest } from '../companion/prefs';
+import type { Robot, RobotFamily } from '../robots';
import { DEMO_COMMAND, DEMO_THREAD } from '../fake-client/allow-demo';
import { addApp, addLink, DEFAULT_BAR, removeItem, removeTool, renameItem, writeBar, writeBarEdit, type BarLayout } from './bar';
import { FAKE_APPS } from '../windows/fake';
@@ -194,6 +197,15 @@ function poseAway(): void {
};
}
+/** The mascot a `skin=` pose puts on the lead robot: one per family, each wearing something on top. */
+const SKIN_POSES: Record = {
+ box: { family: 'box', shape: 6, color: 4, eyes: 2, top: 3 },
+ jelly: { family: 'jelly', shape: 0, color: 3, eyes: 0, top: 3 },
+ bubble: { family: 'bubble', shape: 0, color: 5, eyes: 2, top: 1 },
+ capsule: { family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 },
+ retro: { family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 },
+};
+
const WALLPAPER = 'html[data-companion] body { background: linear-gradient(160deg, #34466e, #7d6797 55%, #d49c78) !important; }';
async function until(ready: () => boolean, tries = 60): Promise {
@@ -244,6 +256,8 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param
if (params.get('todo') === '1') poseTodo(companion);
const mission = params.get('mission');
if (mission === 'ask' || mission === 'list') poseMissions(companion, mission);
+ const skin = params.get('skin');
+ if (skin && Object.hasOwn(SKIN_POSES, skin)) companion.prefs = { ...companion.prefs, mascot: SKIN_POSES[skin as RobotFamily] };
const rest = params.get('rest') as PillRest | null;
if (rest && PILL_RESTS.includes(rest)) companion.prefs = { ...companion.prefs, pillRest: rest };
if (params.get('rule') === '1') {
diff --git a/src/main.ts b/src/main.ts
index 20ef2d4..4bab1f6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,7 @@
/*
* The page's entry: the companion's transparent window, or Bots's settings
- * window on `?view=settings`. The language comes first, so nothing is drawn
+ * window on `?view=settings` (the dev bundle adds `?view=mascots`, every
+ * companion family side by side). The language comes first, so nothing is drawn
* in English and swapped a tick later.
*/
import '@boite/ui/app.css';
@@ -10,11 +11,18 @@ import { startLocale } from '@boite/ui/lib/i18n.svelte';
import { followTrayLocale } from './lib/tray.svelte';
const target = document.getElementById('app')!;
-const settings = new URLSearchParams(location.search).get('view') === 'settings';
+const view = new URLSearchParams(location.search).get('view');
+const settings = view === 'settings';
+// Every companion family side by side, for captures: the dev bundle only.
+const mascots = import.meta.env.DEV && view === 'mascots';
document.documentElement.dataset.view = settings ? 'settings' : 'bots';
void startLocale().then(async () => {
- const page = settings ? (await import('./SettingsApp.svelte')).default : (await import('./App.svelte')).default;
+ const page = mascots
+ ? (await import('./MascotGallery.svelte')).default
+ : settings
+ ? (await import('./SettingsApp.svelte')).default
+ : (await import('./App.svelte')).default;
mount(page, { target });
- if (!settings) followTrayLocale();
+ if (!settings && !mascots) followTrayLocale();
});