Skip to contents

This article records changes to the browser engine in inst/www/ (mascot.js, mascot-motion.js, mascot-host.js, mascot-parts.js, mascot-geometry.js, mascot-faces.js, mascot-player.js and mascot.css), newest first. User-facing R functions and arguments are covered in NEWS.md.

mascot (development version)

mascot 0.1.0

mascot-engine 0.1.0; character-format-version 1. The engine is versioned with the package; this is the engine of the first CRAN release.

Summary

  • One drawing path. Every character, built-in or your own, is drawn from a character file in format version 1: parts and geometry, faces, mood channels, motion tracks, detail bands for small sizes and kit effects. A part item can glow in a palette colour, and outlines are painted under the fill so that only their outer half shows. The older drawing code and the bridge that drew Tessa through it are gone (#300, #308, #313, #325, #463).
  • Detail bands. The band comes from the canvas width: micro below 48 pixels, small from 48 to 127 and full from
    1. A band over its limits is refused before anything is painted; the old band stays and the refusal is reported once in the browser console (#303).
  • Deterministic. A frame is worked out from the character and its state only: the engine never reads the clock to draw and never calls Math.random().
  • The browser checks what R checks. It counts drawing work with the same rules and limits as R for each detail band, refuses reserved capabilities and requires a wave and a nod that move, so a character R accepts draws in the browser and the other way round. Accessory part ids cannot collide, and the optional parts of the format (requires, $schema, note and x_ keys) are accepted as in R (#304, #306, #312, #328, #493).
  • The host. It mounts mascots in Shiny apps, saved pages and Quarto or R Markdown pages, keeps updates that arrive before a mascot appears, survives re-rendering, works in nested modules, shows connection loss and starts again when a page reconnects into a new Shiny session. Shiny keeps one complete state per mount, so a used gesture or a dismissed bubble stays that way when an element is replaced (#70, #133, #305, #426).
  • Panels and clicks. The speech bubble, the menu and the action tooltip share one panel look; the mascot looks towards an open bubble or menu. A click does nothing, runs one action or opens a menu; only the action sends input$<id>_click. Menus open on the side with room, one at a time, and can be disabled; the space kept for the bubble lets clicks through (#225, #233, #241, #257, #349, #350, #506).
  • Placement. Floating corners, inline placement, minimising with a sharp redraw on restore (reported to Shiny as input$<id>_minimised), a default size capped at 96 pixels on narrow screens with small_screen = "auto", and dragging that snaps to the nearest corner or (experimental) stays where it is dropped (#133, #220, #259, #275, #513).
  • Motion. Gestures play from tracks with five easing curves, interruption, reduced motion and a five-second deadline. A swap to another character fades the old one out and the new one in, and is instant with reduced motion. Idle, mood change, gestures, swaps, minimising, restoring and panel messages each have their own switch (#239, #299, #307).
  • Character files stay as they are. The tests keep frozen copies of the 0.1.0 character files and face kits (the shared face drawings that characters name) with their checksums. A test fails when a frozen file changes or goes, every frozen character must keep loading, and a shipped face kit can change only on purpose, through a copy for the next version and a NEWS line (#467, #554).
  • Light on the page. Layout reads are batched, mascots scrolled off-screen stop drawing, and a page carries each built-in character’s data once (#295, #497).
  • Palettes. R resolves a named palette; the host applies palette colours first, then role overrides, then CSS. A swap keeps the palette when the incoming character’s own colours carry its id (#336, #358).
  • Styling and version. Public classes, --mascot-* custom properties and the .mascot-bubble-region class are documented in ?mascot_styling; everything else is private. window.mascot.version gives the version on the page, and a page that loads a second copy of the engine in another version keeps the first and says so in the browser console (#260, #464, #558).

Development entries

What changed while 0.1.0 was developed, by topic

The visual tool some of these changes mention (tools/visual/) is no longer in the repository.

Drawing and the character format

  • Parts and geometry (mascot-parts.js, mascot-geometry.js). mascot.parts works out hierarchical 2D transforms per frame: reserved ids root, face and effects, unique snake_case ids, one accessory per slot, acyclic part and anchor order, keep_upright and no_flip, channel-driven variant switches (part.switch), band hiding, layer sorting. Lookups of data-named keys go through own() (Object.hasOwn), so a key cannot reach Object.prototype. mascot.geometry compiles path, circle, arc, ellipse, rect, round_rect, polyline, polygon, hexagon and ref shapes into cached Path2D objects, with linear and radial gradients (2 to 6 stops), two outlines (outline, outline.second, per-band overrides), detail strokes and cross-part clipping (style.clip, guarded against singular matrices). Each draw returns work counters for the budget checks.
  • Player (mascot-player.js). mascot.runtime compiles a character once (compile(), a weak cache that keeps failures) and plays it. mount() returns the controller the host uses (update, gesture, setMood, seek, redraw, setBand, pause, play, holdVisibility, destroy, current, onChange). A frame is tracks.frameAt(), then the mood’s channels (the reduced ones under reduced motion), then held look, then parts.frame(), the ground shadow, the part layers, the face and each kit effect in the layer order the data declares. Colours are the character’s roles, the user’s overrides, then the mood’s recolour; an explicit user colour wins. A patch is validated whole before any state or pixel changes, and a frame is resolved and checked before the canvas is cleared (#307).
  • Mood channels, recolour and switches (#302). Channel names are checked against the rig (root.*, face.*, effects.*, part transforms). Values merge in the order part resting defaults, named pose, mood channels, reduced channels. idle, thinking, happy, worried and celebrating are required, and fallback chains end at them (swapMood()). recolour and reduced.recolour redirect colour roles before paints and gradients; a cycle fails with invalid_colour. Item overrides set fill, stroke and alpha; a switch: { channel, above, variant } applies strictly above its threshold, and anchors follow a changed variant.
  • Face kits and kit effects. mascot-faces.js paints generic face kits; an omitted glow alpha is 1. The sweat drop and sparkle star use mascot.parts.sweatProgress() and mascot.parts.sparklePulse(), so the tests of those two functions test the drawing’s maths. mascot.faces.effects() checks the effect value first (code input_type at $.effects.<id>) (#465).
  • Glow (#300). One item of a part can glow in a palette colour. glowPass() draws one blurred pass per fill or stroke on a scratch surface sized to that pass, so output never depends on an earlier pass; backingBlur() turns design units into backing pixels (blur * cssWidth / 300 * dpr). Glow is off in the micro band and at alpha 0. A fill-and-stroke emitter counts two blurred passes.
  • Outlines (#325). An item with outline: true is painted second outline, outline, fill, then its own stroke, so only the outer half of an outline shows, as in the drawings. Sela, Neer (outline 3 to 4.5, second 5 to 6.5) and Knurl (2.5 to 3.75, 5 to 6.25) have wider outlines to keep the visible weight; the micro outline of Knurl is unchanged.
  • Detail bands (#303). bandFor(width) classifies the untransformed CSS width: micro below 48, small from 48 to 127.999, full from 128. The host mounts at the band the canvas measures and calls setBand() only when the band changes. A band over its limits is refused before anything paints: the old band, pixels and state stay, and the refusal is reported once per band through console.warn. A canvas with no layout width mounts at full. The canvas is redrawn once after a resize.
  • Tessa (#307, #308, #313). A format-v1 character mounts in the host and runs end to end: it draws through the modules, moves along its tracks, swaps, restores after a remount and shows its own status, label, gaze, theme and band. Tessa moved onto this path from the older drawing code; her public colour role names are kept (mascot_colour_roles("tessa")) and her restore button reads “Restore Tessa, ”. A temporary bridge (mascot-tessa-adapter.js, switched on by mascot.internal.useCharacterAdapter) drew her through the modules in between; it and its tests went in #313, and inst/characters/tessa.json left the package.
  • Gaze. Held look is face.look_x and face.look_y only. compile() records whether a face can look (a kit with an elliptical pupil); a faceless character or a kit of arcs, paths and dots has no gaze channels, and the host sends none.

Checks shared with R

  • Same counts as R (#304). mascot.runtime.bound() counts all nine limits of the format for each band: parts and one accessory per slot, the face kit, kit effects (declared and started by a gesture), the ground shadow, mood and reduced-motion item changes, clips and gradients. Glow is counted on the box a shape covers after ref and transform moves, on the style a shared shape resolves to, and on the largest of an item’s mood alternatives; R (R/character-work.R) got the last rule too. Scale ranges start from every part’s resting scale; null keys of set tracks are ignored. A clip whose target is missing in a reachable variant throws missing_reference at $.clip. check() throws budget at $ before painting. lod.micro.glow: true is refused with budget at $ in both. Tests compare the browser with R at 1e-12 for the reference characters and Tessa in all three bands.
  • Band overrides (#303). lod.full or any name other than small and micro is refused with unknown_key; so are an unknown key, a face flag other than scale, lashes, brows or iris, and a non-boolean glow.
  • Reserved capabilities (#306). extends, connector, ring_pulse, follow, particles, morph, blend, inputs, fit, wardrobe and vividness are refused with code capability and “not supported by this version of mascot”. A name in requires outside the eight supported capabilities is refused at $.requires[n]. A reserved name counts only as a key of a fixed-field object (as in inst/format/character-v1.json), so a colour role or a kit eye style called follow compiles. R reports the first problem in document order; the engine checks the top-level object first, so the two can differ when a reserved key comes before a structural error. Both results are pinned in test-character-check.R and test-engine-parts-browser.R.
  • Required gestures (#328). A character needs a wave and a nod whose keyed values change. A wave needs a changing track on a non-root part; a nod can change any channel, root included.
  • Accessory ids (#312). A worn accessory’s parts are wear__<accessory>__<part>, and authored ids may not contain two underscores in a row (schema and browser check); a_b/c and a/b_c no longer collide. mascot.parts.accessoryPartId() builds the id in one place.
  • Format 1 for outside authors (#493). requires is optional; a top-level $schema, x_ keys and a note are accepted where R accepts them and never read (including in mascot-motion.js, mascot-geometry.js and a brand theme’s primary and secondary slots). mascot.parts.capabilities lists supported and reserved names for the parity test. An unknown capability is named in the error; kit effect errors use the paths the R checker uses, such as $.face.kit_definition.effects.<id>.items[<n>].clip, and fade_at must be below 1.

The host and Shiny

  • Life cycle. mascot-host.js creates window.mascot.host (mount, unmount, receive, state), registers MascotBinding on Shiny.inputBindings and handles mascot:update messages. Updates that arrive before a mount wait in a queue limited to 20. Mount serials and per-field revision watermarks (message.replay, message.fields, record.fields) let the server replay state after a remount, and playedGestures stops a handled gesture from playing twice. state() reports band, measures and offscreen.
  • R-settled state (#305). Shiny sends the latest complete accepted state for one mount generation. The host checks versioned envelopes, ignores stale revisions and ended generations, publishes mount and state snapshots with event priority and restores open bubbles with their remaining lifetime. A connection ledger keeps gesture use and bubble dismissal when an element is replaced. A duplicate live id leaves the first binding in charge. An unknown static UI token starts a one-time baseline exchange, so pages rendered before the server starts, and cached pages, bind after a restart; R checks the baseline fully before storing it. The canvas label follows the status of the character and mood shown.
  • New Shiny session (#426). On shiny:sessioninitialized with a different session id, each live mascot resets its revision and its gesture and bubble watermarks and publishes its snapshot again as a resume start, keeping mount and generation. The page keeps what it shows until the new session sends a state, and a dismissed bubble does not reopen.
  • Connection loss (#70). On shiny:disconnected every mascot switches to its worried mood and announces “Connection lost. Reload the page to continue.” above the disconnect overlay (mascot.css raises it). Updates during the outage are kept and restored on shiny:connected.
  • Modules and re-rendering (#133, #273). Examples cover nested modules, delayed renderUI() outputs and re-rendered containers. The host sends input$<id>_minimised (event priority) on minimise and restore.
  • Version (#464). mascot_dependency() gives the mascot.js tag a data-mascot-version attribute, which window.mascot.version reads (null without it). A second copy of another version warns once through console.warn and the first stays.
  • Format-1 route. mascot.runtime.isFormat() detects the description; mascot.runtime.validate() checks moods and gestures; mascot.mount() always uses mascot.runtime. A swap without a mood takes the incoming mood from the fallback chain. mascot_preview() resolves its theme the way mascot() does, so a character that is not built in passes the theme check there.
  • Comments. Comments in the engine files say what the code does, without internal stage names or references to documents outside the package (#464).

Panels, clicks and menus

  • One panel system (#233, #237, #241, #244, #248, #249, #250, #254, #257, #263). Speech bubbles, pop-up menus and action tooltips share .mascot-panel. click (type: 'none' | 'action' | 'menu') replaced popup_menu. A clickable mascot is wrapped in one native <button class="mascot-button"> that sends input$<id>_click (a monotonic counter that ignores key repeat and drag releases). .mascot-tooltip follows WCAG 1.4.13: a 150 ms pointer-leave grace and a shared Escape listener.
  • Placement of panels. inkBox() scans the alpha channel for the drawn box when a menu or tooltip opens; placePanel() chooses the side (auto, top, bottom, left, right, falling back to the opposite side or auto) inside clipBounds() and positions --_mascot-panel-tail. Only one menu is open at a time (openMenus); bubble timers pause while a menu is open or the mascot is minimised, and focus moves before controls are hidden. Panels fade out over 150 ms, using an inert DOM clone.
  • Gaze. gazeSide(), gazePose() and refreshGaze() hold look and lookY towards the open menu’s resolved data-side or the active bubble, allowing for horizontal flip.
  • Menus (#349, #350). Only action buttons send input$<id>_click; opening or closing a menu leaves the count alone, and choosing an item sends input$<id>_menu with event priority. A menu click setting carries a disabled flag: the button stays focusable (aria-disabled) but opens nothing and sends nothing; disabling an open menu closes it.
  • Clicks through the bubble space (#506). .mascot-bubble-region has pointer-events: none and .mascot-bubble restores auto for itself and its contents, so clicks reach page controls around a bubble.
  • Inline bubbles. While one shows, the host also watches sibling layout changes so the message stays inside the viewport.

Placement, dragging and minimising

  • Floating placement. portal() moves a floating mascot to document.body and leaves a .mascot-source placeholder span, so the unbindAll call of Shiny and the source container’s visibility and styles still work. A ResizeObserver scales the canvas by the device pixel ratio, and the offset of #shiny-notification-panel is tracked.
  • Phone sizing (#259, #261, #264, #266). With small_screen: "auto" (validateSize()), floating mascots cap their default size at 96 pixels at <= 575.98px (.mascot[data-floating="true"][data-small-screen="auto"]).
  • Dragging (#220, #222, #225, #229, #231, #232). Pointer capture, listenDrag(), drop(), cancelDrag(), clampPoint() and nearestCorner(), a 4-pixel threshold (dragThreshold), click suppression after a drag in the capture phase, a 200 ms snap animation and input$<id>_position. snap goes to the nearest corner and free stays where it is dropped. The minimised restore button drags too.
  • Snap corner (#513). drop() picks the corner with nearestCorner() from the drop point, with the mascot’s own size, before clampPoint(); a mascot taller or wider than the window less its margins used to be clamped first and snapped back to the bottom or right.
  • Minimise and restore (#275, #276). Minimising and restoring animate with Web Animations (240 ms transform and opacity, using a .mascot-motion-copy canvas clone). The host re-measures and calls controller.redraw() when restoring, when the animation finishes or is cancelled, and whenever measure() changes the canvas size, so the mascot is sharp at full device resolution. redraw() repeats drawFrame() without moving time or the activity window.

Motion

  • Tracks (#299). mascot.motion.tracks: normalise() and sample() for [[at, value, ease?], ...] and { mode, keys } tracks (add and set modes, null as the mood base, discrete values that switch at keys, the easing curves linear, ease_in, ease_out, ease_in_out and step, exact rest at the end); compile() for needs and fallback traversal (missing, cyclic and unplayable chains fail with { code, path }), target-part checks and the 1 to 150 frame budget; settings(); fit() for late effects; frameAt() for interruption, reduction, a five-second absolute deadline, multiplicative breathing, blink skipping and separate effect instances; drawn(). Unknown gesture, channel, easing and effect names are errors; morph, blend and particles stay reserved. The idle extra start time comes from state.extraAt (the host’s seeded value; null suppresses it).
  • Swapping characters (#307). The outgoing character waves (its wave tracks over the captured channels) and fades over thirty frames, the incoming one waves and fades in over thirty, composited once through a private canvas, with durations fitted to the incoming activity window. A new target mid-fade keeps the exit time that remains; asking for the exiting character again reverses; a mood change mid-swap commits without a wiggle; a gesture during a swap is dropped; reduced motion or swap: false is instant.
  • Separate switches (#239, #267). animate became independent switches for idle, mood_change, gestures, swap, minimising, restoring, others and per-panel messages (bubble, tooltip, menu); mascot.motion.settings and mascot.motion.panels are frozen exports. In development mode a shiny:client-message warns when a server gesture is skipped because gestures = false.

Styling and theming

  • Public hooks (#260, #270). --mascot-panel-*, --mascot-restore-* and --mascot-z-index are public; layout variables are private --_mascot-* (size, source-*, drag-*, menu-*, tooltip-*, panel-tail, notification-offset), so user style sheets win over Bootstrap and page defaults. For a floating mascot moved to document.body, the inherited public hooks are copied from the source anchor to --_mascot-source-*.
  • Bubble space class (#558). .mascot-bubble-region is a public class next to .mascot-bubble; hide it with display: none on a page that never shows a bubble. Renaming it needs a deprecation. ?mascot_styling lists it and a test checks that mascot() puts it on the page.
  • Page themes. resolveTheme(), shadeColour() (HSL lightness and saturation shifts from the leader roles in description.follows), colourContrast() (WCAG relative luminance, 3:1 warnings) and pageMode(), which watches [data-bs-theme] and body.quarto-dark / quarto-light and defaults to light.
  • Palettes (#336, #358). R resolves a named palette; the host receives its id and light and dark leader colours apart from explicit overrides and applies palette colours, then role overrides, then CSS. R decides whether a palette is kept on a character swap and warns when the incoming character lacks the id. Outside Shiny, a host patch resolves a shared palette against the incoming character or drops it. When the incoming character’s own colours carry the palette id (native_palette), the id is kept and no palette colours are applied, as for original.

Performance

  • Page changes and layout (#295). relevantMutation() filters MutationObserver records, so changes outside a mascot’s ancestor chain, its .mascot-source anchor, <head> styles or #shiny-notification-panel do no work. Page refreshes and theme-mode changes coalesce through schedulePageRefresh() into one requestAnimationFrame per burst; changes confined to private host style properties are ignored.
  • Off-screen mascots (#295). A shared, reference-counted IntersectionObserver (watchViewport()) calls controller.holdVisibility() when a mascot leaves or re-enters the viewport. The hold is independent of user pause and activity: returning does not draw, renew the five-second window or override another pause, and a moving player continues from its saved frame. The observer disconnects with the last unmount.
  • Character data once per page (#497). mascot() puts a built-in character’s <script type="application/json" data-mascot-character="<id>"> block in the page head through an HTML dependency named mascot-character-<id>; a custom character’s block stays inside its element. characterScript(id, scope) in mascot.js finds it: the element’s own block, then one outside every mascot element, never another mascot’s. readCharacter(id, scope) parses it, and the host’s template-needed recovery reads through characterScript().
  • Caches. bound() is frozen and cached per character and band; a band that passed once is not checked again. The channel table of a plain frame is one object per player.

What was removed

  • The older drawing path (#463). mascot-draw.js is gone and mascot_dependency() no longer lists it: about 2,090 lines and 87 KB less JavaScript on every page. mascot.js keeps fps, readCharacter() and validColour() (its load-twice guard keys on readCharacter); draw(), validateCharacter() and checkCharacter() are removed. mascot-motion.js keeps tracks, shared (with swapTiming), settings and panels; its older player, pose(), frame() and validate() are removed. update({ character }) with a description that is not format version 1 fails with “update.character: expected a character with format_version 1”.
  • The Tessa bridge (#313). See the Tessa item under drawing. mascot.geometry.bandBound() and checkBudget() went with it; checkWork() stays.
  • Namespace hint (#392). suggestNamespace(), a two-second development-mode hint for a missing ns(), could not be reached from Shiny, because R drops an update for an id that is not mounted. The module rule stays in ?mascot and ?update_mascot.
  • simplify_when_small (#219, #221). The separate small-size face drawing is gone; detail bands replace it.

mascot 0.0.2

2026-10-06 — Animated character swaps (ea35c6f, #147, #173)

In brief: Swapping from one character to another waves and fades the old character out over one second, then fades the new character in while waving over one second, handling rapid mid-swap changes and reversals smoothly.

Technical:

  • inst/www/mascot-motion.js: added mascot.motion.frame (sceneFrame(ch, state, n)) and SwapState (at, until, outFrames, inFrames, opacity, outgoing snapshot with wave and recovery continuations), off-screen buffer blending (layer canvas in drawFrame()) so part-level alpha never leaks during fade-out and fade-in, controller.current(), and options.onChange(snapshot).
  • inst/www/mascot.js: added validColour and cached character validation in a WeakMap.

2026-10-06 — Frame-stepping motion engine and five-second player (853b448, #27, #145)

In brief: Added mascot-motion.js for mood transitions (wiggle or hop), deterministic seeded blinking and breathing, one-off nod and wave gestures, prefers-reduced-motion support, and a canvas player that automatically stops its requestAnimationFrame loop after five seconds (150 frames).

Technical:

  • inst/www/mascot-motion.js: added window.mascot.motion ({ pose, validate }) and window.mascot.mount(canvas, ch, options) returning { seek, update, setMood, gesture, pause, play, destroy }; implemented LCG hash blinking (Math.imul((seed >>> 0) ^ i, 1664525) + 1013904223), smooth breathing fade over the final third of the 150-frame window, and atomic preparePatch() validation.

2026-10-03 — Data-driven character engine (4ba964a, f642d19, #52, #66, #137, #138)

In brief: Turned Tessa’s hard-coded drawing code into a generic engine (mascot.js and mascot-draw.js) that validates and draws any character from its JSON description (inst/characters/<id>.json).

Technical:

  • inst/www/mascot.js: added data-driven readCharacter, validateCharacter and draw under window.mascot (fps = 30). Colour validation and WeakMap validation caching follow in ea35c6f.
  • inst/www/mascot-draw.js: added window.mascot.drawing (svgPath parser for absolute M/L/Q/C/Z commands, hexPath, drawPart, resolvePose, guardPose, anchors returning world coordinates and 2x2 linear matrices, accessories slot validation, and FACE_STYLE overrides).

2026-10-03 — Initial port of Tessa’s canvas drawing code (df97139, #9, #24, #136)

In brief: Brought Tessa’s deterministic 2D canvas drawing code over from the prototype site (tools/animate/engine.js).

Technical:

  • inst/www/mascot.js, inst/www/mascot-draw.js: initial classic-script IIFE setup under window.mascot drawing Tessa’s hexagon body, eyes, mouths, brows, blush, sweat, sparkle, arms and feet at 30 fps without Math.random() or wall-clock reads.