Docs

Integration guide

Everything needed to put the mascot in an app: install, render, drive it from events, and pin a version.

Rendered from docs/INTEGRATION.md in the repo. Editing that file changes this page.

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.252 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 ETag304
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

  1. Embed a baseline in the app bundle. First launch needs no network.
  2. Check occasionally — once per 24h, jittered per install, skipped on metered connections. Never on every launch.
  3. 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-motion and re-checks it at runtime, not just on construction.
  • Instances pause when scrolled out of view (IntersectionObserver).
  • One shared requestAnimationFrame loop drives every mascot on the page.
  • The DOM is patched attribute-by-attribute, not re-serialised.
  • Give the element a meaningful label when 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.