Skip to content

v0.15 — physics dynamics, fixed-timestep, React/Aseprite/LDtk, docs-site polish#212

Merged
Exoridus merged 80 commits into
mainfrom
feat/v0.15
Jun 28, 2026
Merged

v0.15 — physics dynamics, fixed-timestep, React/Aseprite/LDtk, docs-site polish#212
Exoridus merged 80 commits into
mainfrom
feat/v0.15

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

v0.15 — feature release

Merges the full feat/v0.15 line into main (77 commits). Highlights by area:

Physics (@codexo/exojs-physics)

  • Body sleeping with islands + perf gate + drawSleeping debug
  • Joint suite: Distance, Revolute (+ motor/limits), Weld, Prismatic, Wheel, Mouse (soft drag), rope limits
  • CCD / bullet-mode (vs static + dynamic) with normal reflection
  • Oriented-bounding-box (OBB) SAT fixes for rotated / inherited-rotation nodes

Core & rendering

  • Fixed-timestep loop: Scene.fixedUpdate / onFixedFrame / frameAlpha
  • Full W3C blend-mode suite (all 18 modes) wired through both backends
  • MeshMaterial.from() / SpriteMaterial.from() factories; SceneNode.cullArea
  • UI widgets: Tooltip, ScrollContainer
  • Tilemap: Tiled parallax-factor support

React / Aseprite / LDtk

  • ExoCanvas batteries-included wrapper; React Testing Library suite (jsdom)
  • Characterization suites for aseprite + ldtk; API docs wired in
  • create:package scaffolder; lockstep release wiring

Docs site & playground

  • Brand assets + favicons (mark / wordmark set); exo.js header wordmark
  • Playground: vendor #-subpath type fix, extension typings (ts2307) resolved, panel-fill + cross-realm canvas detection, light-theme code contrast
  • Guides: physics / aseprite / ldtk / React chapters (F3); fixed-timestep
  • Companion mascot: hero greeter + 8-state callouts; relocated to the live-example section
  • Homepage: animated example thumbnails + hero / cap-strip motion, playground card links, tween hero snippet
  • Guide scroll-spy: bottom-section highlight + clicked-link activation

Notes

Exoridus added 30 commits June 26, 2026 03:14
Allows specifying a custom Rectangle for viewport cull intersection
instead of the computed node bounds. Useful for nodes with off-screen
effects or custom cull regions. Serialized in commonFields.
Adds dist/exo.iife.js and dist/exo.iife.min.js as global IIFE bundles
exposing ExoJS on globalThis. Enables script-tag CDN usage.

Adds size-limit CI gate: ESM bundle ≤700KB gzip, IIFE min ≤250KB gzip.
Adds parallaxX/parallaxY to TileLayer (default 1.0). TiledMap.toTileMap()
now forwards parallax factors from TiledLayer. TileLayerNode applies
camera-relative parallax offset during render plan collection, enabling
depth parallax effects from Tiled editor layer settings.
Adds TweenSequencer with sequential and parallel stage composition,
wait() delays, repeat/yoyo, and lifecycle callbacks. Integrates with
TweenManager via addTicker(). Factory: manager.createSequencer().
Tooltip: hover-triggered text label with configurable delay and position.
ScrollContainer: clipped widget with vertical/horizontal scroll via mouse
wheel. Content added to .content child container.
Integrates the advanced-blend-spike compositor code into the render plan.
Modes Darken(5)..Luminosity(17) now use backdrop-aware compositing
(GPU copy + blend shader) instead of broken fixed-function MIN/MAX.
Modes 0-4 unchanged (fixed-function path).

Adds isAdvancedBlendMode predicate and needsBackdropBlend barrier flag.
Both WebGL2 and WebGPU backends implement composeWithBackdropBlend().
Adds Logger with LogSeverity/LogChannel/LogEntry, prod-stripped for
non-error logs. Exports logger singleton with auto-attached console
handler in dev mode.

Adds new Signals: Application.onError, Scene.onLoad/onUnload,
SystemRegistry.onAdd/onRemove for external system integration.
…tic factories

DX improvement — instead of manually constructing both ShaderSource and
MeshMaterial/SpriteMaterial, a single call suffices:

  MeshMaterial.from(vertSrc, fragSrc, { uniforms, blendMode })
  MeshMaterial.from(existingShaderSource, { uniforms })

Two TS overloads per class:
  1. from(source: ShaderSource, options?: Omit<MaterialOptions, 'shader'>)
  2. from(glslVertex, glslFragment, options?: { wgsl?, uniforms?, blendMode?, sampler? })

Implementation uses exactOptionalPropertyTypes-safe conditional spreads
(...(x !== undefined ? { x } : {})) and instanceof dispatch for the
ShaderSource overload. SpriteMaterial.from() is a structural mirror.
…tic factories

DX improvement — instead of manually constructing both ShaderSource and
MeshMaterial/SpriteMaterial, a single call suffices:

  MeshMaterial.from(vertSrc, fragSrc, { uniforms, blendMode })
  MeshMaterial.from(existingShaderSource, { uniforms })

Two TS overloads per class:
  1. from(source: ShaderSource, options?: Omit<MaterialOptions, 'shader'>)
  2. from(glslVertex, glslFragment, options?: { wgsl?, uniforms?, blendMode?, sampler? })

Implementation uses exactOptionalPropertyTypes-safe conditional spreads
(...(x !== undefined ? { x } : {})) and instanceof dispatch for the
ShaderSource overload. SpriteMaterial.from() is a structural mirror.
…oader

Adds four new public signals to Loader for tracking foreground load batches
without polling:

- onLoadStart [key, url]              fires when the loader transitions idle to active
- onLoadProgress [loaded, total, key] fires after each item settles
- onLoadComplete []                   fires when all batch items have settled
- onLoadError [key, error]            fires on failure, does not suppress onLoadComplete

Batch lifecycle: the first load() call that finds the loader idle starts a new
batch and resets counters; concurrent calls within the same tick join the current
batch so total grows dynamically. When active count returns to zero, onLoadComplete
fires and totals reset.

All four code paths in load() (extension-based, single string, array, Record) plus
_createLoadingQueue (Asset/Assets/config-map) are instrumented. All four signals
are destroyed in destroy().
Adds blob (8-neighbor) and edge (4-neighbor) Wang autotiling support to
@codexo/exojs-tilemap. The corner-dependency rule ensures diagonal bits are
only set when both adjacent cardinal directions are present, collapsing the
256 raw blob combinations to the standard 47 valid states.

- WangSet: maps a bitmask to a local tile ID within a named tileset.
  Accepts both ReadonlyMap<number,number> and a plain Record.
- autoTile(layer, wangSet, options?): two-pass (snapshot -> write) so
  reads always reflect pre-call state; supports matchFn to restrict the
  Wang group and wrapBorder to control border tile fill behaviour.
- 7 unit tests covering corner dependency, matchFn scope, and edge mode.
Adds the new @codexo/exojs-ldtk extension package that parses the LDtk
level-editor JSON format and converts each level to a runtime TileMap.

- LdtkData.ts: TypeScript types for the LDtk JSON format (v1.5.x)
- LdtkMap.ts: parsed source model with levels: TileMap[] and getLevelByName()
- ldtkToTileMap.ts: pure converter (LdtkData + optional TileSet map → LdtkMap);
  Tiles/AutoLayer → TileLayer; IntGrid → TileLayer (auto-tiles when present);
  Entities → ObjectLayer with scalar field properties; no __DEV__ usage
- loadLdtkMap.ts: async loader — fetches JSON, loads tileset textures,
  builds TileSets, then calls ldtkToTileMap
- ldtkBinding.ts: AssetBinding claiming the .ldtk extension
- ldtkExtension.ts: Extension descriptor depending on tilemapExtension
- register.ts: side-effectful auto-registration entry
- public.ts / index.ts: barrel + ExtensionTypeMap / AssetDefinitions augmentation
- test/ldtkToTileMap.test.ts: 19 unit tests (no loader/texture required)
- pnpm-workspace.yaml + vitest.config.ts: register the package
New package that integrates Aseprite JSON sprite sheet exports with the
ExoJS AnimatedSprite system. Supports both array-mode and hash-mode
Aseprite JSON exports, derives animation clips from frameTags with
per-frame fps averaging, and exposes a simple one-liner API:

  const sheet = await loader.load(AsepriteSheet, 'hero.aseprite.json');
  const sprite = sheet.createAnimatedSprite();
  sprite.play('run');

Public API:
- AsepriteSheet.parse(data, texture) — static factory
- AsepriteSheet.createAnimatedSprite() — returns AnimatedSprite with all clips
- AsepriteSheet.clips — ReadonlyMap<string, AnimatedSpriteClipDefinition>
- AsepriteSheet.spritesheet — underlying Spritesheet
- AsepriteFormatError — thrown on invalid Aseprite JSON
- isAsepriteArrayData() — discriminator for array/hash mode
- asepriteExtension / asepriteBinding — for manual extension registration
- /register entry for global auto-registration

Note: pnpm-workspace.yaml registration left to a follow-up commit
(per task constraints: shared config files not modified here).
…p, useScene

Provides React integration for the ExoJS game engine:
- ExoCanvas: mounts canvas, creates/destroys Application, provides context
- useExoApp: returns the Application from the nearest ExoCanvas ancestor
- useScene: starts the engine on first mount, switches scenes on re-renders
- ExoContext / useExoContext exported for advanced provider use

Package ships TypeScript source (no Rollup build step); consumers supply the bundler.
Exoridus and others added 23 commits June 27, 2026 08:07
Softly pulls a single body's grab point toward a movable target point
(the standard mouse-drag primitive: physics sandboxes, puzzle/grab
mechanics). The grab point is fixed on the body at creation; reassigning
`joint.target` each frame drags it and wakes the body so the drag tracks
live. Bounded by `maxForce` so heavy bodies lag.

Reuses the existing soft-constraint joint infrastructure (Box2D-v3 soft
factors, sub-step solve, warm-start). Being single-body, it stands a
private static ground sentinel in for bodyA so the island/solver
machinery — which assumes two bodies — sees a static anchor it never
integrates, unions or mutates; only the dragged body is touched.

Tests: SG-J17 (drags to target + tracks a moved target), SG-J18
(maxForce caps the pull). Full physics suite 120/120 green; typecheck +
lint + prettier clean.

Co-authored-by: Exoridus <github@codexo.de>
…195)

* fix(core): oriented bounding-box axes for rotated-node SAT collision

SceneNode.getNormals()/project() delegated to getBounds() — the loose
axis-aligned AABB — even for rotated nodes. The SAT collision path
(node-vs-node / -rect / -polygon) therefore tested the AABB's axes, so a
rotated node accepted collisions its true oriented box rejects.

Now, for non-axis-aligned nodes, getNormals()/project() build the oriented
bounding box (the four local-bounds corners under the global transform) as
a Polygon and use its edge normals / projection; the isAlignedBox fast
path keeps the exact, cheaper AABB. The oriented polygon is built lazily
(only rotated nodes that are SAT-tested pay it) and refreshed in place via
a shared corner scratch — allocation-free.

Tests: getNormals() returns diagonal axes for a 45° node; intersectsWith()
rejects an AABB false-positive (target in the AABB corner, outside the
oriented diamond).

Note: rotated-node vs circle/ellipse/line/point still uses the AABB
(unchanged) — only the SAT axis path is corrected here.

* chore(core): sync API doc + export snapshots for the OBB change

- scene-node.mdx: source-line anchor shifted by the added module scratch.
- root-index snapshots: capture the pre-existing `Codec` core export (the
  physics PRs never ran the core lane, so the drift went unexercised; this
  core-touching change runs it and must be current).

---------

Co-authored-by: Exoridus <github@codexo.de>
…d rotation) (#196)

* fix(core): use the world transform to pick AABB vs OBB collision path

The OBB collision fix gated on isAlignedBox, which inspects only the
node's OWN rotation. A node with no rotation of its own but a rotated
ancestor has a rotated WORLD box yet isAlignedBox is true — so it took
the AABB fast path and SAT silently tested the loose axis-aligned box.
(Latent since the SAT routing was added; never surfaced because the only
collision example uses non-rotated axis-aligned rectangles.)

getNormals()/project()/intersectsWith()/collidesWith()/contains() now
gate on the global transform mapping axes to axes — (b≈0 && c≈0) ||
(a≈0 && d≈0) — instead of the node-local isAlignedBox. The public
isAlignedBox getter keeps its node-local meaning. The fast/SAT choice
only affects which (both-correct) path runs, so the epsilon is perf-only,
never a correctness risk.

Test: a Drawable with no own rotation under a 45°-rotated Container —
isAlignedBox is true, yet a target in the world-AABB corner (outside the
inherited diamond) is correctly rejected. Core suite 2498/2498 green.

* docs(api): regenerate scene-node.mdx for world-alignment wording

---------

Co-authored-by: Exoridus <github@codexo.de>
…rameAlpha) (#197)

* feat(core): fixed-timestep loop (Scene.fixedUpdate / onFixedFrame / frameAlpha)

Adds an accumulator-driven fixed timestep (Gaffer's "Fix Your Timestep")
so physics and deterministic gameplay advance at a frame-rate-independent
rate, decoupled from the variable render rate — the Unity Update/FixedUpdate
split.

Each frame, after input, the loop runs 0..N fixed steps:
  scene.fixedUpdate(fixedDelta) + onFixedFrame, then the variable
  scene.update / draw as before. Always on (default 1/60s, configurable via
  `new Application({ fixedTimeStep })`); fixedUpdate is a no-op by default, so
  existing games are behaviour-identical and the empty loop is free.

- `FixedTimestep` (@internal): pure accumulator → step count + interpolation
  alpha, with an epsilon so exact multiples don't lose a step to float error,
  and a maxSteps spiral-of-death guard that drops backlog rather than carrying
  it. Accumulator resets on start/resume so a paused gap is not caught up.
- `Scene.fixedUpdate(delta)` + `SceneManager.fixedUpdate` (skips a paused scene).
- `Application.onFixedFrame`, `Application.frameAlpha` (render interpolation
  factor; the scene graph is not auto-interpolated — expose alpha, lerp opt-in),
  `ApplicationOptions.fixedTimeStep`.

Tests: FixedTimestep maths (steps/alpha/cap/reset), the loop runs the right
number of fixed steps + alpha + spiral cap, SceneManager skips a paused scene.
Core suite 2510/2510 green.

* test(core): stub _fixed in bypass-constructor app tests + regenerate API docs

The Object.create(Application.prototype) loop tests hand-build the app and
stub the fields update() touches; add the new _fixed accumulator stub (a
0-step no-op) alongside _frameClock. Regenerate the Application/Scene/
SceneManager API docs for the new fixed-timestep members.

---------

Co-authored-by: Exoridus <github@codexo.de>
… (F2) (#198)

Adds the three remaining extension packages to the API-doc generator's
EXTENSION_PACKAGES so their public classes/enums get MDX pages and appear
in the API navigation (physics / aseprite / ldtk subsystems + nav meta).
React stays hand-written (hooks/JSX over generated API) per the plan.

Fixes a latent build-api bug surfaced by physics: TypeDoc infers a
per-package source base for a package that pulls in no core *source*
files (only type-only imports), yielding package-relative file names
(`shapes/BoxShape.ts`) that miss the sourceMarker filter and break source
links. Pinning `basePath` to the repo root makes every package report
repo-relative paths (`packages/<pkg>/src/...`) consistently — existing
packages' pages are byte-identical (verified: 0 changed), physics/
aseprite/ldtk now generate.

Generated: 16 physics, 2 aseprite, 1 ldtk pages (269 total). Interfaces/
types/functions are intentionally not paged (classes + enums only), so
aseprite/ldtk are thin by design.

Co-authored-by: Exoridus <github@codexo.de>
Both packages were near-untested. These are characterization tests for
the shipped behaviour (real checked-in fixtures, no behaviour mocks):

aseprite (3 files, 51 tests): AsepriteSheet parse for both array- and
hash-frames forms (frame rekeying, rects, fps from durations + 12fps
fallback), isAsepriteArrayData guard, clips from frameTags (inclusive
range, loop, pingpong not expanded), createAnimatedSprite, the binding
load path (image resolution + sub-load) and all malformed-input
AsepriteFormatError cases, and the extension registration contract
(root side-effect-free vs /register).

ldtk (4 new files, +59 tests → 78): LdtkMap accessors + destroy
forwarding, flip-bit decoding (ldtkFlipNone/X/Y/Xy), ldtkToTileMap
across layer types (Tiles/AutoLayer/IntGrid/Entities) + grid-size
derivation + field projection + entity-id formula, the loadLdtkMap
loader path, and the extension contract.

Flagged for follow-up (asserted as current behaviour, not fixed here):
- ldtk `resolveLdtkUrl` throws on a relative .ldtk base URL (Tiled's
  adapter handles relative bases — real inconsistency).
- ldtk `loadLdtkMap` does no structural validation (raw TypeError vs a
  typed format error like TiledFormatError).
- aseprite parses `slices` into the type model but AsepriteSheet never
  surfaces them (unimplemented, not a bug).

Co-authored-by: Exoridus <github@codexo.de>
The @codexo/exojs-react package had no tests and no vitest project.
Adds the infrastructure and a meaningful suite (5 files, 24 tests) that
mocks the engine (a MockApplication) so the React glue is what's under
test, not WebGL.

Infra (additive — the other 8 jsdom projects are byte-for-byte unchanged):
- devDeps @testing-library/react + @testing-library/dom (root); pnpm
  resolved their react/react-dom peers to the existing workspace 19.x.
- vitest.config: an `exojs-react` jsdom project reusing the shared
  createJsdomTestProject helper unchanged, with esbuild automatic JSX set
  at the project level only (the rendering-perf `plugins` pattern).
- eslint.config: widen the three package-test globs `.ts` → `.{ts,tsx}`
  (a superset — no other package has .tsx tests; verified unchanged for
  them). Without it `.tsx` tests fell through to the typed config and
  crashed eslint.

Tests: useExoApplication (construct once, canvas binding, recreate +
destroy on backend change, live-sync size/sizingMode/clearColor with
value-keying, destroy on unmount), ExoCanvas (positioned wrapper + canvas,
div/canvas prop forwarding, children overlay, context), Scenes/Scene/
useActiveScene (activation + setScene with transition + null on unknown),
useScene, and the context error path (useExoApp throws outside a provider).

Verified: exojs-react 24/24; multi-project run (react + core + aseprite +
particles) 2639/2639 green — the shared-config change disturbs nothing.

Written by a subagent; the shared-infra changes were reviewed before commit.

Co-authored-by: Exoridus <github@codexo.de>
`pnpm create:package <name>` scaffolds a new lockstep extension package and
auto-wires it into the single sources of truth, so adding the N-th package
is one command instead of a ~10-file hand-edit.

Generates `packages/exojs-<name>/` (package.json/tsconfig/rollup/src/test/
README/LICENSE) templated from the existing packages, and edits (idempotently):
- `scripts/release/lockstep-packages.ts` — the LockstepPackage entry,
- `scripts/ci/select-lanes.mjs` — RUNTIME_PACKAGES,
- `pnpm-workspace.yaml` — the explicit member list (the workspace lists
  members explicitly, so the package is invisible to pnpm without it).

Then prints a concrete, copy-pasteable checklist for what it deliberately
does NOT auto-edit (enumerated YAML / other runtimes): the `_ci-checks.yml`
+ `release.yml` `--filter` lines (the latter enforced by verify:release-matrix),
the `vitest.config.ts` project, the root `package.json` filter lines, and the
npm placeholder-publish + Trusted-Publisher (OIDC) bootstrap from RELEASING.md.

Flags: `--register` (ships `/register` + `sideEffects` array) vs `--library`
(default, `sideEffects: false`); `--dep <pkg>` (workspace runtime dep, tiled→
tilemap style, repeatable/comma-list); `--description`; `--no-offline-smoke`.
Version + peer range derive from Core's package.json. `--dep` also threads any
`*-source` custom condition a dep needs (e.g. particles' `#` imports).

Verified end-to-end: library + register+dep + multi-dep modes all generate a
package that `tsc --noEmit` passes, wire all three SoT files, and the smoke
artifacts revert cleanly. Written by a subagent; reviewed + re-smoke-tested
before commit.

Co-authored-by: Exoridus <github@codexo.de>
…#202)

The live playground reported `Module '@codexo/exojs' has no exported member
'Application'` (and ~10 similar). Root cause: the vendor declaration tree
ships Node `#`-subpath imports (`export * from '#core/index'`). Node and tsc
resolve `#` natively via package.json `imports`, so npm consumers are fine —
but Monaco's in-browser TypeScript worker does NOT apply the imports map, so
every `#`-imported symbol (Application, Scene, …) resolves to nothing. This
regressed silently when the `@/` → `#` migration deleted the old dts-alias
rewrite (Node didn't need it; the browser does).

sync-exo-vendor now rewrites each `#<path>` to a path relative to its
declaration file (per the root `imports` map, `#<path>` → `dist/esm/<path>`,
i.e. `<path>` within the copied esm tree), then **asserts no import-position
`#` survives** — a stray one silently breaks the playground, so this guard
fails the build loudly on every sync/deploy rather than shipping broken types.

Verified: re-sync rewrites 169 declarations; `esm/index.d.ts` is now relative
and `Application`/`ApplicationStatus` resolve through `./core/index →
./Application`. GLSL `"#version …"` strings, hex colours and sourcemap
comments are correctly not touched (guard checks import position only).

Co-authored-by: Exoridus <github@codexo.de>
)

Adds the optimised ExoJS brand SVGs (9 marks + wordmarks) under
site/public/brand/ and regenerates the full favicon/PWA icon set from the
contained dark mark (mark-e-dot-dark.svg — the bare mark can vanish on light
browser chrome):

- favicon.svg (the dark mark), favicon.ico (48/32/16), favicon-96x96.png,
  apple-touch-icon.png (180), icon-192.png, icon-512.png.
- SVGs optimised with a repo-root svgo.config.mjs that keeps viewBox /
  currentColor / a11y hooks and strips fixed dimensions; favicons rasterised
  via ImageMagick (RSVG) from a 1024 master. Recipe in brand/README.md.

Fixes the PWA manifest: its icon `src`s were absolute (`/web-app-manifest-…`)
which 404 under the `/ExoJS/` base — now relative (`icon-192.png` /
`icon-512.png`), resolved against the manifest URL. Removes the two orphan
web-app-manifest PNGs. The <link> tags in AppShell.astro already pointed at
these filenames, so no head change is needed.

Co-authored-by: Exoridus <github@codexo.de>
* fix(site): derive api subsystem enum from the single source of truth

The `subsystem` Zod enum in content.config.ts had drifted: it never gained
`physics` (v0.14), `aseprite`, or `ldtk` (#199), but the generated API pages for
those packages carry exactly those values. That broke the whole Astro build
(dev, build, and check) on the branch — `astro dev` failed validating
aseprite-format-error.mdx, with every physics page queued behind it.

api-reference.ts already lists all subsystems in API_SUBSYSTEM_ORDER (order +
labels). Derive the validation enum from it so the schema can never fall out of
sync with the package list again.

* fix(site): stop prose styles bleeding into guide chrome boxes

The chapter layout scoped its typographic rules (h2/h3/p/li/links) to
.chapter-prose, which wraps the whole article — including the GuideMeta boxes
("What you'll learn" / "Before you start"). Those boxes' <h2> headings inherited
the prose-h2 styling: a 56px top margin, top padding, and a "#" ::before glyph,
leaving a large empty gap and a stray "#" in each box; <li> picked up an extra
margin, loosening the bullets.

Scope the prose rules to .chapter-content (the rendered MDX body) instead.
GuideMeta sits outside it, so the bleed is gone — specificity-proof, without
touching GuideMeta.astro. The MDX body, lead, related-api, and pager are
unaffected.

Also bump the TryIt list gap from 4px to 8px so links that wrap to two lines
stay visually distinct.

---------

Co-authored-by: Exoridus <github@codexo.de>
Adds the Pocket Relic Bot as a waving corner greeter in the top-right of the
landing hero, peeking over the code panel. The source render (companion-hero.png,
flat magenta key) is run through the project's magenta→alpha chroma-key + despill,
resized to 600px, and shipped as a 36KB WebP in site/public/brand/.

- Decorative (aria-hidden); the headline carries the meaning.
- Gentle float/bob loop, disabled under prefers-reduced-motion.
- Anchored to the centered hero content so it tracks the panel on wide screens.
- Hidden below 1000px, where the hero stacks to one column.

Co-authored-by: Exoridus <github@codexo.de>
Code blocks always render on the dark `--color-code-bg` surface (dark in both
themes), but the syntax colour was bridged to the site's data-theme:
`html[data-theme='light']` forced `--shiki-light`, the light-on-light variant —
dark text on the dark panel, i.e. unreadable in the light theme. This hit every
code surface: the home hero/quickstart panels, guide MDX blocks, and snippets.

Pin the syntax colours to `--shiki-dark` regardless of site theme, matching the
always-dark surface. Dual-theme `<Code>`/markdown output still defines the var.

Co-authored-by: Exoridus <github@codexo.de>
#207)

The "On this page" rail used an IntersectionObserver with a narrow
`-20% 0px -65% 0px` band: a heading only went active while inside the top
20–35% of the viewport. Headings near the page bottom never scroll high enough
to enter that band, so the final section(s) never activated — at the very bottom
the rail stayed stuck on whichever heading was last in the band (often the first).

Replace it with a scroll-position computation: the active heading is the last one
whose top has crossed a line ~28% down the viewport, and the last heading is
forced once scrolled to the page bottom. rAF-throttled scroll/resize listeners.

Verified via Playwright at top / middle / bottom — correct heading active in each,
exactly one active at a time.

Co-authored-by: Exoridus <github@codexo.de>
`onChange={value => ...}` left `value` implicitly `any` — the only error
`astro check` reported on the site. Annotate it `string | undefined` (the
@monaco-editor/react OnChange signature). No behaviour change; `astro check`
is now clean.

Co-authored-by: Exoridus <github@codexo.de>
Lands five package guides into the guide IA, each with API-verified code blocks:

- assets/aseprite, assets/ldtk — new chapters in the Assets part
- physics/physics-basics, physics/joints-and-dynamics — new Physics part
- integrations/react — new Integrations part

guide-structure.ts gains the two new parts and the two Assets chapters with
levels, learning goals, prerequisites, and API links (all resolving to existing
API pages). tsconfig.guides.json gains source paths for @codexo/exojs-{physics,
aseprite,ldtk} (+/register for the two extensions) so the extracted snippets
type-check against engine source.

Verified: typecheck:guides green (82 snippets), guide-structure reconciliation
test 19/19, and the new pages render (HTTP 200, sidebar shows Physics +
Integrations, no console errors).

Co-authored-by: Exoridus <github@codexo.de>
Reworks the guide Callout into the eight Pocket Relic Bot states from the
companion design brief: info, hint, warning, pitfall, success, debug, invalid,
broken. Each callout now pairs the painterly companion sprite (a trailing-edge
badge with a state-coloured screen-glow) with a state-coloured glyph + label —
glyph and label carry the meaning, so the callout still reads via shape and
text, not colour alone.

- 8 transparent companion sprites keyed + optimised to ~6 KB WebP each in
  site/public/brand/companion/.
- Colour exclusivity per the brief: amber=warning, mint=success, gray=invalid,
  red=broken; info/hint/pitfall/debug share the companion cyan and are told
  apart by glyph. State colours are fixed, so they stay stable across the
  user-selectable accent themes.
- Migrates the existing usages to the new states: common-mistake→pitfall,
  tip→hint, and the two browser callouts → info / warning by meaning.

Verified all eight states render cleanly in light and dark (Playwright), with no
sprite halo on either theme; astro check 0 errors. The right-rail "Need a hand?"
helper card is a deliberate follow-up.

Co-authored-by: Exoridus <github@codexo.de>
…#211)

Follow-up to the 8-state callouts (#210). The first pass used the wrong, stale
sprite source and rendered the companion too small.

- Source: slice the 8 states from the production 8-state sheet (exojs-companion-12),
  which carries the finished faces + state glyphs — not the raw faceless bodies.
- Crop to the upper body (head + torso, where the expression lives) and seat it
  large on the callout's bottom edge, legs clipped — matching the mockup scale.
- Flip the companion to face inward toward the text; pitfall and debug keep their
  native orientation so their ?/[ ] face glyphs don't mirror.
- Swap the weak text-in-thin-circle glyph for proper Lucide-style SVG icons in a
  soft state-tinted chip; sentence-case titles.

Verified all eight states light + dark and side-by-side against the canonical
mockup (scale / upper-body crop / inward orientation now match); astro check 0.

Co-authored-by: Exoridus <github@codexo.de>
Monaco only registered core @codexo/exojs typings, so every extension example reported ts2307 'Cannot find module'. Emit a per-extension typings manifest + monaco-registry (virtual package.json, subpath shims, '#'-import rewrite) in the vendor sync, and load them in EditorCode alongside core.
The iframe canvas lives in the iframe realm, so 'instanceof HTMLCanvasElement' (parent realm) was always false and the --canvas-w/h/zoom sizing never ran. Use a realm-safe null check, and scale the canvas to the real panel width instead of leaving it at native size.
Replace the CSS text placeholder ('e' tile + 'ExoJS' text) with the inline, theme-aware exo.js wordmark (currentColor + var(--accent)).
Clicking a TOC link jumps the heading to the top, which the scroll heuristic alone could attribute to the next section. Add a click handler that highlights the clicked link immediately.
…ompanion

Animated example thumbnails (new ExampleThumb), hero grid-drift/glow + cap-strip pulse, example cards now link straight to the playground, a shorter tween-based hero snippet, and the companion mascot moves into the live-example section.
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

Exoridus and others added 3 commits June 28, 2026 10:34
The particles package imports '@codexo/exojs/renderer-sdk' (RenderBackendType) at runtime, but the entry was missing from preview.html's import-map, so every particles example threw 'Failed to resolve module specifier'. Added the local + CDN entries.
…typecheck

exojs-ldtk pulls in exojs-tilemap source (via the @codexo/exojs-tilemap path),
which imports @codexo/exojs/renderer-sdk. ldtk's tsconfig lacked that mapping
(tilemap's has it), so the source-condition typecheck fell back to node
resolution: green locally with a built dist, TS2307 on CI (no dist), cascading
into ~25 follow-on errors. Mirror tilemap's path mappings.
* fix(examples): batch performance-overlay sprites via a Container

The overlay rendered 1600 sprites with one context.render() per sprite,
emitting one draw call each. Adding them to a Container and rendering it
once batches them into a single draw call (7.8ms -> 1.8ms on the spike).

* test(rendering): add cross-call sprite batching regression test (red)

* perf(rendering): frame-scoped draw-plan lifecycle for cross-call batching

setView flushes only on real view change; the transform buffer resets once
per frame; the plan builder bases node indices at the frame buffer count;
nested plans isolate their rows. Per-call renders now batch (1000 -> 1 draw).
Leaves the barrier-path allocation gate red — fixed in the next commit.

* perf(rendering): delta-upload transform texture rows per flush

A frame-scoped buffer made barrier flushes re-upload a growing buffer
(O(N^2)). Uploading only [uploadedRows, count) per flush via commitRect makes
it O(N) while keeping the cross-frame hash-guard skip. Fixes the effect-barrier
gate.

* test(rendering): per-call render output matches Container render

* fix(rendering): upload exact dirty transform-row range, not a high-water mark

Task 3's delta upload tracked only the highest uploaded row, so a slot reused
below that mark (a filter composite reusing a row a nested plan had rewound)
was never re-uploaded, leaving stale transform data — the filter-boundary
browser test rendered the wrong color. Track the exact written-slot range
[dirtyMin, dirtyMax] in TransformBuffer instead; the delta upload pushes
precisely the changed rows regardless of reuse. Restores filter-boundary
(browser 149/149), keeps effect-barrier under budget and cross-call batching.

* perf(rendering): WebGPU parity for frame-scoped cross-call batching

Mirror the WebGl2 backend's Tasks 2-4 lifecycle changes onto WebGPU:

- TransformBuffer is now frame-scoped (reset in resetStats, not per plan)
- Add transformBufferCount getter so RenderPlanBuilder offsets node indices
  correctly for WebGPU (previously fell back to 0 -> no cross-call batching)
- _beginDrawPlan: push base/hash stacks instead of resetting; reserve is
  based on frame-global count + plan nodes to avoid mid-frame reallocations
- _endDrawPlan: pop stacks; nested plans flush + rewindTo to free their rows
- setView: conditional flush (only on real view change) to stop breaking
  batches on every render() call that re-applies the same camera view
- WebGpuTransformStorage.getBuffer: delta upload via consumeDirtyRange
  instead of full-buffer writeBuffer on every flush boundary

* fix(webgpu): full re-upload after storage-buffer grow; remove dead begin()

After _growBuffer creates a new empty GPUBuffer, set _needsFullUpload=true.
In getBuffer, always consumeDirtyRange first (clears stale range), then
branch: full [0,count) upload when _needsFullUpload, else delta rowCount>0.
Mirrors WebGl2's full-upload-on-grow so mid-frame reallocated slots are never
read as uninitialized transforms by the shader.

Also removes the dead begin(nodeCount) wrapper — callers use buffer.begin() directly.

* fix(webgpu): restore begin() wrapper + fix RT-display test for frame-scoped slots

The prior commit removed WebGpuTransformStorage.begin() as dead code, but it has
~25 test call sites (30 tests broke). Restore it. Also the webgpu-backend
RenderTexture+Sprite test asserted the sprite transform in slot 0, but with
frame-scoped batching the graphics-into-RT is slot 0 and the sprite lands in
slot 1 — read slot 1 (transform verified: tx=24, ty=18 present after the
full-upload-on-grow). Full exojs project green (2510); no other regressions.

Process note: the exojs unit project (test/**) was not run during the earlier
tasks — only rendering-perf + browser-webgl; this surfaced both issues.

* chore(rendering): fix import order + prettier formatting

Autofix: sort the playRenderTree import in the perf harness; prettier-format
WebGpuTransformStorage after the begin() restore. verify:quick green.

* test(particles): seed frame-scoped batching stacks in WebGpuBackend mock

The particle GPU-injection test mocks the backend via Object.create(prototype),
bypassing the constructor that initializes _planBaseStack/_planHashStack (used by
_beginDrawPlan since the cross-call batching work). Seed them like the existing
device mock. Full test suite green (3609).

* docs(rendering): update stale comments + add TransformBuffer dirty-range tests

- RenderingContext: setView flushes only on view change (not unconditionally);
  correctness rests on trailing flush() and renderer-switch flushes
- RenderInstruction: nodeIndex is frame-global [frameBase, frameBase+nodeCount),
  not plan-local [0, nodeCount)
- WebGpuTransformStorage: clarify consumeDirtyRange is inside the upload branch only;
  add upload-guard note explaining why a skipped flush is safe
- WebGl2Backend: same upload-guard safety note as WebGpu counterpart
- test: 6 new TransformBuffer dirty-range cases (consumeDirtyRange sentinel,
  coverage+self-clearing, below-HWM reuse, clamping, rewindTo, begin reset)

---------

Co-authored-by: Exoridus <github@codexo.de>
@Exoridus Exoridus enabled auto-merge June 28, 2026 17:44
@Exoridus Exoridus merged commit a9bb85e into main Jun 28, 2026
12 of 13 checks passed
@Exoridus Exoridus deleted the feat/v0.15 branch June 28, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant