kluuu mascot — integration guide
Everything a developer needs to render the mascot, set moods from app events, and keep the spec up to date without shipping a release.
- Base URL:
https://kluuu.pages.dev(configurable — see Changing the host) - Schema major:
1(in the URL path) - Spec version:
1.0.0(semver, in the payload)
1. The model in one paragraph
The mascot is a pure function of about sixty numbers. A mood is a named, sparse set of overrides on those numbers. A transition is a bank of springs moving from one set to another. Nothing is a pre-authored animation, which is why a mood change, an idle breath and a blink can all be in flight at once without fighting each other. Your app sets a mood id; the runtime does the rest.
You never send frames over the wire. You send numbers.
2. Install
npm i @kluuu/mascot
ESM-only. Node 18+ / any modern bundler. React and Vue are optional peers — install neither if you use the custom element.
Entry points
| Import | What you get |
|---|---|
@kluuu/mascot |
createMascot, the controller, scene builders, the full spec |
@kluuu/mascot/define |
Registers <kluuu-mascot> (side effect only) |
@kluuu/mascot/element |
The element class, unregistered |
@kluuu/mascot/react |
<KluuuMascot> for React 18+ |
@kluuu/mascot/vue |
<KluuuMascot> for Vue 3.4+ |
@kluuu/mascot/spec |
Moods, rig descriptors, motion constants — no DOM |
@kluuu/mascot/client |
The OTA spec client |
@kluuu/mascot/spec is DOM-free on purpose: server code that needs to know
which moods exist should not pull in a renderer.
3. Render it
Custom element (framework-agnostic)
<script type="module">
import '@kluuu/mascot/define'
</script>
<kluuu-mascot mood="happy" size="160" idle="subtle"></kluuu-mascot>
| Attribute | Values | Default | Notes |
|---|---|---|---|
mood |
any mood id | idle |
Springs across; never cuts |
size |
number or CSS length | — | 160 and 160px both work |
idle |
off | subtle | lively |
subtle |
Ambient motion level |
speed |
0.25–2 |
1 |
Softens every spring; does not stretch durations |
seed |
integer | position in DOM | Deterministic per-instance wobble |
colour-mode |
mood | brand |
mood |
brand keeps every mood in the registered mint |
paused |
boolean | absent | Freezes without unmounting |
reduced-motion |
boolean | absent | Forced on top of the OS setting |
Fires moodchange (a CustomEvent with detail.mood) when a transition settles.
React
import { KluuuMascot } from '@kluuu/mascot/react'
<KluuuMascot mood="celebrating" size={160} idle="subtle" onMoodChange={(id) => {}} />
Renders the first frame during the normal render pass, so SSR and hydration match. After mount the shared ticker drives the DOM imperatively — it does not re-render your tree sixty times a second.
Vue / Nuxt
<script setup lang="ts">
import { KluuuMascot } from '@kluuu/mascot/vue'
</script>
<template>
<KluuuMascot :mood="mood" :size="160" idle="subtle" />
</template>
Wrap in <ClientOnly> only if you also drive it from a browser-only store; the
component itself is SSR-safe.
Imperative
import { createMascot } from '@kluuu/mascot'
const mascot = createMascot(el, { mood: 'idle', seed: 7 })
mascot.setMood('onFire')
mascot.setParams({ mouthCurve: 0.5 }) // drive the rig directly
mascot.destroy()
4. Drive it from app events
Do not name animations in product code. Name events, and let the mapping live in the spec:
import { EVENT_MOOD } from '@kluuu/mascot/spec'
mascot.setMood(EVENT_MOOD.answer_correct) // 'happy'
mascot.setMood(EVENT_MOOD.session_complete_perfect) // 'perfectScore'
This matters because the right mood for an event changes over time. If your code
says setMood('happy'), changing that decision is a code release; if it says
EVENT_MOOD.answer_correct, it is a spec update.
Full event map: answer_correct, answer_correct_streak, answer_wrong,
answer_wrong_repeated, quiz_generating, notes_uploading,
session_complete_high, session_complete_perfect, session_complete_low,
exam_started, streak_broken, returning_user, idle_short, idle_long,
error_network, error_fatal, empty_state.
5. The spec API
GET /spec/v1/index.json pointer, ~1 KB
GET /spec/v1/releases/<version>/bundle.json immutable release
GET /spec/v1/schema.json JSON Schema
Caching
| Endpoint | Cache-Control |
Revalidation |
|---|---|---|
index.json |
public, max-age=300, stale-while-revalidate=86400 |
strong ETag → 304 |
releases/*/bundle.json |
public, max-age=31536000, immutable |
never changes |
schema.json |
public, max-age=31536000, immutable |
never changes |
An update check that finds nothing new costs one 304 with no body. Send the
ETag you stored:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "<stored-etag>"' \
https://kluuu.pages.dev/spec/v1/index.json
# → 304
index.json
{
"schema": "kluuu.mascot.index/1",
"latest": {
"version": "1.0.0",
"url": "https://kluuu.pages.dev/spec/v1/releases/1.0.0/bundle.json",
"sha256": "0b0733da…"
},
"channels": { "stable": "1.0.0", "next": "1.0.0" }
}
bundle.json
$schema URL of the JSON Schema
spec "kluuu.mascot.spec/v1"
version "1.0.0"
channel "v1"
geometry 1024×1024 artboard: lobes, cusps, face, mouth, sulci, highlights
rig { params: [descriptors], defaults, enums }
motion { springs, channelSpring, easing, duration, idle, follow, asymmetry, squash }
moods { groups, groupLabels, events, list: [47 moods] }
rig.params are self-describing (id, min, max, default, step,
label, describe). The studio builds its entire control panel from them, and
so can you.
6. Over-the-air updates
The shape
- Embed a baseline in the app bundle. First launch needs no network.
- Check occasionally — once per 24h, jittered per install, skipped on metered connections. Never on every launch.
- Swap atomically after verifying the signature and hash. If the stored spec fails to parse on the next boot, fall back to the embedded one.
import { createSpecClient } from '@kluuu/mascot/client'
import embedded from './kluuu-spec-1.0.0.json'
const client = createSpecClient({
baseUrl: 'https://kluuu.pages.dev',
embedded,
storage: localStorage, // AsyncStorage on RN, UserDefaults on iOS
publicKeys: { k1: '<32-byte base64>' },
})
const mascot = createMascot(el, { mood: 'idle', spec: client.current() })
// Fire-and-forget, off the critical path.
client.checkForUpdate().then((r) => {
if (r.updated) applySpec(mascot, client.current())
})
Why data-only
The payload is JSON, never JavaScript. The client parses and validates it; it never evaluates it. This is not paranoia — it is what makes the mechanism allowed: Apple guideline 3.3.2 permits interpreted data but not downloaded executable code. A new mood ships without a store release; a new renderer does not.
Update policy constants
| Setting | Value |
|---|---|
| Base interval | 24 h |
| Jitter | ±25%, deterministic from install id |
| Index timeout | 6 s |
| Bundle timeout | 30 s |
| Max index size | 64 KB |
| Max bundle size | 2 MB |
| Max index age before forced refresh | 30 days |
Jitter is derived from the install id rather than random so a release does not stampede the origin at the same instant, and so a given install behaves the same way every run.
7. Versioning
Two independent axes. Never conflate them.
schemaMajor— integer, in the URL (/v1/). Changes only on a breaking change./v1/keeps working forever.version— semver, in the payload.
What counts as what
MAJOR (new /v2/, old clients keep using /v1/)
- removing or renaming a mood
id - changing the coordinate system: viewBox extent, origin, y-direction, or the ground pivot
- changing the spring parameterisation or time units
- changing a field's type, or making an optional field required
- removing a value from a closed enum
MINOR (1.4.0 → 1.5.0, same /v1/)
- adding a mood
- adding an optional rig parameter
- adding a prop, an eye glyph, or a named easing
- deprecating a mood while keeping it functional
PATCH (1.4.0 → 1.4.1)
- retuning a spring within its damping band
- a colour change under ΔL 0.02
- re-exported geometry that renders equivalently
A curve tweak that visibly changes the silhouette or the read is a MINOR, not a patch. The test: would a designer call it "a different expression"?
Forward compatibility
Parsers must ignore unknown keys. This is the single rule that makes minor versions safe — without it, adding one mood breaks every deployed client, and you can never ship anything but majors.
Mood ids are permanent
An id appears in app source, in analytics and in saved user state. A misspelled id is deprecated and aliased, never corrected:
{ "id": "celebrating", "$deprecated": "Use 'perfectScore'", "replacedBy": "perfectScore" }
Both ids resolve for at least 6 months or 2 store releases, whichever is longer. Removal happens only at a major.
8. Embeddable bundle
Two files, downloadable from /export:
| File | Size | Contents |
|---|---|---|
kluuu-mascot-<version>.js |
~62 KB | Runtime, ESM, zero imports |
kluuu-spec-<version>.json |
~37 KB | Moods, geometry, motion |
Zero imports is deliberate: a single bare specifier breaks a file:// or
WebView-hosted consumer. Build them with:
cd apps/studio && npm run embed
They are split so mood edits ship as data while the renderer stays fixed — see Why data-only.
9. Native
Both native renderers read the same bundle.json and run the same
spring integrator (semi-implicit Euler, fixed 1/240 s substep, dt clamped to
32 ms). Port the integrator faithfully — if it differs, iOS and web drift apart
within a second and nobody can tell which is correct.
See native/PARITY.md for exactly which visual features are identical,
approximated, or unsupported per platform.
10. Accessibility and performance
- Every instance respects
prefers-reduced-motionand re-checks it at runtime, not just on construction. - Instances pause when scrolled out of view (
IntersectionObserver). - One shared
requestAnimationFrameloop drives every mascot on the page. - The DOM is patched attribute-by-attribute, not re-serialised.
- Give the element a meaningful
labelwhen the mascot carries information; the default is"kluuu mascot".
11. Changing the host
The base URL is a single value:
https://mascot.example.com
Clients take it as a constructor argument, so pointing an app at a staging spec is a config change, not a rebuild of the library.