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 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
- 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
waveand anodthat 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,noteandx_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 withsmall_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-regionclass are documented in?mascot_styling; everything else is private.window.mascot.versiongives 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.partsworks out hierarchical 2D transforms per frame: reserved idsroot,faceandeffects, uniquesnake_caseids, one accessory per slot, acyclic part and anchor order,keep_uprightandno_flip, channel-driven variant switches (part.switch), band hiding, layer sorting. Lookups of data-named keys go throughown()(Object.hasOwn), so a key cannot reachObject.prototype.mascot.geometrycompilespath,circle,arc,ellipse,rect,round_rect,polyline,polygon,hexagonandrefshapes into cachedPath2Dobjects, 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.runtimecompiles 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 istracks.frameAt(), then the mood’s channels (the reduced ones under reduced motion), then held look, thenparts.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,worriedandcelebratingare required, and fallback chains end at them (swapMood()).recolourandreduced.recolourredirect colour roles before paints and gradients; a cycle fails withinvalid_colour. Item overrides setfill,strokeandalpha; aswitch: { channel, above, variant }applies strictly above its threshold, and anchors follow a changed variant. -
Face kits and kit effects.
mascot-faces.jspaints generic face kits; an omitted glowalphais 1. The sweat drop and sparkle star usemascot.parts.sweatProgress()andmascot.parts.sparklePulse(), so the tests of those two functions test the drawing’s maths.mascot.faces.effects()checks the effect value first (codeinput_typeat$.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: trueis painted second outline, outline, fill, then its ownstroke, 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 callssetBand()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 throughconsole.warn. A canvas with no layout width mounts atfull. 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 bymascot.internal.useCharacterAdapter) drew her through the modules in between; it and its tests went in #313, andinst/characters/tessa.jsonleft the package. -
Gaze. Held look is
face.look_xandface.look_yonly.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 afterrefandtransformmoves, 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;nullkeys ofsettracks are ignored. A clip whose target is missing in a reachable variant throwsmissing_referenceat$.clip.check()throwsbudgetat$before painting.lod.micro.glow: trueis refused withbudgetat$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.fullor any name other thansmallandmicrois refused withunknown_key; so are an unknown key, afaceflag other thanscale,lashes,browsoriris, and a non-booleanglow. -
Reserved capabilities (#306).
extends,connector,ring_pulse,follow,particles,morph,blend,inputs,fit,wardrobeandvividnessare refused with codecapabilityand “not supported by this version of mascot”. A name inrequiresoutside the eight supported capabilities is refused at$.requires[n]. A reserved name counts only as a key of a fixed-field object (as ininst/format/character-v1.json), so a colour role or a kit eye style calledfollowcompiles. 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 intest-character-check.Randtest-engine-parts-browser.R. -
Required gestures (#328). A character needs a
waveand anodwhose keyed values change. A wave needs a changing track on a non-root part; a nod can change any channel,rootincluded. -
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/canda/b_cno longer collide.mascot.parts.accessoryPartId()builds the id in one place. -
Format 1 for outside authors (#493).
requiresis optional; a top-level$schema,x_keys and anoteare accepted where R accepts them and never read (including inmascot-motion.js,mascot-geometry.jsand a brand theme’sprimaryandsecondaryslots).mascot.parts.capabilitieslists 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, andfade_atmust be below 1.
The host and Shiny
-
Life cycle.
mascot-host.jscreateswindow.mascot.host(mount,unmount,receive,state), registersMascotBindingonShiny.inputBindingsand handlesmascot:updatemessages. 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, andplayedGesturesstops a handled gesture from playing twice.state()reportsband,measuresandoffscreen. - 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:sessioninitializedwith a different session id, each live mascot resets its revision and its gesture and bubble watermarks and publishes its snapshot again as aresumestart, 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:disconnectedevery mascot switches to its worried mood and announces “Connection lost. Reload the page to continue.” above the disconnect overlay (mascot.cssraises it). Updates during the outage are kept and restored onshiny:connected. -
Modules and re-rendering (#133, #273). Examples
cover nested modules, delayed
renderUI()outputs and re-rendered containers. The host sendsinput$<id>_minimised(event priority) on minimise and restore. -
Version (#464).
mascot_dependency()gives themascot.jstag adata-mascot-versionattribute, whichwindow.mascot.versionreads (nullwithout it). A second copy of another version warns once throughconsole.warnand the first stays. -
Format-1 route.
mascot.runtime.isFormat()detects the description;mascot.runtime.validate()checks moods and gestures;mascot.mount()always usesmascot.runtime. A swap without a mood takes the incoming mood from the fallback chain.mascot_preview()resolves its theme the waymascot()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') replacedpopup_menu. A clickable mascot is wrapped in one native<button class="mascot-button">that sendsinput$<id>_click(a monotonic counter that ignores key repeat and drag releases)..mascot-tooltipfollowsWCAG1.4.13: a 150 ms pointer-leave grace and a sharedEscapelistener. -
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 orauto) insideclipBounds()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()andrefreshGaze()holdlookandlookYtowards the open menu’s resolveddata-sideor the active bubble, allowing for horizontalflip. -
Menus (#349, #350). Only action buttons send
input$<id>_click; opening or closing a menu leaves the count alone, and choosing an item sendsinput$<id>_menuwith event priority. A menu click setting carries adisabledflag: 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-regionhaspointer-events: noneand.mascot-bubblerestoresautofor 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 todocument.bodyand leaves a.mascot-sourceplaceholder span, so theunbindAllcall of Shiny and the source container’s visibility and styles still work. AResizeObserverscales the canvas by the device pixel ratio, and the offset of#shiny-notification-panelis 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()andnearestCorner(), a 4-pixel threshold (dragThreshold), click suppression after a drag in the capture phase, a 200 ms snap animation andinput$<id>_position.snapgoes to the nearest corner andfreestays where it is dropped. The minimised restore button drags too. -
Snap corner (#513).
drop()picks the corner withnearestCorner()from the drop point, with the mascot’s own size, beforeclampPoint(); 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-copycanvas clone). The host re-measures and callscontroller.redraw()when restoring, when the animation finishes or is cancelled, and whenevermeasure()changes the canvas size, so the mascot is sharp at full device resolution.redraw()repeatsdrawFrame()without moving time or the activity window.
Motion
-
Tracks (#299).
mascot.motion.tracks:normalise()andsample()for[[at, value, ease?], ...]and{ mode, keys }tracks (add and set modes,nullas the mood base, discrete values that switch at keys, the easing curveslinear,ease_in,ease_out,ease_in_outandstep, 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 idleextrastart time comes fromstate.extraAt(the host’s seeded value;nullsuppresses it). -
Swapping characters (#307). The outgoing character
waves (its
wavetracks 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 orswap: falseis instant. -
Separate switches (#239, #267).
animatebecame independent switches foridle,mood_change,gestures,swap,minimising,restoring,othersand per-panelmessages(bubble,tooltip,menu);mascot.motion.settingsandmascot.motion.panelsare frozen exports. In development mode ashiny:client-messagewarns when a server gesture is skipped becausegestures = false.
Styling and theming
-
Public hooks (#260, #270).
--mascot-panel-*,--mascot-restore-*and--mascot-z-indexare 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 todocument.body, the inherited public hooks are copied from the source anchor to--_mascot-source-*. -
Bubble space class (#558).
.mascot-bubble-regionis a public class next to.mascot-bubble; hide it withdisplay: noneon a page that never shows a bubble. Renaming it needs a deprecation.?mascot_stylinglists it and a test checks thatmascot()puts it on the page. -
Page themes.
resolveTheme(),shadeColour()(HSLlightness and saturation shifts from the leader roles indescription.follows),colourContrast()(WCAGrelative luminance, 3:1 warnings) andpageMode(), which watches[data-bs-theme]andbody.quarto-dark/quarto-lightand 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 fororiginal.
Performance
-
Page changes and layout (#295).
relevantMutation()filtersMutationObserverrecords, so changes outside a mascot’s ancestor chain, its.mascot-sourceanchor,<head>styles or#shiny-notification-paneldo no work. Page refreshes and theme-mode changes coalesce throughschedulePageRefresh()into onerequestAnimationFrameper burst; changes confined to private host style properties are ignored. -
Off-screen mascots (#295). A shared,
reference-counted
IntersectionObserver(watchViewport()) callscontroller.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 namedmascot-character-<id>; a custom character’s block stays inside its element.characterScript(id, scope)inmascot.jsfinds it: the element’s own block, then one outside every mascot element, never another mascot’s.readCharacter(id, scope)parses it, and the host’stemplate-neededrecovery reads throughcharacterScript(). -
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.jsis gone andmascot_dependency()no longer lists it: about 2,090 lines and 87 KB less JavaScript on every page.mascot.jskeepsfps,readCharacter()andvalidColour()(its load-twice guard keys onreadCharacter);draw(),validateCharacter()andcheckCharacter()are removed.mascot-motion.jskeepstracks,shared(withswapTiming),settingsandpanels; its older player,pose(),frame()andvalidate()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()andcheckBudget()went with it;checkWork()stays. -
Namespace hint (#392).
suggestNamespace(), a two-second development-mode hint for a missingns(), could not be reached from Shiny, because R drops an update for an id that is not mounted. The module rule stays in?mascotand?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: addedmascot.motion.frame(sceneFrame(ch, state, n)) andSwapState(at,until,outFrames,inFrames,opacity,outgoingsnapshot withwaveandrecoverycontinuations), off-screen buffer blending (layercanvas indrawFrame()) so part-level alpha never leaks during fade-out and fade-in,controller.current(), andoptions.onChange(snapshot). -
inst/www/mascot.js: addedvalidColourand cached character validation in aWeakMap.
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: addedwindow.mascot.motion({ pose, validate }) andwindow.mascot.mount(canvas, ch, options)returning{ seek, update, setMood, gesture, pause, play, destroy }; implementedLCGhash blinking (Math.imul((seed >>> 0) ^ i, 1664525) + 1013904223), smooth breathing fade over the final third of the 150-frame window, and atomicpreparePatch()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-drivenreadCharacter,validateCharacteranddrawunderwindow.mascot(fps = 30). Colour validation andWeakMapvalidation caching follow inea35c6f. -
inst/www/mascot-draw.js: addedwindow.mascot.drawing(svgPathparser for absoluteM/L/Q/C/Zcommands,hexPath,drawPart,resolvePose,guardPose,anchorsreturning world coordinates and 2x2 linear matrices,accessoriesslot validation, andFACE_STYLEoverrides).
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-scriptIIFEsetup underwindow.mascotdrawing Tessa’s hexagon body, eyes, mouths, brows, blush, sweat, sparkle, arms and feet at 30fpswithoutMath.random()or wall-clock reads.
