Skip to content
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ There are twelve moods: `chill`, `flipping`, `writing`, `checking`, `fishing`, `

Pass a `skin` to dress him differently — start from `defaultSkin` and change its colours, lights or allowed props. Pass `patterns` to replace the motion for any mood: each is an `AnimationPattern` whose `target` returns the pose channels for a moment in time, and the engine springs and crossfades them for you. `createMotion`, `createMoodMachine` and `defaultPatterns` are exported if you want to drive the motion yourself.

Two looks are options on the skin. `cheeks` shows his rosy cheeks and is `false` by default. `hat` is `'none'` (the default), `'peakCap'`, `'topHat'` or `'party'`, with its colours for the caps in `colours.hat` and `colours.hatBand`. The party mood brings its own paper hat, which takes priority over a cap or top hat while it shows, and the chosen hat returns afterwards; choose `'party'` and the paper hat stays on in every mood, never doubled. The SVG fallback follows both options when you pass the same `skin`.

He follows the system reduced-motion setting and holds a still pose, and `setReducedMotion(on)` or the `reducedMotion` option overrides it. He also pauses whenever he is off screen or the tab is hidden.

When WebGL is unavailable, use the SVG companion from the `/fallback` entry, which does not import `three`:
Expand All @@ -53,7 +55,7 @@ const jax = await createJax(host).catch((error) => {
});
```

Run `bun run demo` for a page with every mood, two skins and a smoothness readout.
Run `bun run demo` for a page with every mood, two skins, cheeks and hat switches and a smoothness readout.

## Licence

Expand Down
9 changes: 8 additions & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
flex-wrap: wrap;
gap: 8px;
}
button {
button,
select {
font: inherit;
color: var(--text);
background: var(--panel);
Expand All @@ -57,6 +58,12 @@
padding: 6px 14px;
cursor: pointer;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
button:hover {
border-color: var(--amber);
}
Expand Down
39 changes: 37 additions & 2 deletions demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import {
createJax,
defaultSkin,
HATS,
JaxUnavailableError,
MOODS,
type Hat,
type Jax,
type Mood,
type Skin
Expand All @@ -32,7 +34,9 @@ const state = {
fallback: false,
reduced: false,
cycling: false,
mood: 'chill' as Mood
mood: 'chill' as Mood,
cheeks: false,
hat: 'none' as Hat
};
let instances: Jax[] = [];
let heroJax: Jax | undefined;
Expand All @@ -50,7 +54,8 @@ function button(label: string, onClick: () => void): HTMLButtonElement {
}

async function spawn(host: HTMLElement, size: number): Promise<Jax> {
const options = { size, skin: state.skin, reducedMotion: state.reduced };
const skin: Skin = { ...state.skin, cheeks: state.cheeks, hat: state.hat };
const options = { size, skin, reducedMotion: state.reduced };
if (!state.fallback) {
try {
return await createJax(host, options);
Expand Down Expand Up @@ -127,6 +132,36 @@ const skinButton = button('Skin: afterglow', () => {
void mountAll();
});
$('controls').append(skinButton);
const HAT_LABELS: Record<Hat, string> = {
none: 'none',
peakCap: 'peak cap',
topHat: 'top hat',
party: 'party hat'
};
const cheeksButton = button('Cheeks', () => {
state.cheeks = !state.cheeks;
cheeksButton.setAttribute('aria-pressed', String(state.cheeks));
void mountAll();
});
cheeksButton.id = 'cheeks';
cheeksButton.setAttribute('aria-pressed', 'false');
$('controls').append(cheeksButton);
const hatLabel = document.createElement('label');
hatLabel.textContent = 'Hat ';
const hatSelect = document.createElement('select');
hatSelect.id = 'hat';
for (const hat of HATS) {
const option = document.createElement('option');
option.value = hat;
option.textContent = HAT_LABELS[hat];
hatSelect.append(option);
}
hatSelect.addEventListener('change', () => {
state.hat = hatSelect.value as Hat;
void mountAll();
});
hatLabel.append(hatSelect);
$('controls').append(hatLabel);
toggle('Force fallback', 'fallback', () => void mountAll());
toggle('Reduced motion', 'reduced', () => {
for (const jax of instances) jax.setReducedMotion(state.reduced);
Expand Down
4 changes: 3 additions & 1 deletion src/fallback/fallback.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Class-name contract: every class starts with `lq-jax-`. The root `<svg>` carries
// `lq-jax-fallback`, a `data-mood` attribute and, when motion is off, `lq-jax-still`. Parts are
// `lq-jax-body`, `lq-jax-head`, `lq-jax-eyes`, `lq-jax-arm-l`, `lq-jax-arm-r` and `lq-jax-tail`.
// Props are `lq-jax-prop-<name>` and show only for the moods that use them.
// Props are `lq-jax-prop-<name>` and show only for the moods that use them. The optional cheeks are `lq-jax-cheek` and the worn hat is `lq-jax-worn-hat`, hidden for the party mood.

export const FALLBACK_STYLE_ID = 'lq-jax-fallback-styles';

Expand Down Expand Up @@ -35,6 +35,8 @@ export const FALLBACK_CSS = `
.lq-jax-fallback{display:block;overflow:visible}
.lq-jax-fallback .lq-jax-prop{opacity:0;transition:opacity .4s ease}
${propRules}
.lq-jax-fallback .lq-jax-worn-hat{transition:opacity .4s ease}
.lq-jax-fallback[data-mood='party'] .lq-jax-worn-hat{opacity:0}
.lq-jax-fallback[data-mood='nap'] .lq-jax-eyes{transform:scaleY(.12)}
.lq-jax-fallback .lq-jax-eyes,.lq-jax-fallback .lq-jax-head,.lq-jax-fallback .lq-jax-arm-r,
.lq-jax-fallback .lq-jax-arm-l,.lq-jax-fallback .lq-jax-body,.lq-jax-fallback .lq-jax-prop-confetti,
Expand Down
44 changes: 43 additions & 1 deletion src/fallback/fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MOODS } from '../types.js';
import { MOODS, type Skin } from '../types.js';
import { defaultSkin } from '../model/defaultSkin.js';
import { createFallbackJax } from './index.js';
import { FALLBACK_STYLE_ID } from './fallback.css.js';

Expand All @@ -33,6 +34,47 @@ describe('fallback Jax', () => {
}
});

it('draws cheeks and a hat only when the skin asks for them', () => {
const draw = (skin?: Partial<Skin>) => {
const host = document.createElement('div');
document.body.appendChild(host);
createFallbackJax(host, skin ? { skin: { ...defaultSkin, ...skin } } : {});
return host;
};
const plain = draw();
expect(plain.querySelectorAll('.lq-jax-cheek')).toHaveLength(0);
expect(plain.querySelectorAll('.lq-jax-worn-hat')).toHaveLength(0);
const dressed = draw({ cheeks: true, hat: 'peakCap' });
expect(dressed.querySelectorAll('.lq-jax-cheek')).toHaveLength(2);
expect(dressed.querySelectorAll('.lq-jax-worn-hat.lq-jax-hat-peakCap')).toHaveLength(1);
const tall = draw({ hat: 'topHat' });
expect(tall.querySelectorAll('.lq-jax-hat-topHat')).toHaveLength(1);
for (const host of [plain, dressed, tall]) {
expect(host.querySelectorAll('[style]')).toHaveLength(0);
}
expect(document.getElementById(FALLBACK_STYLE_ID)!.textContent).toContain(
"[data-mood='party'] .lq-jax-worn-hat"
);
});

it('draws a chosen party hat once in any mood and the mood adds no second one', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const jax = createFallbackJax(host, { skin: { ...defaultSkin, hat: 'party' } });
for (const mood of MOODS) {
jax.setMood(mood);
expect(host.querySelectorAll('.lq-jax-party-hat')).toHaveLength(1);
expect(host.querySelectorAll('.lq-jax-prop-hat')).toHaveLength(0);
expect(host.querySelectorAll('.lq-jax-worn-hat')).toHaveLength(0);
}
expect(host.querySelectorAll('[style]')).toHaveLength(0);
const other = document.createElement('div');
document.body.appendChild(other);
createFallbackJax(other, { skin: { ...defaultSkin, hat: 'topHat' } });
expect(other.querySelectorAll('.lq-jax-party-hat')).toHaveLength(0);
expect(other.querySelectorAll('.lq-jax-prop-hat')).toHaveLength(1);
});

it('injects one scoped style element however many Jaxes there are', () => {
const a = document.createElement('div');
const b = document.createElement('div');
Expand Down
91 changes: 84 additions & 7 deletions src/fallback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ function buildSvg(doc: Document, options: JaxOptions): SVGElement {
const pages = hex(colours?.pages, '#fbfaf6');
const accent = hex(colours?.accent, '#ffb454');
const prop = hex(colours?.prop, '#6ecddc');
const hatColour = hex(colours?.hat, '#3a3550');
const hatBand = hex(colours?.hatBand, accent);
const cheeks = options.skin?.cheeks === true;
const size = options.size ?? 120;

const prop_ = (name: string, ...children: Element[]) =>
Expand Down Expand Up @@ -129,20 +132,45 @@ function buildSvg(doc: Document, options: JaxOptions): SVGElement {
el(doc, 'ellipse', { cx: 100, cy: 74, rx: 48, ry: 40, fill: fur }),
el(doc, 'ellipse', { cx: 100, cy: 86, rx: 24, ry: 16, fill: cream }),
el(doc, 'ellipse', { cx: 100, cy: 70, rx: 7, ry: 5, fill: nose }),
el(doc, 'ellipse', { cx: 70, cy: 82, rx: 6, ry: 4, fill: blush, opacity: 0.8 }),
el(doc, 'ellipse', { cx: 130, cy: 82, rx: 6, ry: 4, fill: blush, opacity: 0.8 }),
...(cheeks
? [
el(doc, 'ellipse', {
class: 'lq-jax-cheek',
cx: 70,
cy: 82,
rx: 6,
ry: 4,
fill: blush,
opacity: 0.8
}),
el(doc, 'ellipse', {
class: 'lq-jax-cheek',
cx: 130,
cy: 82,
rx: 6,
ry: 4,
fill: blush,
opacity: 0.8
})
]
: []),
el(
doc,
'g',
{ class: 'lq-jax-eyes' },
el(doc, 'ellipse', { cx: 82, cy: 62, rx: 5, ry: 6, fill: eye }),
el(doc, 'ellipse', { cx: 118, cy: 62, rx: 5, ry: 6, fill: eye })
),
prop_(
'hat',
el(doc, 'polygon', { points: '100,6 122,40 78,40', fill: book }),
el(doc, 'circle', { cx: 100, cy: 6, r: 5, fill: accent })
)
...wornHat(doc, options.skin?.hat, hatColour, hatBand, book, accent),
...(options.skin?.hat === 'party'
? []
: [
prop_(
'hat',
el(doc, 'polygon', { points: '100,6 122,40 78,40', fill: book }),
el(doc, 'circle', { cx: 100, cy: 6, r: 5, fill: accent })
)
])
),
prop_(
'confetti',
Expand All @@ -163,6 +191,55 @@ function buildSvg(doc: Document, options: JaxOptions): SVGElement {
return svg;
}

/** The chosen hat in flat SVG. The party mood hides it with CSS, as the party hat takes over; a chosen party hat stays on. */
function wornHat(
doc: Document,
hat: string | undefined,
main: string,
band: string,
book: string,
accent: string
): SVGElement[] {
if (hat === 'party') {
// Always on: it has no `lq-jax-worn-hat` class, so the party mood never hides it.
return [
el(
doc,
'g',
{ class: 'lq-jax-party-hat' },
el(doc, 'polygon', { points: '100,6 122,40 78,40', fill: book }),
el(doc, 'circle', { cx: 100, cy: 6, r: 5, fill: accent })
)
];
}
if (hat === 'peakCap') {
return [
el(
doc,
'g',
{ class: 'lq-jax-worn-hat lq-jax-hat-peakCap' },
el(doc, 'path', { d: 'M73 46 A27 24 0 0 1 127 46 Z', fill: main }),
el(doc, 'rect', { x: 73, y: 41, width: 54, height: 6, fill: band }),
el(doc, 'ellipse', { cx: 100, cy: 49, rx: 30, ry: 6, fill: main }),
el(doc, 'circle', { cx: 100, cy: 23, r: 3.5, fill: band })
)
];
}
if (hat === 'topHat') {
return [
el(
doc,
'g',
{ class: 'lq-jax-worn-hat lq-jax-hat-topHat' },
el(doc, 'ellipse', { cx: 100, cy: 38, rx: 31, ry: 6, fill: main }),
el(doc, 'rect', { x: 80, y: 4, width: 40, height: 35, rx: 3, fill: main }),
el(doc, 'rect', { x: 80, y: 26, width: 40, height: 8, fill: band })
)
];
}
return [];
}

function textNode(doc: Document, text: string): Element {
// A bare text child is wrapped so `el` can take it with the other children.
return doc.createTextNode(text) as unknown as Element;
Expand Down
4 changes: 4 additions & 0 deletions src/model/defaultSkin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ export const defaultSkin: Skin = {
eye: 0x1a1210,
shine: 0xffffff,
blush: 0xe25d75,
hat: 0x3a3550,
hatBand: 0xe25d75,
book: 0xe25d75,
pages: 0xfbfaf6,
accent: 0xffb454,
prop: 0x6ecddc,
water: 0x6ecddc
},
cheeks: false,
hat: 'none',
lights: { sky: 0xfff1e0, ground: 0x3a2a40, key: 0xffe2c0, rim: 0xa78bfa }
};
Loading